diff --git a/app/game-api/src/auth/accessTokenStore.ts b/app/game-api/src/auth/accessTokenStore.ts index 030fb46..1cd52ac 100644 --- a/app/game-api/src/auth/accessTokenStore.ts +++ b/app/game-api/src/auth/accessTokenStore.ts @@ -6,6 +6,7 @@ import { isValid, parseISO } from 'date-fns'; interface RedisClientLike { get(key: string): Promise; set(key: string, value: string, options?: { EX?: number; NX?: boolean }): Promise; + del?(key: string): Promise; } const ACCESS_TOKEN_PREFIX = 'ga_'; @@ -72,6 +73,17 @@ export class RedisAccessTokenStore { } } + async revoke(accessToken: string): Promise { + 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 { if (ttlSeconds <= 0) { return false; diff --git a/app/game-api/src/context.ts b/app/game-api/src/context.ts index dc09885..62d160e 100644 --- a/app/game-api/src/context.ts +++ b/app/game-api/src/context.ts @@ -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, diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index b24b6fd..a6d92a2 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -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 ( diff --git a/app/game-api/src/server.ts b/app/game-api/src/server.ts index e005910..662642d 100644 --- a/app/game-api/src/server.ts +++ b/app/game-api/src/server.ts @@ -196,6 +196,7 @@ export const createGameApiServer = async () => { uploadPath: config.uploadPath, uploadPublicUrl: config.uploadPublicUrl, auth, + ...(auth && token ? { accessToken: token } : {}), accessTokenStore, flushStore, gameTokenSecret: config.gameTokenSecret, diff --git a/app/game-api/test/accessTokenStore.test.ts b/app/game-api/test/accessTokenStore.test.ts new file mode 100644 index 0000000..f6ca747 --- /dev/null +++ b/app/game-api/test/accessTokenStore.test.ts @@ -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(); + }); +}); diff --git a/app/game-api/test/inGameMenuPermissions.test.ts b/app/game-api/test/inGameMenuPermissions.test.ts index abb11a1..fdd0dc3 100644 --- a/app/game-api/test/inGameMenuPermissions.test.ts +++ b/app/game-api/test/inGameMenuPermissions.test.ts @@ -78,6 +78,7 @@ const createContext = (options: { targets?: GeneralRow[]; nationMeta?: Record; requestCommand?: ReturnType; + 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 => 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 });