feat: implement user session management with Redis and add game token verification

This commit is contained in:
2025-12-30 01:55:40 +00:00
parent 29523ef22f
commit f01a21f2f0
28 changed files with 477 additions and 27 deletions
+69
View File
@@ -0,0 +1,69 @@
export interface GatewayUserFlushEvent {
userId: string;
flushedAt: string;
reason?: string;
}
export interface FlushStore {
getFlushedAt(userId: string): Date | null;
applyFlush(event: GatewayUserFlushEvent): void;
}
export class InMemoryFlushStore implements FlushStore {
private readonly flushedAtByUser = new Map<string, Date>();
getFlushedAt(userId: string): Date | null {
return this.flushedAtByUser.get(userId) ?? null;
}
applyFlush(event: GatewayUserFlushEvent): void {
const parsed = new Date(event.flushedAt);
if (Number.isNaN(parsed.getTime())) {
return;
}
const existing = this.flushedAtByUser.get(event.userId);
if (!existing || parsed > existing) {
this.flushedAtByUser.set(event.userId, parsed);
}
}
}
export class RedisGatewayFlushSubscriber {
private readonly client: {
subscribe: (channel: string, listener: (message: string) => void) => Promise<void>;
unsubscribe: (channel: string) => Promise<void>;
};
private readonly channel: string;
private readonly store: FlushStore;
constructor(
client: {
subscribe: (channel: string, listener: (message: string) => void) => Promise<void>;
unsubscribe: (channel: string) => Promise<void>;
},
channel: string,
store: FlushStore
) {
this.client = client;
this.channel = channel;
this.store = store;
}
async start(): Promise<void> {
await this.client.subscribe(this.channel, (message) => {
try {
const payload = JSON.parse(message) as GatewayUserFlushEvent;
if (!payload || typeof payload.userId !== 'string') {
return;
}
this.store.applyFlush(payload);
} catch {
return;
}
});
}
async stop(): Promise<void> {
await this.client.unsubscribe(this.channel);
}
}
+47
View File
@@ -0,0 +1,47 @@
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken.js';
import { decryptGameSessionToken } from '@sammo-ts/common/auth/gameToken.js';
import type { FlushStore } from './flushStore.js';
export interface GameTokenVerifier {
verify(token: string): GameSessionTokenPayload | null;
}
const parseDate = (value: string): Date | null => {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return null;
}
return parsed;
};
export const createGameTokenVerifier = (options: {
secret: string;
profileName: string;
flushStore: FlushStore;
}): GameTokenVerifier => {
return {
verify: (token: string): GameSessionTokenPayload | null => {
const payload = decryptGameSessionToken(token, options.secret);
if (!payload) {
return null;
}
if (payload.profile !== options.profileName) {
return null;
}
const expiresAt = parseDate(payload.expiresAt);
const issuedAt = parseDate(payload.issuedAt);
if (!expiresAt || !issuedAt) {
return null;
}
if (Date.now() > expiresAt.getTime()) {
return null;
}
const flushedAt = options.flushStore.getFlushedAt(payload.user.id);
if (flushedAt && issuedAt <= flushedAt) {
return null;
}
return payload;
},
};
};