feat: add access token management and gateway token exchange functionality

This commit is contained in:
2026-01-16 19:23:40 +00:00
parent 9c85a50645
commit b5f53a7c42
10 changed files with 378 additions and 33 deletions
+119
View File
@@ -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<string | null>;
set(key: string, value: string, options?: { EX?: number; NX?: boolean }): Promise<string | null>;
}
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<GameSessionTokenPayload>;
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<GameSessionTokenPayload['user']>;
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<GameSessionTokenPayload | null> {
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<boolean> {
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';
}
}
+11
View File
@@ -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,
};
};
+2
View File
@@ -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,
+99
View File
@@ -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,
};
}),
});
+14 -8
View File
@@ -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,
});
},
},
+12
View File
@@ -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',
};
};
+64 -25
View File
@@ -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<boolean> {
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';
+8
View File
@@ -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;
}
@@ -27,6 +27,7 @@ const notice = ref('');
const profiles = ref<LobbyProfile[]>([]);
const profileDetails = ref<Record<string, LobbyInfo | undefined>>({});
const profileMapPreviews = ref<Record<string, MapPreviewBundle | undefined>>({});
const entryLoading = ref<Record<string, boolean>>({});
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;
}
};
</script>
<template>
@@ -186,12 +229,16 @@ const handleLogout = async () => {
<button
v-if="profileDetails[profile.profileName]?.myGeneral"
class="w-full bg-zinc-700 hover:bg-zinc-600 text-white py-1.5 rounded text-sm transition-colors"
:disabled="entryLoading[profile.profileName]"
@click="handleEnter(profile, '/')"
>
입장
</button>
<button
v-else
class="w-full bg-zinc-700 hover:bg-zinc-600 text-white py-1.5 rounded text-sm transition-colors"
:disabled="entryLoading[profile.profileName]"
@click="handleEnter(profile, '/join')"
>
장수생성
</button>
@@ -135,10 +135,12 @@
- 게임 API: `join.getConfig`, `join.createGeneral`, `join.listPossessCandidates`, `join.possessGeneral` 추가
- Public 화면 구현: 캐시 지도/중원 정세/세력 일람/제한 장수 일람 구성
- Public API: `public.getCachedMap`, `public.getWorldTrend`, `public.getNationList`, `public.getGeneralList` 추가
- Gateway → Game handoff: 게이트웨이에서 `gameToken` 발급 후 게임 프론트에서 1회 교환(access token)하는 흐름 추가
## Next Frontend Tasks
- 게이트웨이 로그인/프로필 선택 플로우 정리 (토큰 전달 방식, 자동 로그인, 쿠키 기반 전환 고려)
- 게이트웨이/게임 프론트 도메인 경로(`VITE_GAME_WEB_URL`) 확정 및 운영 배포 경로 문서화
- 실시간 업데이트(SSE) 연결 설계 및 메인 화면 토글과 연동
- MapViewer 비주얼 보강: 레거시 테마/아이콘/맵 배경 스타일 상세 이식
- 레거시 이미지 서빙 위치 확정 및 SPA 배포 시 정적 경로 매핑