fix(game-api): revoke token after prestart deletion

This commit is contained in:
2026-07-31 17:42:03 +00:00
parent 21d204c749
commit 596d01b6f4
6 changed files with 78 additions and 2 deletions
+12
View File
@@ -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;
+3
View File
@@ -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,
+3
View File
@@ -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 (
+1
View File
@@ -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 });