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,
});
},
},