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';
}
}