From b5f53a7c4204aa1738aa443b2e153f765d908aef Mon Sep 17 00:00:00 2001 From: Hide_D Date: Fri, 16 Jan 2026 19:23:40 +0000 Subject: [PATCH] feat: add access token management and gateway token exchange functionality --- app/game-api/src/auth/accessTokenStore.ts | 119 +++++++++++++++++++ app/game-api/src/context.ts | 11 ++ app/game-api/src/router.ts | 2 + app/game-api/src/router/auth/index.ts | 99 +++++++++++++++ app/game-api/src/server.ts | 22 ++-- app/game-api/test/router.test.ts | 12 ++ app/game-frontend/src/stores/session.ts | 89 ++++++++++---- app/gateway-frontend/src/env.d.ts | 8 ++ app/gateway-frontend/src/views/LobbyView.vue | 47 ++++++++ docs/architecture/game-frontend-spa-plan.md | 2 + 10 files changed, 378 insertions(+), 33 deletions(-) create mode 100644 app/game-api/src/auth/accessTokenStore.ts create mode 100644 app/game-api/src/router/auth/index.ts diff --git a/app/game-api/src/auth/accessTokenStore.ts b/app/game-api/src/auth/accessTokenStore.ts new file mode 100644 index 0000000..b69322f --- /dev/null +++ b/app/game-api/src/auth/accessTokenStore.ts @@ -0,0 +1,119 @@ +import { randomUUID } from 'node:crypto'; + +import type { GameSessionTokenPayload } from '@sammo-ts/common'; +import { isValid, parseISO } from 'date-fns'; + +interface RedisClientLike { + get(key: string): Promise; + set(key: string, value: string, options?: { EX?: number; NX?: boolean }): Promise; +} + +const ACCESS_TOKEN_PREFIX = 'ga_'; + +const buildAccessKey = (profileName: string, token: string): string => + `sammo:game:access:${profileName}:${token}`; + +const buildGatewayUsedKey = (profileName: string, sessionId: string): string => + `sammo:game:gateway-used:${profileName}:${sessionId}`; + +const parsePayload = (value: unknown): GameSessionTokenPayload | null => { + if (!value || typeof value !== 'object') { + return null; + } + const payload = value as Partial; + if (payload.version !== 1) { + return null; + } + if (typeof payload.profile !== 'string') { + return null; + } + if (typeof payload.issuedAt !== 'string' || typeof payload.expiresAt !== 'string') { + return null; + } + if (typeof payload.sessionId !== 'string') { + return null; + } + if (!payload.user || typeof payload.user !== 'object') { + return null; + } + const user = payload.user as Partial; + if ( + typeof user.id !== 'string' || + typeof user.username !== 'string' || + typeof user.displayName !== 'string' || + !Array.isArray(user.roles) + ) { + return null; + } + if (!payload.sanctions || typeof payload.sanctions !== 'object') { + return null; + } + return payload as GameSessionTokenPayload; +}; + +const resolveTtlSeconds = (expiresAt: string): number => { + const parsed = parseISO(expiresAt); + if (!isValid(parsed)) { + return 0; + } + const ttl = Math.floor((parsed.getTime() - Date.now()) / 1000); + return ttl > 0 ? ttl : 0; +}; + +export class RedisAccessTokenStore { + private readonly client: RedisClientLike; + private readonly profileName: string; + + constructor(client: RedisClientLike, profileName: string) { + this.client = client; + this.profileName = profileName; + } + + static isAccessToken(token: string): boolean { + return token.startsWith(ACCESS_TOKEN_PREFIX); + } + + async create(payload: GameSessionTokenPayload): Promise<{ accessToken: string; expiresAt: string } | null> { + const ttlSeconds = resolveTtlSeconds(payload.expiresAt); + if (ttlSeconds <= 0) { + return null; + } + const accessToken = `${ACCESS_TOKEN_PREFIX}${randomUUID()}`; + const key = buildAccessKey(this.profileName, accessToken); + await this.client.set(key, JSON.stringify(payload), { EX: ttlSeconds }); + return { accessToken, expiresAt: payload.expiresAt }; + } + + async get(accessToken: string): Promise { + if (!RedisAccessTokenStore.isAccessToken(accessToken)) { + return null; + } + const key = buildAccessKey(this.profileName, accessToken); + const raw = await this.client.get(key); + if (!raw) { + return null; + } + try { + const payload = parsePayload(JSON.parse(raw)); + if (!payload) { + return null; + } + const ttl = resolveTtlSeconds(payload.expiresAt); + if (ttl <= 0) { + return null; + } + return payload; + } catch { + return null; + } + } + + 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/context.ts b/app/game-api/src/context.ts index ad7bf77..f874446 100644 --- a/app/game-api/src/context.ts +++ b/app/game-api/src/context.ts @@ -4,6 +4,8 @@ import type { DatabaseClient as InfraDatabaseClient, RedisConnector, GamePrisma import type { TurnDaemonTransport } from './daemon/transport.js'; import type { BattleSimTransport } from './battleSim/transport.js'; +import type { FlushStore } from './auth/flushStore.js'; +import type { RedisAccessTokenStore } from './auth/accessTokenStore.js'; export interface GameProfile { id: string; @@ -48,6 +50,9 @@ export interface GameApiContext { battleSim: BattleSimTransport; profile: GameProfile; auth: GameSessionTokenPayload | null; + accessTokenStore: RedisAccessTokenStore; + flushStore: FlushStore; + gameTokenSecret: string; } export const createGameApiContext = (options: { @@ -57,6 +62,9 @@ export const createGameApiContext = (options: { battleSim: BattleSimTransport; profile: GameProfile; auth: GameSessionTokenPayload | null; + accessTokenStore: RedisAccessTokenStore; + flushStore: FlushStore; + gameTokenSecret: string; }): GameApiContext => { return { db: options.db, @@ -65,5 +73,8 @@ export const createGameApiContext = (options: { battleSim: options.battleSim, profile: options.profile, auth: options.auth, + accessTokenStore: options.accessTokenStore, + flushStore: options.flushStore, + gameTokenSecret: options.gameTokenSecret, }; }; diff --git a/app/game-api/src/router.ts b/app/game-api/src/router.ts index b6cee6b..652ad19 100644 --- a/app/game-api/src/router.ts +++ b/app/game-api/src/router.ts @@ -1,6 +1,7 @@ import { router } from './trpc.js'; import { battleRouter } from './router/battle/index.js'; +import { authRouter } from './router/auth/index.js'; import { generalRouter } from './router/general/index.js'; import { healthRouter } from './router/health/index.js'; import { joinRouter } from './router/join/index.js'; @@ -15,6 +16,7 @@ import { worldRouter } from './router/world/index.js'; export const appRouter = router({ health: healthRouter, + auth: authRouter, lobby: lobbyRouter, public: publicRouter, join: joinRouter, diff --git a/app/game-api/src/router/auth/index.ts b/app/game-api/src/router/auth/index.ts new file mode 100644 index 0000000..252222c --- /dev/null +++ b/app/game-api/src/router/auth/index.ts @@ -0,0 +1,99 @@ +import { TRPCError } from '@trpc/server'; +import { decryptGameSessionToken } from '@sammo-ts/common'; +import { isAfter, isValid, parseISO } from 'date-fns'; +import { z } from 'zod'; + +import type { GameSessionTokenPayload } from '@sammo-ts/common'; +import { procedure, router } from '../../trpc.js'; + +const parseDate = (value: string): Date | null => { + const parsed = parseISO(value); + return isValid(parsed) ? parsed : null; +}; + +const resolveTtlSeconds = (expiresAt: string): number => { + const parsed = parseDate(expiresAt); + if (!parsed) { + return 0; + } + const ttl = Math.floor((parsed.getTime() - Date.now()) / 1000); + return ttl > 0 ? ttl : 0; +}; + +const verifyGatewayToken = ( + token: string, + profileName: string, + secret: string +): GameSessionTokenPayload | null => { + const payload = decryptGameSessionToken(token, secret); + if (!payload) { + return null; + } + if (payload.profile !== profileName) { + return null; + } + const expiresAt = parseDate(payload.expiresAt); + const issuedAt = parseDate(payload.issuedAt); + if (!expiresAt || !issuedAt) { + return null; + } + if (isAfter(new Date(), expiresAt)) { + return null; + } + return payload; +}; + +export const authRouter = router({ + exchangeGatewayToken: procedure + .input(z.object({ gatewayToken: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + const payload = verifyGatewayToken( + input.gatewayToken, + ctx.profile.name, + ctx.gameTokenSecret + ); + if (!payload) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'Invalid gateway token.', + }); + } + const flushedAt = ctx.flushStore.getFlushedAt(payload.user.id); + if (flushedAt && new Date(payload.issuedAt) <= flushedAt) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'Gateway token revoked.', + }); + } + + const ttlSeconds = resolveTtlSeconds(payload.expiresAt); + if (ttlSeconds <= 0) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'Gateway token expired.', + }); + } + + const used = await ctx.accessTokenStore.markGatewayTokenUsed(payload.sessionId, ttlSeconds); + if (!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', + message: 'Failed to issue access token.', + }); + } + + return { + accessToken: created.accessToken, + expiresAt: created.expiresAt, + issuedAt: payload.issuedAt, + }; + }), +}); diff --git a/app/game-api/src/server.ts b/app/game-api/src/server.ts index 044f8e2..b759b39 100644 --- a/app/game-api/src/server.ts +++ b/app/game-api/src/server.ts @@ -13,7 +13,7 @@ import { createGameApiContext, type DatabaseClient as _DatabaseClient } from './ import { buildTurnDaemonStreamKeys } from './daemon/streamKeys.js'; import { RedisTurnDaemonTransport } from './daemon/redisTransport.js'; import { InMemoryFlushStore, RedisGatewayFlushSubscriber } from './auth/flushStore.js'; -import { createGameTokenVerifier } from './auth/tokenVerifier.js'; +import { RedisAccessTokenStore } from './auth/accessTokenStore.js'; import { appRouter } from './router.js'; import { buildBattleSimQueueKeys } from './battleSim/keys.js'; import { RedisBattleSimTransport } from './battleSim/redisTransport.js'; @@ -55,11 +55,7 @@ export const createGameApiServer = async () => { await flushSubscriberClient.connect(); const flushSubscriber = new RedisGatewayFlushSubscriber(flushSubscriberClient, config.flushChannel, flushStore); await flushSubscriber.start(); - const tokenVerifier = createGameTokenVerifier({ - secret: config.gameTokenSecret, - profileName: config.profileName, - flushStore, - }); + const accessTokenStore = new RedisAccessTokenStore(redis.client, config.profileName); const app = fastify({ logger: true, @@ -74,9 +70,16 @@ export const createGameApiServer = async () => { prefix: config.trpcPath, trpcOptions: { router: appRouter, - createContext: ({ req }: { req: FastifyRequest }) => { + createContext: async ({ req }: { req: FastifyRequest }) => { const token = extractBearerToken(req.headers.authorization); - const auth = token ? tokenVerifier.verify(token) : null; + let auth = null; + if (token) { + const stored = await accessTokenStore.get(token); + if (stored) { + const flushedAt = flushStore.getFlushedAt(stored.user.id); + auth = flushedAt && new Date(stored.issuedAt) <= flushedAt ? null : stored; + } + } return createGameApiContext({ db: postgres.prisma, redis: redis.client, @@ -88,6 +91,9 @@ export const createGameApiServer = async () => { name: config.profileName, }, auth, + accessTokenStore, + flushStore, + gameTokenSecret: config.gameTokenSecret, }); }, }, diff --git a/app/game-api/test/router.test.ts b/app/game-api/test/router.test.ts index 0e87826..570573d 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -4,6 +4,8 @@ import type { DatabaseClient, GameApiContext, GameProfile, WorldStateRow } from import type { RedisConnector } from '@sammo-ts/infra'; import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js'; import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js'; +import { InMemoryFlushStore } from '../src/auth/flushStore.js'; +import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; import { appRouter } from '../src/router.js'; const profile: GameProfile = { @@ -43,6 +45,13 @@ const buildContext = (options?: { createMany: async () => ({}), }, }; + const accessTokenStore = new RedisAccessTokenStore( + { + get: async () => null, + set: async () => null, + }, + profile.name + ); return { db: db as unknown as DatabaseClient, turnDaemon: transport, @@ -50,6 +59,9 @@ const buildContext = (options?: { profile, auth: null, redis: {} as unknown as RedisConnector['client'], + accessTokenStore, + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'test-secret', }; }; diff --git a/app/game-frontend/src/stores/session.ts b/app/game-frontend/src/stores/session.ts index 497ff18..6369a0f 100644 --- a/app/game-frontend/src/stores/session.ts +++ b/app/game-frontend/src/stores/session.ts @@ -23,6 +23,7 @@ interface SessionState { const SESSION_TOKEN_KEY = 'sammo-session-token'; const PROFILE_KEY = 'sammo-game-profile'; const GAME_TOKEN_KEY = 'sammo-game-token'; +const ACCESS_TOKEN_PREFIX = 'ga_'; const readStorage = (key: string): string | null => { if (typeof window === 'undefined') { @@ -56,6 +57,13 @@ const readQueryParam = (key: string): string | null => { return value; }; +const isAccessToken = (token: string | null): boolean => { + if (!token) { + return false; + } + return token.startsWith(ACCESS_TOKEN_PREFIX); +}; + export const useSessionStore = defineStore('session', { state: (): SessionState => ({ status: 'unknown', @@ -96,6 +104,13 @@ export const useSessionStore = defineStore('session', { this.status = 'authed'; return; } + if (!isAccessToken(this.gameToken)) { + const exchanged = await this.exchangeGatewayToken(); + if (!exchanged) { + this.status = this.sessionToken ? 'authed' : 'public'; + return; + } + } try { const lobby = await gameTrpc.lobby.info.query(); this.status = lobby.myGeneral ? 'general' : 'authed'; @@ -103,6 +118,22 @@ export const useSessionStore = defineStore('session', { this.error = 'game_status_unavailable'; } }, + async exchangeGatewayToken(): Promise { + if (!this.gameToken || isAccessToken(this.gameToken)) { + return true; + } + try { + const exchanged = await gameTrpc.auth.exchangeGatewayToken.mutate({ + gatewayToken: this.gameToken, + }); + this.setGameToken(exchanged.accessToken); + return true; + } catch { + this.error = 'game_token_exchange_failed'; + this.setGameToken(null); + return false; + } + }, async initialize() { if (this.initializing || this.status !== 'unknown') { return; @@ -121,6 +152,11 @@ export const useSessionStore = defineStore('session', { this.setProfile(profileFromQuery); } + const gatewayTokenFromQuery = readQueryParam('gameToken'); + if (gatewayTokenFromQuery) { + this.setGameToken(gatewayTokenFromQuery); + } + const storedToken = this.sessionToken ?? readStorage(SESSION_TOKEN_KEY); if (storedToken && storedToken !== this.sessionToken) { this.setSessionToken(storedToken); @@ -131,30 +167,34 @@ export const useSessionStore = defineStore('session', { this.setProfile(storedProfile); } - if (!this.sessionToken) { + if (!this.sessionToken && !this.gameToken) { this.status = 'public'; this.initializing = false; return; } - try { - const me = await gatewayTrpc.me.query(); - if (!me) { - this.clearSession(); + if (this.sessionToken) { + try { + const me = await gatewayTrpc.me.query(); + if (!me) { + this.clearSession(); + this.initializing = false; + return; + } + this.user = { + id: me.id, + username: me.username, + displayName: me.displayName, + }; + this.status = 'authed'; + } catch { + this.error = 'gateway_unavailable'; + this.status = 'public'; this.initializing = false; return; } - this.user = { - id: me.id, - username: me.username, - displayName: me.displayName, - }; + } else { this.status = 'authed'; - } catch { - this.error = 'gateway_unavailable'; - this.status = 'public'; - this.initializing = false; - return; } if (!this.profile) { @@ -163,17 +203,16 @@ export const useSessionStore = defineStore('session', { } try { - const sessionToken = this.sessionToken; - if (!sessionToken) { - this.status = 'public'; - this.initializing = false; - return; + if (!this.gameToken || !isAccessToken(this.gameToken)) { + const sessionToken = this.sessionToken; + if (sessionToken) { + const issued = await gatewayTrpc.auth.issueGameSession.mutate({ + sessionToken, + profile: this.profile, + }); + this.setGameToken(issued.gameToken); + } } - const issued = await gatewayTrpc.auth.issueGameSession.mutate({ - sessionToken, - profile: this.profile, - }); - this.setGameToken(issued.gameToken); await this.refreshGeneralStatus(); } catch { this.error = 'game_session_unavailable'; diff --git a/app/gateway-frontend/src/env.d.ts b/app/gateway-frontend/src/env.d.ts index a699b8a..b24d3c1 100644 --- a/app/gateway-frontend/src/env.d.ts +++ b/app/gateway-frontend/src/env.d.ts @@ -5,3 +5,11 @@ declare module '*.vue' { const component: DefineComponent<{}, {}, any>; export default component; } + +interface ImportMetaEnv { + readonly VITE_GAME_WEB_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/app/gateway-frontend/src/views/LobbyView.vue b/app/gateway-frontend/src/views/LobbyView.vue index 4a66ba1..2124e52 100644 --- a/app/gateway-frontend/src/views/LobbyView.vue +++ b/app/gateway-frontend/src/views/LobbyView.vue @@ -27,6 +27,7 @@ const notice = ref(''); const profiles = ref([]); const profileDetails = ref>({}); const profileMapPreviews = ref>({}); +const entryLoading = ref>({}); onMounted(async () => { try { @@ -75,6 +76,48 @@ const handleLogout = async () => { // await trpc.auth.logout.mutation(); await router.push('/'); }; + +const resolveGameUrl = (path: string, profileName: string, gameToken: string): string | null => { + const baseUrl = import.meta.env.VITE_GAME_WEB_URL ?? ''; + if (!baseUrl) { + return null; + } + const base = new URL(baseUrl, window.location.origin); + const normalizedPath = path.replace(/^\//, ''); + const url = new URL(normalizedPath, base); + url.searchParams.set('profile', profileName); + url.searchParams.set('gameToken', gameToken); + return url.toString(); +}; + +const handleEnter = async (profile: LobbyProfile, targetPath: string) => { + if (entryLoading.value[profile.profileName]) { + return; + } + const sessionToken = window.localStorage.getItem('sammo-session-token'); + if (!sessionToken) { + await router.push('/'); + return; + } + entryLoading.value[profile.profileName] = true; + try { + const issued = await trpc.auth.issueGameSession.mutate({ + sessionToken, + profile: profile.profileName, + }); + const url = resolveGameUrl(targetPath, issued.profile, issued.gameToken); + if (!url) { + alert('게임 프론트엔드 주소가 설정되지 않았습니다.'); + return; + } + window.location.href = url; + } catch (e) { + console.error('Failed to issue game session', e); + alert('게임 서버 접속에 실패했습니다.'); + } finally { + entryLoading.value[profile.profileName] = false; + } +};