fix(game-api): revoke token after prestart deletion
This commit is contained in:
@@ -6,6 +6,7 @@ import { isValid, parseISO } from 'date-fns';
|
||||
interface RedisClientLike {
|
||||
get(key: string): Promise<string | null>;
|
||||
set(key: string, value: string, options?: { EX?: number; NX?: boolean }): Promise<string | null>;
|
||||
del?(key: string): Promise<number>;
|
||||
}
|
||||
|
||||
const ACCESS_TOKEN_PREFIX = 'ga_';
|
||||
@@ -72,6 +73,17 @@ export class RedisAccessTokenStore {
|
||||
}
|
||||
}
|
||||
|
||||
async revoke(accessToken: string): Promise<boolean> {
|
||||
if (!RedisAccessTokenStore.isAccessToken(accessToken)) {
|
||||
return false;
|
||||
}
|
||||
if (!this.client.del) {
|
||||
throw new Error('Redis client does not support access token revocation.');
|
||||
}
|
||||
const key = buildAccessKey(this.profileName, accessToken);
|
||||
return (await this.client.del(key)) > 0;
|
||||
}
|
||||
|
||||
async markGatewayTokenUsed(sessionId: string, ttlSeconds: number): Promise<boolean> {
|
||||
if (ttlSeconds <= 0) {
|
||||
return false;
|
||||
|
||||
@@ -83,6 +83,7 @@ export interface GameApiContext {
|
||||
uploadPath: string;
|
||||
uploadPublicUrl: string | null;
|
||||
auth: GameSessionTokenPayload | null;
|
||||
accessToken?: string;
|
||||
accessTokenStore: RedisAccessTokenStore;
|
||||
flushStore: FlushStore;
|
||||
gameTokenSecret: string;
|
||||
@@ -100,6 +101,7 @@ export const createGameApiContext = (options: {
|
||||
uploadPath: string;
|
||||
uploadPublicUrl: string | null;
|
||||
auth: GameSessionTokenPayload | null;
|
||||
accessToken?: string;
|
||||
accessTokenStore: RedisAccessTokenStore;
|
||||
flushStore: FlushStore;
|
||||
gameTokenSecret: string;
|
||||
@@ -117,6 +119,7 @@ export const createGameApiContext = (options: {
|
||||
uploadPath: options.uploadPath,
|
||||
uploadPublicUrl: options.uploadPublicUrl,
|
||||
auth: options.auth,
|
||||
...(options.accessToken ? { accessToken: options.accessToken } : {}),
|
||||
accessTokenStore: options.accessTokenStore,
|
||||
flushStore: options.flushStore,
|
||||
gameTokenSecret: options.gameTokenSecret,
|
||||
|
||||
@@ -86,6 +86,9 @@ const requestImmediateAction = async (
|
||||
if (!result.ok) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
|
||||
}
|
||||
if (action === 'dieOnPrestart' && ctx.accessToken) {
|
||||
await ctx.accessTokenStore.revoke(ctx.accessToken);
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
if (
|
||||
|
||||
@@ -196,6 +196,7 @@ export const createGameApiServer = async () => {
|
||||
uploadPath: config.uploadPath,
|
||||
uploadPublicUrl: config.uploadPublicUrl,
|
||||
auth,
|
||||
...(auth && token ? { accessToken: token } : {}),
|
||||
accessTokenStore,
|
||||
flushStore,
|
||||
gameTokenSecret: config.gameTokenSecret,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||
|
||||
describe('RedisAccessTokenStore.revoke', () => {
|
||||
it('deletes only a profile-scoped game access token key', async () => {
|
||||
const del = vi.fn(async () => 1);
|
||||
const store = new RedisAccessTokenStore(
|
||||
{
|
||||
get: async () => null,
|
||||
set: async () => null,
|
||||
del,
|
||||
},
|
||||
'che:default'
|
||||
);
|
||||
|
||||
await expect(store.revoke('ga_current')).resolves.toBe(true);
|
||||
expect(del).toHaveBeenCalledWith('sammo:game:access:che:default:ga_current');
|
||||
});
|
||||
|
||||
it('does not delete a gateway or malformed token', async () => {
|
||||
const del = vi.fn(async () => 1);
|
||||
const store = new RedisAccessTokenStore(
|
||||
{
|
||||
get: async () => null,
|
||||
set: async () => null,
|
||||
del,
|
||||
},
|
||||
'che:default'
|
||||
);
|
||||
|
||||
await expect(store.revoke('gateway-token')).resolves.toBe(false);
|
||||
expect(del).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -78,6 +78,7 @@ const createContext = (options: {
|
||||
targets?: GeneralRow[];
|
||||
nationMeta?: Record<string, unknown>;
|
||||
requestCommand?: ReturnType<typeof vi.fn>;
|
||||
accessToken?: string;
|
||||
}) => {
|
||||
const me = options.me === undefined ? buildGeneral() : options.me;
|
||||
const targets = options.targets ?? (me ? [me] : []);
|
||||
@@ -121,6 +122,9 @@ const createContext = (options: {
|
||||
},
|
||||
};
|
||||
const redisClient = { get: async () => null, set: async () => null };
|
||||
const accessTokenStore = new RedisAccessTokenStore(redisClient, 'che:default');
|
||||
const revokeAccessToken = vi.fn(async (_accessToken: string): Promise<boolean> => true);
|
||||
vi.spyOn(accessTokenStore, 'revoke').mockImplementation(revokeAccessToken);
|
||||
const context: GameApiContext = {
|
||||
db: db as unknown as DatabaseClient,
|
||||
redis: {} as RedisConnector['client'],
|
||||
@@ -131,11 +135,12 @@ const createContext = (options: {
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:default'),
|
||||
...(options.accessToken ? { accessToken: options.accessToken } : {}),
|
||||
accessTokenStore,
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
gameTokenSecret: 'test-secret',
|
||||
};
|
||||
return { context, db, requestCommand };
|
||||
return { context, db, requestCommand, revokeAccessToken };
|
||||
};
|
||||
|
||||
describe('in-game my information ownership', () => {
|
||||
@@ -333,6 +338,23 @@ describe('in-game my information ownership', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('revokes only the current game access token after a successful prestart deletion', async () => {
|
||||
const requestCommand = vi.fn(async () => ({ type: 'dieOnPrestart', ok: true, generalId: 7 }));
|
||||
const fixture = createContext({ requestCommand, accessToken: 'ga_current' });
|
||||
|
||||
await expect(appRouter.createCaller(fixture.context).general.dieOnPrestart()).resolves.toEqual({ ok: true });
|
||||
expect(fixture.revokeAccessToken).toHaveBeenCalledOnce();
|
||||
expect(fixture.revokeAccessToken).toHaveBeenCalledWith('ga_current');
|
||||
});
|
||||
|
||||
it('keeps the current game access token after another successful immediate action', async () => {
|
||||
const requestCommand = vi.fn(async () => ({ type: 'instantRetreat', ok: true, generalId: 7 }));
|
||||
const fixture = createContext({ requestCommand, accessToken: 'ga_current' });
|
||||
|
||||
await expect(appRouter.createCaller(fixture.context).general.instantRetreat()).resolves.toEqual({ ok: true });
|
||||
expect(fixture.revokeAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects deletion with the legacy no-general message before dispatching a daemon command', async () => {
|
||||
const requestCommand = vi.fn();
|
||||
const fixture = createContext({ me: null, requestCommand });
|
||||
|
||||
Reference in New Issue
Block a user