feat: add access token management and gateway token exchange functionality
This commit is contained in:
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user