diff --git a/app/game-api/src/auth/accessTokenStore.ts b/app/game-api/src/auth/accessTokenStore.ts index 1cd52ac..717251a 100644 --- a/app/game-api/src/auth/accessTokenStore.ts +++ b/app/game-api/src/auth/accessTokenStore.ts @@ -7,6 +7,7 @@ interface RedisClientLike { get(key: string): Promise; set(key: string, value: string, options?: { EX?: number; NX?: boolean }): Promise; del?(key: string): Promise; + eval?(script: string, options: { keys: string[]; arguments: string[] }): Promise; } const ACCESS_TOKEN_PREFIX = 'ga_'; @@ -16,6 +17,15 @@ const buildAccessKey = (profileName: string, token: string): string => `sammo:ga const buildGatewayUsedKey = (profileName: string, sessionId: string): string => `sammo:game:gateway-used:${profileName}:${sessionId}`; +const ISSUE_FROM_GATEWAY_SCRIPT = ` +if redis.call('EXISTS', KEYS[1]) == 1 then + return 0 +end +redis.call('SET', KEYS[2], ARGV[1], 'EX', ARGV[2]) +redis.call('SET', KEYS[1], '1', 'EX', ARGV[2]) +return 1 +`; + const resolveTtlSeconds = (expiresAt: string): number => { const parsed = parseISO(expiresAt); if (!isValid(parsed)) { @@ -49,6 +59,32 @@ export class RedisAccessTokenStore { return { accessToken, expiresAt: payload.expiresAt }; } + async issueFromGateway( + payload: GameSessionTokenPayload + ): Promise<{ accessToken: string; expiresAt: string } | null | 'ALREADY_USED'> { + const ttlSeconds = resolveTtlSeconds(payload.expiresAt); + if (ttlSeconds <= 0) { + return null; + } + if (!this.client.eval) { + throw new Error('Redis client does not support atomic gateway token exchange.'); + } + const accessToken = `${ACCESS_TOKEN_PREFIX}${randomUUID()}`; + const accessKey = buildAccessKey(this.profileName, accessToken); + const usedKey = buildGatewayUsedKey(this.profileName, payload.sessionId); + const result = await this.client.eval(ISSUE_FROM_GATEWAY_SCRIPT, { + keys: [usedKey, accessKey], + arguments: [JSON.stringify(payload), String(ttlSeconds)], + }); + if (Number(result) === 0) { + return 'ALREADY_USED'; + } + if (Number(result) !== 1) { + throw new Error('Unexpected Redis result while issuing an access token.'); + } + return { accessToken, expiresAt: payload.expiresAt }; + } + async get(accessToken: string): Promise { if (!RedisAccessTokenStore.isAccessToken(accessToken)) { return null; @@ -83,13 +119,4 @@ export class RedisAccessTokenStore { 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; - } - const key = buildGatewayUsedKey(this.profileName, sessionId); - const result = await this.client.set(key, '1', { NX: true, EX: ttlSeconds }); - return result === 'OK'; - } } diff --git a/app/game-api/src/router/auth/index.ts b/app/game-api/src/router/auth/index.ts index 9a3bbe6..f2c0c3a 100644 --- a/app/game-api/src/router/auth/index.ts +++ b/app/game-api/src/router/auth/index.ts @@ -87,15 +87,13 @@ export const authRouter = router({ await enqueueProfileIconResetForUser(ctx, payload.user.id, payload.user.profileIconResetAt); } - const used = await ctx.accessTokenStore.markGatewayTokenUsed(payload.sessionId, ttlSeconds); - if (!used) { + const created = await ctx.accessTokenStore.issueFromGateway(payload); + if (created === 'ALREADY_USED') { throw new TRPCError({ code: 'CONFLICT', message: 'Gateway token already used.', }); } - - const created = await ctx.accessTokenStore.create(payload); if (!created) { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', diff --git a/app/game-api/test/accessTokenStore.test.ts b/app/game-api/test/accessTokenStore.test.ts index f6ca747..2a1f506 100644 --- a/app/game-api/test/accessTokenStore.test.ts +++ b/app/game-api/test/accessTokenStore.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; + import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; describe('RedisAccessTokenStore.revoke', () => { @@ -33,3 +35,69 @@ describe('RedisAccessTokenStore.revoke', () => { expect(del).not.toHaveBeenCalled(); }); }); + +describe('RedisAccessTokenStore.issueFromGateway', () => { + const payload = { + sessionId: 'gateway-session-1', + profile: 'che:default', + issuedAt: '2099-01-01T00:00:00.000Z', + expiresAt: '2099-01-01T01:00:00.000Z', + user: { id: 'user-1', username: 'tester' }, + roles: [], + sanctions: [], + } as unknown as GameSessionTokenPayload; + + it('issues the access key and consumes the gateway token in one Redis evaluation', async () => { + const evalCommand = vi.fn(async (_script: string, _options: { keys: string[]; arguments: string[] }) => 1); + const store = new RedisAccessTokenStore( + { + get: async () => null, + set: async () => null, + eval: evalCommand, + }, + 'che:default' + ); + + const issued = await store.issueFromGateway(payload); + + expect(issued).toMatchObject({ expiresAt: payload.expiresAt }); + expect(issued && issued !== 'ALREADY_USED' ? issued.accessToken : '').toMatch(/^ga_/); + expect(evalCommand).toHaveBeenCalledTimes(1); + expect(evalCommand.mock.calls[0]?.[1].keys[0]).toBe('sammo:game:gateway-used:che:default:gateway-session-1'); + expect(evalCommand.mock.calls[0]?.[1].keys[1]).toBe( + `sammo:game:access:che:default:${issued && issued !== 'ALREADY_USED' ? issued.accessToken : ''}` + ); + }); + + it('can retry after an atomic Redis evaluation fails before commit', async () => { + const keys = new Set(); + let attempts = 0; + const evalCommand = vi.fn(async (_script: string, options: { keys: string[] }) => { + attempts += 1; + if (attempts === 1) { + throw new Error('injected Redis failure'); + } + if (keys.has(options.keys[0] ?? '')) { + return 0; + } + keys.add(options.keys[0] ?? ''); + keys.add(options.keys[1] ?? ''); + return 1; + }); + const store = new RedisAccessTokenStore( + { + get: async () => null, + set: async () => null, + eval: evalCommand, + }, + 'che:default' + ); + + await expect(store.issueFromGateway(payload)).rejects.toThrow('injected Redis failure'); + expect(keys.size).toBe(0); + await expect(store.issueFromGateway(payload)).resolves.toMatchObject({ expiresAt: payload.expiresAt }); + expect([...keys].filter((key) => key.includes(':access:'))).toHaveLength(1); + expect([...keys].filter((key) => key.includes(':gateway-used:'))).toHaveLength(1); + await expect(store.issueFromGateway(payload)).resolves.toBe('ALREADY_USED'); + }); +}); diff --git a/app/game-api/test/router.test.ts b/app/game-api/test/router.test.ts index dca6e75..bb58f50 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -309,8 +309,7 @@ describe('appRouter', () => { throw new Error('ordinary exchange must not read the icon source'); }); const accessTokenStore = { - markGatewayTokenUsed: vi.fn(async () => true), - create: vi.fn(async () => ({ + issueFromGateway: vi.fn(async () => ({ accessToken: 'ga_access', expiresAt: '2099-01-01T01:00:00.000Z', })), @@ -342,14 +341,13 @@ describe('appRouter', () => { const calls: string[] = []; const revision = '2099-01-01T00:00:00.001Z'; const accessTokenStore = { - markGatewayTokenUsed: vi.fn(async () => { + issueFromGateway: vi.fn(async () => { calls.push('mark-used'); - return true; + return { + accessToken: 'ga_access', + expiresAt: '2099-01-01T01:00:00.000Z', + }; }), - create: vi.fn(async () => ({ - accessToken: 'ga_access', - expiresAt: '2099-01-01T01:00:00.000Z', - })), } as unknown as RedisAccessTokenStore; const payload = buildAuth(); payload.issuedAt = '2099-01-01T00:00:00.000Z';