feat: implement user session management with Redis and add game token verification
This commit is contained in:
@@ -17,6 +17,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^10.0.1",
|
"@fastify/cors": "^10.0.1",
|
||||||
|
"@sammo-ts/common": "workspace:*",
|
||||||
"@sammo-ts/infra": "workspace:*",
|
"@sammo-ts/infra": "workspace:*",
|
||||||
"@trpc/server": "^11.4.4",
|
"@trpc/server": "^11.4.4",
|
||||||
"fastify": "^5.3.3",
|
"fastify": "^5.3.3",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -6,6 +6,8 @@ export interface GameApiConfig {
|
|||||||
scenario: string;
|
scenario: string;
|
||||||
profileName: string;
|
profileName: string;
|
||||||
daemonRequestTimeoutMs: number;
|
daemonRequestTimeoutMs: number;
|
||||||
|
gameTokenSecret: string;
|
||||||
|
flushChannel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseNumber = (value: string | undefined, fallback: number, label: string): number => {
|
const parseNumber = (value: string | undefined, fallback: number, label: string): number => {
|
||||||
@@ -25,6 +27,11 @@ export const resolveGameApiConfigFromEnv = (
|
|||||||
const profile = env.PROFILE ?? env.SERVER_PROFILE ?? 'che';
|
const profile = env.PROFILE ?? env.SERVER_PROFILE ?? 'che';
|
||||||
const scenario = env.SCENARIO ?? 'default';
|
const scenario = env.SCENARIO ?? 'default';
|
||||||
const profileName = `${profile}:${scenario}`;
|
const profileName = `${profile}:${scenario}`;
|
||||||
|
const secret = env.GAME_TOKEN_SECRET ?? env.GATEWAY_TOKEN_SECRET ?? '';
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error('GAME_TOKEN_SECRET is required for game token verification.');
|
||||||
|
}
|
||||||
|
const gatewayPrefix = env.GATEWAY_REDIS_PREFIX ?? 'sammo:gateway';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
host: env.GAME_API_HOST ?? '0.0.0.0',
|
host: env.GAME_API_HOST ?? '0.0.0.0',
|
||||||
@@ -38,5 +45,7 @@ export const resolveGameApiConfigFromEnv = (
|
|||||||
5000,
|
5000,
|
||||||
'DAEMON_REQUEST_TIMEOUT_MS'
|
'DAEMON_REQUEST_TIMEOUT_MS'
|
||||||
),
|
),
|
||||||
|
gameTokenSecret: secret,
|
||||||
|
flushChannel: `${gatewayPrefix}:flush`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken.js';
|
||||||
|
|
||||||
import type { TurnDaemonTransport } from './daemon/transport.js';
|
import type { TurnDaemonTransport } from './daemon/transport.js';
|
||||||
|
|
||||||
export interface GameProfile {
|
export interface GameProfile {
|
||||||
@@ -26,16 +28,19 @@ export interface GameApiContext {
|
|||||||
db: DatabaseClient;
|
db: DatabaseClient;
|
||||||
turnDaemon: TurnDaemonTransport;
|
turnDaemon: TurnDaemonTransport;
|
||||||
profile: GameProfile;
|
profile: GameProfile;
|
||||||
|
auth: GameSessionTokenPayload | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createGameApiContext = (options: {
|
export const createGameApiContext = (options: {
|
||||||
db: DatabaseClient;
|
db: DatabaseClient;
|
||||||
turnDaemon: TurnDaemonTransport;
|
turnDaemon: TurnDaemonTransport;
|
||||||
profile: GameProfile;
|
profile: GameProfile;
|
||||||
|
auth: GameSessionTokenPayload | null;
|
||||||
}): GameApiContext => {
|
}): GameApiContext => {
|
||||||
return {
|
return {
|
||||||
db: options.db,
|
db: options.db,
|
||||||
turnDaemon: options.turnDaemon,
|
turnDaemon: options.turnDaemon,
|
||||||
profile: options.profile,
|
profile: options.profile,
|
||||||
|
auth: options.auth,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import type { RedisClientType } from 'redis';
|
|
||||||
|
|
||||||
import type { TurnDaemonStreamKeys } from './streamKeys.js';
|
import type { TurnDaemonStreamKeys } from './streamKeys.js';
|
||||||
import type { TurnDaemonTransport } from './transport.js';
|
import type { TurnDaemonTransport } from './transport.js';
|
||||||
import type {
|
import type {
|
||||||
@@ -16,6 +14,14 @@ interface RedisTurnDaemonTransportOptions {
|
|||||||
requestTimeoutMs: number;
|
requestTimeoutMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RedisClientLike {
|
||||||
|
xAdd(stream: string, id: string, message: Record<string, string>): Promise<string>;
|
||||||
|
xRead(
|
||||||
|
streams: { key: string; id: string },
|
||||||
|
options?: { BLOCK?: number; COUNT?: number }
|
||||||
|
): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
type RedisStreamReadResponse = Array<{
|
type RedisStreamReadResponse = Array<{
|
||||||
name: string;
|
name: string;
|
||||||
messages: Array<{ id: string; message: Record<string, string> }>;
|
messages: Array<{ id: string; message: Record<string, string> }>;
|
||||||
@@ -50,11 +56,11 @@ const parseEventEnvelope = (raw: string): TurnDaemonEventEnvelope | null => {
|
|||||||
|
|
||||||
// 턴 데몬 제어 스트림을 Redis로 구현한 전송기.
|
// 턴 데몬 제어 스트림을 Redis로 구현한 전송기.
|
||||||
export class RedisTurnDaemonTransport implements TurnDaemonTransport {
|
export class RedisTurnDaemonTransport implements TurnDaemonTransport {
|
||||||
private readonly client: RedisClientType;
|
private readonly client: RedisClientLike;
|
||||||
private readonly keys: TurnDaemonStreamKeys;
|
private readonly keys: TurnDaemonStreamKeys;
|
||||||
private readonly requestTimeoutMs: number;
|
private readonly requestTimeoutMs: number;
|
||||||
|
|
||||||
constructor(client: RedisClientType, options: RedisTurnDaemonTransportOptions) {
|
constructor(client: RedisClientLike, options: RedisTurnDaemonTransportOptions) {
|
||||||
this.client = client;
|
this.client = client;
|
||||||
this.keys = options.keys;
|
this.keys = options.keys;
|
||||||
this.requestTimeoutMs = options.requestTimeoutMs;
|
this.requestTimeoutMs = options.requestTimeoutMs;
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export * from './daemon/streamKeys.js';
|
|||||||
export * from './daemon/transport.js';
|
export * from './daemon/transport.js';
|
||||||
export * from './daemon/inMemoryTransport.js';
|
export * from './daemon/inMemoryTransport.js';
|
||||||
export * from './daemon/redisTransport.js';
|
export * from './daemon/redisTransport.js';
|
||||||
|
export * from './auth/flushStore.js';
|
||||||
|
export * from './auth/tokenVerifier.js';
|
||||||
|
|
||||||
const isMain = (): boolean => {
|
const isMain = (): boolean => {
|
||||||
if (!process.argv[1]) {
|
if (!process.argv[1]) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import fastify from 'fastify';
|
import fastify, { type FastifyRequest } from 'fastify';
|
||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||||
import {
|
import {
|
||||||
@@ -12,8 +12,25 @@ import { resolveGameApiConfigFromEnv } from './config.js';
|
|||||||
import { createGameApiContext } from './context.js';
|
import { createGameApiContext } from './context.js';
|
||||||
import { buildTurnDaemonStreamKeys } from './daemon/streamKeys.js';
|
import { buildTurnDaemonStreamKeys } from './daemon/streamKeys.js';
|
||||||
import { RedisTurnDaemonTransport } from './daemon/redisTransport.js';
|
import { RedisTurnDaemonTransport } from './daemon/redisTransport.js';
|
||||||
|
import { InMemoryFlushStore, RedisGatewayFlushSubscriber } from './auth/flushStore.js';
|
||||||
|
import { createGameTokenVerifier } from './auth/tokenVerifier.js';
|
||||||
import { appRouter } from './router.js';
|
import { appRouter } from './router.js';
|
||||||
|
|
||||||
|
const extractBearerToken = (value: string | string[] | undefined): string | null => {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const header = Array.isArray(value) ? value[0] : value;
|
||||||
|
if (!header) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const prefix = 'Bearer ';
|
||||||
|
if (header.startsWith(prefix)) {
|
||||||
|
return header.slice(prefix.length).trim();
|
||||||
|
}
|
||||||
|
return header.trim();
|
||||||
|
};
|
||||||
|
|
||||||
export const createGameApiServer = async () => {
|
export const createGameApiServer = async () => {
|
||||||
const config = resolveGameApiConfigFromEnv();
|
const config = resolveGameApiConfigFromEnv();
|
||||||
const postgres = createPostgresConnector(resolvePostgresConfigFromEnv());
|
const postgres = createPostgresConnector(resolvePostgresConfigFromEnv());
|
||||||
@@ -26,6 +43,20 @@ export const createGameApiServer = async () => {
|
|||||||
keys: buildTurnDaemonStreamKeys(config.profileName),
|
keys: buildTurnDaemonStreamKeys(config.profileName),
|
||||||
requestTimeoutMs: config.daemonRequestTimeoutMs,
|
requestTimeoutMs: config.daemonRequestTimeoutMs,
|
||||||
});
|
});
|
||||||
|
const flushStore = new InMemoryFlushStore();
|
||||||
|
const flushSubscriberClient = redis.client.duplicate();
|
||||||
|
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 app = fastify({
|
const app = fastify({
|
||||||
logger: true,
|
logger: true,
|
||||||
@@ -40,8 +71,10 @@ export const createGameApiServer = async () => {
|
|||||||
prefix: config.trpcPath,
|
prefix: config.trpcPath,
|
||||||
trpcOptions: {
|
trpcOptions: {
|
||||||
router: appRouter,
|
router: appRouter,
|
||||||
createContext: () =>
|
createContext: ({ req }: { req: FastifyRequest }) => {
|
||||||
createGameApiContext({
|
const token = extractBearerToken(req.headers.authorization);
|
||||||
|
const auth = token ? tokenVerifier.verify(token) : null;
|
||||||
|
return createGameApiContext({
|
||||||
db: postgres.prisma,
|
db: postgres.prisma,
|
||||||
turnDaemon,
|
turnDaemon,
|
||||||
profile: {
|
profile: {
|
||||||
@@ -49,7 +82,9 @@ export const createGameApiServer = async () => {
|
|||||||
scenario: config.scenario,
|
scenario: config.scenario,
|
||||||
name: config.profileName,
|
name: config.profileName,
|
||||||
},
|
},
|
||||||
}),
|
auth,
|
||||||
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,6 +94,8 @@ export const createGameApiServer = async () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
app.addHook('onClose', async () => {
|
app.addHook('onClose', async () => {
|
||||||
|
await flushSubscriber.stop();
|
||||||
|
await flushSubscriberClient.quit();
|
||||||
await redis.disconnect();
|
await redis.disconnect();
|
||||||
await postgres.disconnect();
|
await postgres.disconnect();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { initTRPC } from '@trpc/server';
|
import { initTRPC, TRPCError } from '@trpc/server';
|
||||||
|
|
||||||
import type { GameApiContext } from './context.js';
|
import type { GameApiContext } from './context.js';
|
||||||
|
|
||||||
@@ -6,3 +6,17 @@ const t = initTRPC.context<GameApiContext>().create();
|
|||||||
|
|
||||||
export const router = t.router;
|
export const router = t.router;
|
||||||
export const procedure = t.procedure;
|
export const procedure = t.procedure;
|
||||||
|
export const authedProcedure = t.procedure.use(({ ctx, next }) => {
|
||||||
|
if (!ctx.auth) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
message: 'Unauthorized',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return next({
|
||||||
|
ctx: {
|
||||||
|
...ctx,
|
||||||
|
auth: ctx.auth,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const buildContext = (options?: {
|
|||||||
db,
|
db,
|
||||||
turnDaemon: transport,
|
turnDaemon: transport,
|
||||||
profile,
|
profile,
|
||||||
|
auth: null,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"references": [
|
"references": [
|
||||||
|
{ "path": "../../packages/common" },
|
||||||
{ "path": "../../packages/infra" }
|
{ "path": "../../packages/infra" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^10.0.1",
|
"@fastify/cors": "^10.0.1",
|
||||||
|
"@sammo-ts/common": "workspace:*",
|
||||||
"@sammo-ts/infra": "workspace:*",
|
"@sammo-ts/infra": "workspace:*",
|
||||||
"@trpc/server": "^11.4.4",
|
"@trpc/server": "^11.4.4",
|
||||||
"fastify": "^5.3.3",
|
"fastify": "^5.3.3",
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export interface GatewayUserFlushEvent {
|
||||||
|
userId: string;
|
||||||
|
flushedAt: string;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatewayFlushPublisher {
|
||||||
|
publishUserFlush(userId: string, reason?: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RedisGatewayFlushPublisher implements GatewayFlushPublisher {
|
||||||
|
private readonly channel: string;
|
||||||
|
private readonly client: { publish: (channel: string, message: string) => Promise<number> };
|
||||||
|
|
||||||
|
constructor(client: { publish: (channel: string, message: string) => Promise<number> }, channel: string) {
|
||||||
|
this.client = client;
|
||||||
|
this.channel = channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
async publishUserFlush(userId: string, reason?: string): Promise<void> {
|
||||||
|
const payload: GatewayUserFlushEvent = {
|
||||||
|
userId,
|
||||||
|
flushedAt: new Date().toISOString(),
|
||||||
|
reason,
|
||||||
|
};
|
||||||
|
await this.client.publish(this.channel, JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,9 @@ export class InMemoryGatewaySessionService implements GatewaySessionService {
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
displayName: user.displayName,
|
displayName: user.displayName,
|
||||||
|
roles: user.roles,
|
||||||
|
sanctions: user.sanctions,
|
||||||
|
createdAt: user.createdAt,
|
||||||
issuedAt: new Date().toISOString(),
|
issuedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
this.sessions.set(sessionToken, {
|
this.sessions.set(sessionToken, {
|
||||||
@@ -95,6 +98,9 @@ export class InMemoryGatewaySessionService implements GatewaySessionService {
|
|||||||
userId: session.userId,
|
userId: session.userId,
|
||||||
username: session.username,
|
username: session.username,
|
||||||
displayName: session.displayName,
|
displayName: session.displayName,
|
||||||
|
roles: session.roles,
|
||||||
|
sanctions: session.sanctions,
|
||||||
|
createdAt: session.createdAt,
|
||||||
issuedAt: new Date().toISOString(),
|
issuedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
const key = buildGameKey(profile, gameToken);
|
const key = buildGameKey(profile, gameToken);
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export const createInMemoryUserRepository = (
|
|||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
username: input.username,
|
username: input.username,
|
||||||
displayName: input.displayName ?? input.username,
|
displayName: input.displayName ?? input.username,
|
||||||
|
roles: ['user'],
|
||||||
|
sanctions: {},
|
||||||
passwordSalt: salt,
|
passwordSalt: salt,
|
||||||
passwordHash: hasher.hash(input.password, salt),
|
passwordHash: hasher.hash(input.password, salt),
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ export class RedisGatewaySessionService implements GatewaySessionService {
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
displayName: user.displayName,
|
displayName: user.displayName,
|
||||||
|
roles: user.roles,
|
||||||
|
sanctions: user.sanctions,
|
||||||
|
createdAt: user.createdAt,
|
||||||
issuedAt: new Date().toISOString(),
|
issuedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
await this.client.set(this.keys.sessionKey(sessionToken), JSON.stringify(info), {
|
await this.client.set(this.keys.sessionKey(sessionToken), JSON.stringify(info), {
|
||||||
@@ -114,6 +117,9 @@ export class RedisGatewaySessionService implements GatewaySessionService {
|
|||||||
userId: session.userId,
|
userId: session.userId,
|
||||||
username: session.username,
|
username: session.username,
|
||||||
displayName: session.displayName,
|
displayName: session.displayName,
|
||||||
|
roles: session.roles,
|
||||||
|
sanctions: session.sanctions,
|
||||||
|
createdAt: session.createdAt,
|
||||||
issuedAt: new Date().toISOString(),
|
issuedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
const gameKey = this.keys.gameSessionKey(profile, gameToken);
|
const gameKey = this.keys.gameSessionKey(profile, gameToken);
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import type { UserRecord } from './userRepository.js';
|
import type { UserRecord, UserSanctions } from './userRepository.js';
|
||||||
|
|
||||||
export interface GatewaySessionInfo {
|
export interface GatewaySessionInfo {
|
||||||
sessionToken: string;
|
sessionToken: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
username: string;
|
username: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
roles: string[];
|
||||||
|
sanctions: UserSanctions;
|
||||||
|
createdAt: string;
|
||||||
issuedAt: string;
|
issuedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,6 +18,9 @@ export interface GameSessionInfo {
|
|||||||
userId: string;
|
userId: string;
|
||||||
username: string;
|
username: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
roles: string[];
|
||||||
|
sanctions: UserSanctions;
|
||||||
|
createdAt: string;
|
||||||
issuedAt: string;
|
issuedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ export interface UserRecord {
|
|||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
roles: string[];
|
||||||
|
sanctions: UserSanctions;
|
||||||
passwordHash: string;
|
passwordHash: string;
|
||||||
passwordSalt: string;
|
passwordSalt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -11,13 +13,24 @@ export interface PublicUser {
|
|||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
roles: string[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserSanctions {
|
||||||
|
bannedUntil?: string;
|
||||||
|
mutedUntil?: string;
|
||||||
|
suspendedUntil?: string;
|
||||||
|
warningCount?: number;
|
||||||
|
flags?: string[];
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const toPublicUser = (user: UserRecord): PublicUser => ({
|
export const toPublicUser = (user: UserRecord): PublicUser => ({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
displayName: user.displayName,
|
displayName: user.displayName,
|
||||||
|
roles: user.roles,
|
||||||
createdAt: user.createdAt,
|
createdAt: user.createdAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ export interface GatewayApiConfig {
|
|||||||
port: number;
|
port: number;
|
||||||
trpcPath: string;
|
trpcPath: string;
|
||||||
redisKeyPrefix: string;
|
redisKeyPrefix: string;
|
||||||
|
flushChannel: string;
|
||||||
sessionTtlSeconds: number;
|
sessionTtlSeconds: number;
|
||||||
gameSessionTtlSeconds: number;
|
gameSessionTtlSeconds: number;
|
||||||
|
gameTokenSecret: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseNumber = (value: string | undefined, fallback: number, label: string): number => {
|
const parseNumber = (value: string | undefined, fallback: number, label: string): number => {
|
||||||
@@ -21,16 +23,23 @@ const parseNumber = (value: string | undefined, fallback: number, label: string)
|
|||||||
export const resolveGatewayApiConfigFromEnv = (
|
export const resolveGatewayApiConfigFromEnv = (
|
||||||
env: NodeJS.ProcessEnv = process.env
|
env: NodeJS.ProcessEnv = process.env
|
||||||
): GatewayApiConfig => {
|
): GatewayApiConfig => {
|
||||||
|
const secret = env.GAME_TOKEN_SECRET ?? env.GATEWAY_TOKEN_SECRET ?? '';
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error('GAME_TOKEN_SECRET is required for gateway token encryption.');
|
||||||
|
}
|
||||||
|
const redisKeyPrefix = env.GATEWAY_REDIS_PREFIX ?? 'sammo:gateway';
|
||||||
return {
|
return {
|
||||||
host: env.GATEWAY_API_HOST ?? '0.0.0.0',
|
host: env.GATEWAY_API_HOST ?? '0.0.0.0',
|
||||||
port: parseNumber(env.GATEWAY_API_PORT, 13000, 'GATEWAY_API_PORT'),
|
port: parseNumber(env.GATEWAY_API_PORT, 13000, 'GATEWAY_API_PORT'),
|
||||||
trpcPath: env.TRPC_PATH ?? '/trpc',
|
trpcPath: env.TRPC_PATH ?? '/trpc',
|
||||||
redisKeyPrefix: env.GATEWAY_REDIS_PREFIX ?? 'sammo:gateway',
|
redisKeyPrefix,
|
||||||
|
flushChannel: `${redisKeyPrefix}:flush`,
|
||||||
sessionTtlSeconds: parseNumber(env.SESSION_TTL_SECONDS, 60 * 60 * 24 * 7, 'SESSION_TTL_SECONDS'),
|
sessionTtlSeconds: parseNumber(env.SESSION_TTL_SECONDS, 60 * 60 * 24 * 7, 'SESSION_TTL_SECONDS'),
|
||||||
gameSessionTtlSeconds: parseNumber(
|
gameSessionTtlSeconds: parseNumber(
|
||||||
env.GAME_SESSION_TTL_SECONDS,
|
env.GAME_SESSION_TTL_SECONDS,
|
||||||
60 * 60 * 6,
|
60 * 60 * 6,
|
||||||
'GAME_SESSION_TTL_SECONDS'
|
'GAME_SESSION_TTL_SECONDS'
|
||||||
),
|
),
|
||||||
|
gameTokenSecret: secret,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
|
import type { GatewayFlushPublisher } from './auth/flushPublisher.js';
|
||||||
import type { GatewaySessionService } from './auth/sessionService.js';
|
import type { GatewaySessionService } from './auth/sessionService.js';
|
||||||
import type { UserRepository } from './auth/userRepository.js';
|
import type { UserRepository } from './auth/userRepository.js';
|
||||||
|
|
||||||
export interface GatewayApiContext {
|
export interface GatewayApiContext {
|
||||||
users: UserRepository;
|
users: UserRepository;
|
||||||
sessions: GatewaySessionService;
|
sessions: GatewaySessionService;
|
||||||
|
flushPublisher: GatewayFlushPublisher;
|
||||||
|
gameTokenSecret: string;
|
||||||
|
gameSessionTtlSeconds: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createGatewayApiContext = (options: {
|
export const createGatewayApiContext = (options: {
|
||||||
users: UserRepository;
|
users: UserRepository;
|
||||||
sessions: GatewaySessionService;
|
sessions: GatewaySessionService;
|
||||||
|
flushPublisher: GatewayFlushPublisher;
|
||||||
|
gameTokenSecret: string;
|
||||||
|
gameSessionTtlSeconds: number;
|
||||||
}): GatewayApiContext => ({
|
}): GatewayApiContext => ({
|
||||||
users: options.users,
|
users: options.users,
|
||||||
sessions: options.sessions,
|
sessions: options.sessions,
|
||||||
|
flushPublisher: options.flushPublisher,
|
||||||
|
gameTokenSecret: options.gameTokenSecret,
|
||||||
|
gameSessionTtlSeconds: options.gameSessionTtlSeconds,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export * from './auth/sessionService.js';
|
|||||||
export * from './auth/inMemorySessionService.js';
|
export * from './auth/inMemorySessionService.js';
|
||||||
export * from './auth/redisSessionService.js';
|
export * from './auth/redisSessionService.js';
|
||||||
export * from './auth/redisKeys.js';
|
export * from './auth/redisKeys.js';
|
||||||
|
export * from './auth/flushPublisher.js';
|
||||||
|
|
||||||
const isMain = (): boolean => {
|
const isMain = (): boolean => {
|
||||||
if (!process.argv[1]) {
|
if (!process.argv[1]) {
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
decryptGameSessionToken,
|
||||||
|
encryptGameSessionToken,
|
||||||
|
} from '@sammo-ts/common/auth/gameToken.js';
|
||||||
|
|
||||||
import { procedure, router } from './trpc.js';
|
import { procedure, router } from './trpc.js';
|
||||||
import { toPublicUser } from './auth/userRepository.js';
|
import { toPublicUser } from './auth/userRepository.js';
|
||||||
|
|
||||||
@@ -8,6 +13,14 @@ const zUsername = z.string().min(2).max(32);
|
|||||||
const zPassword = z.string().min(6).max(128);
|
const zPassword = z.string().min(6).max(128);
|
||||||
const zProfile = z.string().min(1).max(64);
|
const zProfile = z.string().min(1).max(64);
|
||||||
|
|
||||||
|
const parseDate = (value: string): Date | null => {
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
};
|
||||||
|
|
||||||
export const appRouter = router({
|
export const appRouter = router({
|
||||||
health: router({
|
health: router({
|
||||||
ping: procedure.query(() => ({
|
ping: procedure.query(() => ({
|
||||||
@@ -105,7 +118,11 @@ export const appRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const session = await ctx.sessions.getSession(input.sessionToken);
|
||||||
await ctx.sessions.revokeSession(input.sessionToken, { revokeGames: true });
|
await ctx.sessions.revokeSession(input.sessionToken, { revokeGames: true });
|
||||||
|
if (session) {
|
||||||
|
await ctx.flushPublisher.publishUserFlush(session.userId, 'logout');
|
||||||
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}),
|
}),
|
||||||
issueGameSession: procedure
|
issueGameSession: procedure
|
||||||
@@ -126,12 +143,40 @@ export const appRouter = router({
|
|||||||
message: 'Session is not valid.',
|
message: 'Session is not valid.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const now = new Date();
|
||||||
|
const payload = {
|
||||||
|
version: 1,
|
||||||
|
profile: gameSession.profile,
|
||||||
|
issuedAt: now.toISOString(),
|
||||||
|
expiresAt: new Date(now.getTime() + 1000 * ctx.gameSessionTtlSeconds).toISOString(),
|
||||||
|
sessionId: gameSession.gameToken,
|
||||||
|
user: {
|
||||||
|
id: gameSession.userId,
|
||||||
|
username: gameSession.username,
|
||||||
|
displayName: gameSession.displayName,
|
||||||
|
roles: gameSession.roles,
|
||||||
|
createdAt: gameSession.createdAt,
|
||||||
|
},
|
||||||
|
sanctions: gameSession.sanctions,
|
||||||
|
} as const;
|
||||||
|
const gameToken = encryptGameSessionToken(payload, ctx.gameTokenSecret);
|
||||||
return {
|
return {
|
||||||
profile: gameSession.profile,
|
profile: gameSession.profile,
|
||||||
gameToken: gameSession.gameToken,
|
gameToken,
|
||||||
issuedAt: gameSession.issuedAt,
|
issuedAt: payload.issuedAt,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
flushUser: procedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
userId: z.string().min(1),
|
||||||
|
reason: z.string().min(1).optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
await ctx.flushPublisher.publishUserFlush(input.userId, input.reason);
|
||||||
|
return { ok: true };
|
||||||
|
}),
|
||||||
validateGameSession: procedure
|
validateGameSession: procedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -140,26 +185,26 @@ export const appRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const gameSession = await ctx.sessions.getGameSession(
|
const payload = decryptGameSessionToken(input.gameToken, ctx.gameTokenSecret);
|
||||||
input.profile,
|
if (!payload) {
|
||||||
input.gameToken
|
|
||||||
);
|
|
||||||
if (!gameSession) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const session = await ctx.sessions.getSession(gameSession.sessionToken);
|
if (payload.profile !== input.profile) {
|
||||||
if (!session) {
|
return null;
|
||||||
|
}
|
||||||
|
const expiresAt = parseDate(payload.expiresAt);
|
||||||
|
if (!expiresAt || Date.now() > expiresAt.getTime()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
profile: gameSession.profile,
|
profile: payload.profile,
|
||||||
sessionToken: gameSession.sessionToken,
|
sessionToken: payload.sessionId,
|
||||||
user: {
|
user: {
|
||||||
id: gameSession.userId,
|
id: payload.user.id,
|
||||||
username: gameSession.username,
|
username: payload.user.username,
|
||||||
displayName: gameSession.displayName,
|
displayName: payload.user.displayName,
|
||||||
},
|
},
|
||||||
issuedAt: gameSession.issuedAt,
|
issuedAt: payload.issuedAt,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra
|
|||||||
|
|
||||||
import { resolveGatewayApiConfigFromEnv } from './config.js';
|
import { resolveGatewayApiConfigFromEnv } from './config.js';
|
||||||
import { createGatewayApiContext } from './context.js';
|
import { createGatewayApiContext } from './context.js';
|
||||||
|
import { RedisGatewayFlushPublisher } from './auth/flushPublisher.js';
|
||||||
import { createInMemoryUserRepository } from './auth/inMemoryUserRepository.js';
|
import { createInMemoryUserRepository } from './auth/inMemoryUserRepository.js';
|
||||||
import { RedisGatewaySessionService } from './auth/redisSessionService.js';
|
import { RedisGatewaySessionService } from './auth/redisSessionService.js';
|
||||||
import { appRouter } from './router.js';
|
import { appRouter } from './router.js';
|
||||||
@@ -20,6 +21,7 @@ export const createGatewayApiServer = async () => {
|
|||||||
sessionTtlSeconds: config.sessionTtlSeconds,
|
sessionTtlSeconds: config.sessionTtlSeconds,
|
||||||
gameSessionTtlSeconds: config.gameSessionTtlSeconds,
|
gameSessionTtlSeconds: config.gameSessionTtlSeconds,
|
||||||
});
|
});
|
||||||
|
const flushPublisher = new RedisGatewayFlushPublisher(redis.client, config.flushChannel);
|
||||||
|
|
||||||
const app = fastify({
|
const app = fastify({
|
||||||
logger: true,
|
logger: true,
|
||||||
@@ -38,6 +40,9 @@ export const createGatewayApiServer = async () => {
|
|||||||
createGatewayApiContext({
|
createGatewayApiContext({
|
||||||
users,
|
users,
|
||||||
sessions,
|
sessions,
|
||||||
|
flushPublisher,
|
||||||
|
gameTokenSecret: config.gameTokenSecret,
|
||||||
|
gameSessionTtlSeconds: config.gameSessionTtlSeconds,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,18 @@ const buildCaller = () => {
|
|||||||
sessionTtlSeconds: 3600,
|
sessionTtlSeconds: 3600,
|
||||||
gameSessionTtlSeconds: 600,
|
gameSessionTtlSeconds: 600,
|
||||||
});
|
});
|
||||||
return appRouter.createCaller(createGatewayApiContext({ users, sessions }));
|
const flushPublisher = {
|
||||||
|
publishUserFlush: async () => {},
|
||||||
|
};
|
||||||
|
return appRouter.createCaller(
|
||||||
|
createGatewayApiContext({
|
||||||
|
users,
|
||||||
|
sessions,
|
||||||
|
flushPublisher,
|
||||||
|
gameTokenSecret: 'test-secret',
|
||||||
|
gameSessionTtlSeconds: 600,
|
||||||
|
})
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('gateway auth flow', () => {
|
describe('gateway auth flow', () => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"references": [
|
"references": [
|
||||||
|
{ "path": "../../packages/common" },
|
||||||
{ "path": "../../packages/infra" }
|
{ "path": "../../packages/infra" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
export interface UserSanctions {
|
||||||
|
bannedUntil?: string;
|
||||||
|
mutedUntil?: string;
|
||||||
|
suspendedUntil?: string;
|
||||||
|
warningCount?: number;
|
||||||
|
flags?: string[];
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatewayUserInfo {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
roles: string[];
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameSessionTokenPayload {
|
||||||
|
version: 1;
|
||||||
|
profile: string;
|
||||||
|
issuedAt: string;
|
||||||
|
expiresAt: string;
|
||||||
|
sessionId: string;
|
||||||
|
user: GatewayUserInfo;
|
||||||
|
sanctions: UserSanctions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toBase64Url = (data: Buffer): string => data.toString('base64url');
|
||||||
|
const fromBase64Url = (value: string): Buffer => Buffer.from(value, 'base64url');
|
||||||
|
|
||||||
|
const buildKey = (secret: string): Buffer =>
|
||||||
|
createHash('sha256').update(secret).digest();
|
||||||
|
|
||||||
|
export const encryptGameSessionToken = (
|
||||||
|
payload: GameSessionTokenPayload,
|
||||||
|
secret: string
|
||||||
|
): string => {
|
||||||
|
const iv = randomBytes(12);
|
||||||
|
const key = buildKey(secret);
|
||||||
|
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
||||||
|
const plaintext = Buffer.from(JSON.stringify(payload), 'utf8');
|
||||||
|
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
return `${toBase64Url(iv)}.${toBase64Url(ciphertext)}.${toBase64Url(tag)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<GatewayUserInfo>;
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const decryptGameSessionToken = (
|
||||||
|
token: string,
|
||||||
|
secret: string
|
||||||
|
): GameSessionTokenPayload | null => {
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const [ivPart, cipherPart, tagPart] = parts;
|
||||||
|
const iv = fromBase64Url(ivPart);
|
||||||
|
const ciphertext = fromBase64Url(cipherPart);
|
||||||
|
const tag = fromBase64Url(tagPart);
|
||||||
|
const key = buildKey(secret);
|
||||||
|
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString(
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
return parsePayload(JSON.parse(plaintext));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './rng.js';
|
export * from './rng.js';
|
||||||
|
export * from './auth/gameToken.js';
|
||||||
export * from './time/Clock.js';
|
export * from './time/Clock.js';
|
||||||
export * from './util/BytesLike.js';
|
export * from './util/BytesLike.js';
|
||||||
export * from './util/convertBytesLikeToArrayBuffer.js';
|
export * from './util/convertBytesLikeToArrayBuffer.js';
|
||||||
|
|||||||
Generated
+6
@@ -23,6 +23,9 @@ importers:
|
|||||||
'@fastify/cors':
|
'@fastify/cors':
|
||||||
specifier: ^10.0.1
|
specifier: ^10.0.1
|
||||||
version: 10.1.0
|
version: 10.1.0
|
||||||
|
'@sammo-ts/common':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/common
|
||||||
'@sammo-ts/infra':
|
'@sammo-ts/infra':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/infra
|
version: link:../../packages/infra
|
||||||
@@ -84,6 +87,9 @@ importers:
|
|||||||
'@fastify/cors':
|
'@fastify/cors':
|
||||||
specifier: ^10.0.1
|
specifier: ^10.0.1
|
||||||
version: 10.1.0
|
version: 10.1.0
|
||||||
|
'@sammo-ts/common':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/common
|
||||||
'@sammo-ts/infra':
|
'@sammo-ts/infra':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/infra
|
version: link:../../packages/infra
|
||||||
|
|||||||
Reference in New Issue
Block a user