feat: implement Redis session management and user authentication flow
This commit is contained in:
@@ -20,6 +20,7 @@
|
|||||||
"@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",
|
||||||
|
"redis": "^4.7.0",
|
||||||
"zod": "^4.2.1"
|
"zod": "^4.2.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,8 @@
|
|||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"composite": true
|
"composite": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"references": [
|
||||||
|
{ "path": "../../packages/infra" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,20 @@
|
|||||||
"build": "tsdown -c ../../tsdown.config.ts -F @sammo-ts/gateway-api",
|
"build": "tsdown -c ../../tsdown.config.ts -F @sammo-ts/gateway-api",
|
||||||
"dev": "tsdown -c ../../tsdown.config.ts -F @sammo-ts/gateway-api --watch",
|
"dev": "tsdown -c ../../tsdown.config.ts -F @sammo-ts/gateway-api --watch",
|
||||||
"lint": "node -e \"console.log('lint not configured')\"",
|
"lint": "node -e \"console.log('lint not configured')\"",
|
||||||
"test": "node -e \"console.log('test not configured')\"",
|
"test": "vitest run --config vitest.config.ts",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"tsdown": "^0.18.3"
|
"tsdown": "^0.18.3",
|
||||||
|
"vite-tsconfig-paths": "^6.0.0",
|
||||||
|
"vitest": "^4.0.16"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cors": "^10.0.1",
|
||||||
|
"@sammo-ts/infra": "workspace:*",
|
||||||
|
"@trpc/server": "^11.4.4",
|
||||||
|
"fastify": "^5.3.3",
|
||||||
|
"redis": "^4.7.0",
|
||||||
|
"zod": "^4.2.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
GameSessionInfo,
|
||||||
|
GatewaySessionConfig,
|
||||||
|
GatewaySessionInfo,
|
||||||
|
GatewaySessionService,
|
||||||
|
SessionRevocationOptions,
|
||||||
|
} from './sessionService.js';
|
||||||
|
import type { UserRecord } from './userRepository.js';
|
||||||
|
|
||||||
|
interface StoredSession {
|
||||||
|
info: GatewaySessionInfo;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredGameSession {
|
||||||
|
info: GameSessionInfo;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildGameKey = (profile: string, gameToken: string): string => `${profile}:${gameToken}`;
|
||||||
|
|
||||||
|
// 세션 TTL 동작을 테스트할 수 있도록 메모리 기반 구현을 제공한다.
|
||||||
|
export class InMemoryGatewaySessionService implements GatewaySessionService {
|
||||||
|
private readonly sessions = new Map<string, StoredSession>();
|
||||||
|
private readonly gameSessions = new Map<string, StoredGameSession>();
|
||||||
|
private readonly sessionGames = new Map<string, Set<string>>();
|
||||||
|
private readonly sessionTtlMs: number;
|
||||||
|
private readonly gameSessionTtlMs: number;
|
||||||
|
|
||||||
|
constructor(config: GatewaySessionConfig) {
|
||||||
|
this.sessionTtlMs = config.sessionTtlSeconds * 1000;
|
||||||
|
this.gameSessionTtlMs = config.gameSessionTtlSeconds * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSession(user: UserRecord): Promise<GatewaySessionInfo> {
|
||||||
|
const sessionToken = randomUUID();
|
||||||
|
const info: GatewaySessionInfo = {
|
||||||
|
sessionToken,
|
||||||
|
userId: user.id,
|
||||||
|
username: user.username,
|
||||||
|
displayName: user.displayName,
|
||||||
|
issuedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.sessions.set(sessionToken, {
|
||||||
|
info,
|
||||||
|
expiresAt: Date.now() + this.sessionTtlMs,
|
||||||
|
});
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSession(sessionToken: string): Promise<GatewaySessionInfo | null> {
|
||||||
|
const stored = this.sessions.get(sessionToken);
|
||||||
|
if (!stored) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (Date.now() > stored.expiresAt) {
|
||||||
|
this.sessions.delete(sessionToken);
|
||||||
|
this.sessionGames.delete(sessionToken);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return stored.info;
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeSession(
|
||||||
|
sessionToken: string,
|
||||||
|
options: SessionRevocationOptions = { revokeGames: true }
|
||||||
|
): Promise<void> {
|
||||||
|
if (options.revokeGames ?? true) {
|
||||||
|
const gameKeys = this.sessionGames.get(sessionToken);
|
||||||
|
if (gameKeys) {
|
||||||
|
for (const key of gameKeys) {
|
||||||
|
this.gameSessions.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.sessionGames.delete(sessionToken);
|
||||||
|
}
|
||||||
|
this.sessions.delete(sessionToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createGameSession(
|
||||||
|
sessionToken: string,
|
||||||
|
profile: string
|
||||||
|
): Promise<GameSessionInfo | null> {
|
||||||
|
const session = await this.getSession(sessionToken);
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const gameToken = randomUUID();
|
||||||
|
const info: GameSessionInfo = {
|
||||||
|
profile,
|
||||||
|
gameToken,
|
||||||
|
sessionToken,
|
||||||
|
userId: session.userId,
|
||||||
|
username: session.username,
|
||||||
|
displayName: session.displayName,
|
||||||
|
issuedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
const key = buildGameKey(profile, gameToken);
|
||||||
|
this.gameSessions.set(key, {
|
||||||
|
info,
|
||||||
|
expiresAt: Date.now() + this.gameSessionTtlMs,
|
||||||
|
});
|
||||||
|
const set = this.sessionGames.get(sessionToken) ?? new Set<string>();
|
||||||
|
set.add(key);
|
||||||
|
this.sessionGames.set(sessionToken, set);
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGameSession(profile: string, gameToken: string): Promise<GameSessionInfo | null> {
|
||||||
|
const key = buildGameKey(profile, gameToken);
|
||||||
|
const stored = this.gameSessions.get(key);
|
||||||
|
if (!stored) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (Date.now() > stored.expiresAt) {
|
||||||
|
this.gameSessions.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return stored.info;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import { createSimplePasswordHasher, type PasswordHasher } from './passwordHasher.js';
|
||||||
|
import type { CreateUserInput, UserRecord, UserRepository } from './userRepository.js';
|
||||||
|
|
||||||
|
// 유저 데이터 저장소를 메모리로 대체한 임시 구현.
|
||||||
|
export const createInMemoryUserRepository = (
|
||||||
|
hasher: PasswordHasher = createSimplePasswordHasher()
|
||||||
|
): UserRepository => {
|
||||||
|
const usersByName = new Map<string, UserRecord>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
async findByUsername(username: string): Promise<UserRecord | null> {
|
||||||
|
return usersByName.get(username) ?? null;
|
||||||
|
},
|
||||||
|
async createUser(input: CreateUserInput): Promise<UserRecord> {
|
||||||
|
if (usersByName.has(input.username)) {
|
||||||
|
throw new Error('User already exists.');
|
||||||
|
}
|
||||||
|
const salt = hasher.createSalt();
|
||||||
|
const user: UserRecord = {
|
||||||
|
id: randomUUID(),
|
||||||
|
username: input.username,
|
||||||
|
displayName: input.displayName ?? input.username,
|
||||||
|
passwordSalt: salt,
|
||||||
|
passwordHash: hasher.hash(input.password, salt),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
usersByName.set(input.username, user);
|
||||||
|
return user;
|
||||||
|
},
|
||||||
|
async verifyPassword(user: UserRecord, password: string): Promise<boolean> {
|
||||||
|
return hasher.hash(password, user.passwordSalt) === user.passwordHash;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
export interface PasswordHasher {
|
||||||
|
createSalt(): string;
|
||||||
|
hash(password: string, salt: string): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비밀번호 해싱은 임시 구현이므로 이후 안전한 KDF로 교체한다.
|
||||||
|
export const createSimplePasswordHasher = (): PasswordHasher => ({
|
||||||
|
createSalt: () => randomBytes(16).toString('hex'),
|
||||||
|
hash: (password: string, salt: string) =>
|
||||||
|
createHash('sha256').update(`${salt}:${password}`).digest('hex'),
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export interface GatewayRedisKeyBuilder {
|
||||||
|
sessionKey(sessionToken: string): string;
|
||||||
|
sessionGameSetKey(sessionToken: string): string;
|
||||||
|
gameSessionKey(profile: string, gameToken: string): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createGatewayRedisKeyBuilder = (prefix: string): GatewayRedisKeyBuilder => ({
|
||||||
|
sessionKey: (sessionToken: string) => `${prefix}:session:${sessionToken}`,
|
||||||
|
sessionGameSetKey: (sessionToken: string) => `${prefix}:session-games:${sessionToken}`,
|
||||||
|
gameSessionKey: (profile: string, gameToken: string) =>
|
||||||
|
`${prefix}:game-session:${profile}:${gameToken}`,
|
||||||
|
});
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import { createGatewayRedisKeyBuilder } from './redisKeys.js';
|
||||||
|
import type {
|
||||||
|
GameSessionInfo,
|
||||||
|
GatewaySessionConfig,
|
||||||
|
GatewaySessionInfo,
|
||||||
|
GatewaySessionService,
|
||||||
|
SessionRevocationOptions,
|
||||||
|
} from './sessionService.js';
|
||||||
|
import type { UserRecord } from './userRepository.js';
|
||||||
|
|
||||||
|
interface RedisGatewaySessionOptions extends GatewaySessionConfig {
|
||||||
|
keyPrefix: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RedisPipeline {
|
||||||
|
set(key: string, value: string, options?: { EX?: number }): RedisPipeline;
|
||||||
|
sAdd(key: string, member: string): RedisPipeline;
|
||||||
|
expire(key: string, seconds: number): RedisPipeline;
|
||||||
|
del(key: string): RedisPipeline;
|
||||||
|
exec(): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RedisClientLike {
|
||||||
|
get(key: string): Promise<string | null>;
|
||||||
|
set(key: string, value: string, options?: { EX?: number }): Promise<unknown>;
|
||||||
|
sMembers(key: string): Promise<string[]>;
|
||||||
|
multi(): RedisPipeline;
|
||||||
|
del(key: string): Promise<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseJson = <T>(raw: string | null): T | null => {
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Redis 세션 저장소는 게이트웨이와 게임 서버 간 SSO 토큰을 관리한다.
|
||||||
|
export class RedisGatewaySessionService implements GatewaySessionService {
|
||||||
|
private readonly client: RedisClientLike;
|
||||||
|
private readonly keys: ReturnType<typeof createGatewayRedisKeyBuilder>;
|
||||||
|
private readonly sessionTtlSeconds: number;
|
||||||
|
private readonly gameSessionTtlSeconds: number;
|
||||||
|
|
||||||
|
constructor(client: RedisClientLike, options: RedisGatewaySessionOptions) {
|
||||||
|
this.client = client;
|
||||||
|
this.keys = createGatewayRedisKeyBuilder(options.keyPrefix);
|
||||||
|
this.sessionTtlSeconds = options.sessionTtlSeconds;
|
||||||
|
this.gameSessionTtlSeconds = options.gameSessionTtlSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSession(user: UserRecord): Promise<GatewaySessionInfo> {
|
||||||
|
const sessionToken = randomUUID();
|
||||||
|
const info: GatewaySessionInfo = {
|
||||||
|
sessionToken,
|
||||||
|
userId: user.id,
|
||||||
|
username: user.username,
|
||||||
|
displayName: user.displayName,
|
||||||
|
issuedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await this.client.set(this.keys.sessionKey(sessionToken), JSON.stringify(info), {
|
||||||
|
EX: this.sessionTtlSeconds,
|
||||||
|
});
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSession(sessionToken: string): Promise<GatewaySessionInfo | null> {
|
||||||
|
const raw = await this.client.get(this.keys.sessionKey(sessionToken));
|
||||||
|
return parseJson<GatewaySessionInfo>(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeSession(
|
||||||
|
sessionToken: string,
|
||||||
|
options: SessionRevocationOptions = { revokeGames: true }
|
||||||
|
): Promise<void> {
|
||||||
|
const key = this.keys.sessionKey(sessionToken);
|
||||||
|
if (options.revokeGames ?? true) {
|
||||||
|
const gameSetKey = this.keys.sessionGameSetKey(sessionToken);
|
||||||
|
const games = await this.client.sMembers(gameSetKey);
|
||||||
|
if (games.length > 0) {
|
||||||
|
const pipeline = this.client.multi();
|
||||||
|
for (const entry of games) {
|
||||||
|
pipeline.del(entry);
|
||||||
|
}
|
||||||
|
pipeline.del(gameSetKey);
|
||||||
|
pipeline.del(key);
|
||||||
|
await pipeline.exec();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.client.del(gameSetKey);
|
||||||
|
}
|
||||||
|
await this.client.del(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createGameSession(
|
||||||
|
sessionToken: string,
|
||||||
|
profile: string
|
||||||
|
): Promise<GameSessionInfo | null> {
|
||||||
|
const session = await this.getSession(sessionToken);
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const gameToken = randomUUID();
|
||||||
|
const info: GameSessionInfo = {
|
||||||
|
profile,
|
||||||
|
gameToken,
|
||||||
|
sessionToken,
|
||||||
|
userId: session.userId,
|
||||||
|
username: session.username,
|
||||||
|
displayName: session.displayName,
|
||||||
|
issuedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
const gameKey = this.keys.gameSessionKey(profile, gameToken);
|
||||||
|
const gameSetKey = this.keys.sessionGameSetKey(sessionToken);
|
||||||
|
await this.client
|
||||||
|
.multi()
|
||||||
|
.set(gameKey, JSON.stringify(info), { EX: this.gameSessionTtlSeconds })
|
||||||
|
.sAdd(gameSetKey, gameKey)
|
||||||
|
.expire(gameSetKey, this.sessionTtlSeconds)
|
||||||
|
.exec();
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGameSession(profile: string, gameToken: string): Promise<GameSessionInfo | null> {
|
||||||
|
const raw = await this.client.get(this.keys.gameSessionKey(profile, gameToken));
|
||||||
|
return parseJson<GameSessionInfo>(raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { UserRecord } from './userRepository.js';
|
||||||
|
|
||||||
|
export interface GatewaySessionInfo {
|
||||||
|
sessionToken: string;
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
issuedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameSessionInfo {
|
||||||
|
profile: string;
|
||||||
|
gameToken: string;
|
||||||
|
sessionToken: string;
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
issuedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatewaySessionConfig {
|
||||||
|
sessionTtlSeconds: number;
|
||||||
|
gameSessionTtlSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionRevocationOptions {
|
||||||
|
revokeGames?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatewaySessionService {
|
||||||
|
createSession(user: UserRecord): Promise<GatewaySessionInfo>;
|
||||||
|
getSession(sessionToken: string): Promise<GatewaySessionInfo | null>;
|
||||||
|
revokeSession(sessionToken: string, options?: SessionRevocationOptions): Promise<void>;
|
||||||
|
createGameSession(sessionToken: string, profile: string): Promise<GameSessionInfo | null>;
|
||||||
|
getGameSession(profile: string, gameToken: string): Promise<GameSessionInfo | null>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export interface UserRecord {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
passwordHash: string;
|
||||||
|
passwordSalt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicUser {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toPublicUser = (user: UserRecord): PublicUser => ({
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
displayName: user.displayName,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface CreateUserInput {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
displayName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserRepository {
|
||||||
|
findByUsername(username: string): Promise<UserRecord | null>;
|
||||||
|
createUser(input: CreateUserInput): Promise<UserRecord>;
|
||||||
|
verifyPassword(user: UserRecord, password: string): Promise<boolean>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export interface GatewayApiConfig {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
trpcPath: string;
|
||||||
|
redisKeyPrefix: string;
|
||||||
|
sessionTtlSeconds: number;
|
||||||
|
gameSessionTtlSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseNumber = (value: string | undefined, fallback: number, label: string): number => {
|
||||||
|
if (!value) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isNaN(parsed)) {
|
||||||
|
throw new Error(`${label} must be a number.`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveGatewayApiConfigFromEnv = (
|
||||||
|
env: NodeJS.ProcessEnv = process.env
|
||||||
|
): GatewayApiConfig => {
|
||||||
|
return {
|
||||||
|
host: env.GATEWAY_API_HOST ?? '0.0.0.0',
|
||||||
|
port: parseNumber(env.GATEWAY_API_PORT, 13000, 'GATEWAY_API_PORT'),
|
||||||
|
trpcPath: env.TRPC_PATH ?? '/trpc',
|
||||||
|
redisKeyPrefix: env.GATEWAY_REDIS_PREFIX ?? 'sammo:gateway',
|
||||||
|
sessionTtlSeconds: parseNumber(env.SESSION_TTL_SECONDS, 60 * 60 * 24 * 7, 'SESSION_TTL_SECONDS'),
|
||||||
|
gameSessionTtlSeconds: parseNumber(
|
||||||
|
env.GAME_SESSION_TTL_SECONDS,
|
||||||
|
60 * 60 * 6,
|
||||||
|
'GAME_SESSION_TTL_SECONDS'
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { GatewaySessionService } from './auth/sessionService.js';
|
||||||
|
import type { UserRepository } from './auth/userRepository.js';
|
||||||
|
|
||||||
|
export interface GatewayApiContext {
|
||||||
|
users: UserRepository;
|
||||||
|
sessions: GatewaySessionService;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createGatewayApiContext = (options: {
|
||||||
|
users: UserRepository;
|
||||||
|
sessions: GatewaySessionService;
|
||||||
|
}): GatewayApiContext => ({
|
||||||
|
users: options.users,
|
||||||
|
sessions: options.sessions,
|
||||||
|
});
|
||||||
@@ -1 +1,30 @@
|
|||||||
export {};
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { runGatewayApiServer } from './server.js';
|
||||||
|
|
||||||
|
export * from './config.js';
|
||||||
|
export * from './context.js';
|
||||||
|
export * from './router.js';
|
||||||
|
export * from './server.js';
|
||||||
|
export * from './auth/userRepository.js';
|
||||||
|
export * from './auth/passwordHasher.js';
|
||||||
|
export * from './auth/inMemoryUserRepository.js';
|
||||||
|
export * from './auth/sessionService.js';
|
||||||
|
export * from './auth/inMemorySessionService.js';
|
||||||
|
export * from './auth/redisSessionService.js';
|
||||||
|
export * from './auth/redisKeys.js';
|
||||||
|
|
||||||
|
const isMain = (): boolean => {
|
||||||
|
if (!process.argv[1]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isMain()) {
|
||||||
|
runGatewayApiServer().catch((error) => {
|
||||||
|
console.error('[gateway-api] failed to start', error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { TRPCError } from '@trpc/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { procedure, router } from './trpc.js';
|
||||||
|
import { toPublicUser } from './auth/userRepository.js';
|
||||||
|
|
||||||
|
const zUsername = z.string().min(2).max(32);
|
||||||
|
const zPassword = z.string().min(6).max(128);
|
||||||
|
const zProfile = z.string().min(1).max(64);
|
||||||
|
|
||||||
|
export const appRouter = router({
|
||||||
|
health: router({
|
||||||
|
ping: procedure.query(() => ({
|
||||||
|
ok: true,
|
||||||
|
now: new Date().toISOString(),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
auth: router({
|
||||||
|
register: procedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
username: zUsername,
|
||||||
|
password: zPassword,
|
||||||
|
displayName: z.string().min(2).max(40).optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const existing = await ctx.users.findByUsername(input.username);
|
||||||
|
if (existing) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'CONFLICT',
|
||||||
|
message: 'Username already exists.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let created = null;
|
||||||
|
try {
|
||||||
|
created = await ctx.users.createUser(input);
|
||||||
|
} catch (error) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'CONFLICT',
|
||||||
|
message: 'Username already exists.',
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const session = await ctx.sessions.createSession(created);
|
||||||
|
return {
|
||||||
|
user: toPublicUser(created),
|
||||||
|
sessionToken: session.sessionToken,
|
||||||
|
issuedAt: session.issuedAt,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
login: procedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
username: zUsername,
|
||||||
|
password: zPassword,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const user = await ctx.users.findByUsername(input.username);
|
||||||
|
if (!user) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
message: 'Invalid username or password.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const ok = await ctx.users.verifyPassword(user, input.password);
|
||||||
|
if (!ok) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
message: 'Invalid username or password.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const session = await ctx.sessions.createSession(user);
|
||||||
|
return {
|
||||||
|
user: toPublicUser(user),
|
||||||
|
sessionToken: session.sessionToken,
|
||||||
|
issuedAt: session.issuedAt,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
me: procedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
sessionToken: z.string().min(1),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const session = await ctx.sessions.getSession(input.sessionToken);
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
id: session.userId,
|
||||||
|
username: session.username,
|
||||||
|
displayName: session.displayName,
|
||||||
|
},
|
||||||
|
issuedAt: session.issuedAt,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
logout: procedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
sessionToken: z.string().min(1),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
await ctx.sessions.revokeSession(input.sessionToken, { revokeGames: true });
|
||||||
|
return { ok: true };
|
||||||
|
}),
|
||||||
|
issueGameSession: procedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
sessionToken: z.string().min(1),
|
||||||
|
profile: zProfile,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const gameSession = await ctx.sessions.createGameSession(
|
||||||
|
input.sessionToken,
|
||||||
|
input.profile
|
||||||
|
);
|
||||||
|
if (!gameSession) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
message: 'Session is not valid.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
profile: gameSession.profile,
|
||||||
|
gameToken: gameSession.gameToken,
|
||||||
|
issuedAt: gameSession.issuedAt,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
validateGameSession: procedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
profile: zProfile,
|
||||||
|
gameToken: z.string().min(1),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const gameSession = await ctx.sessions.getGameSession(
|
||||||
|
input.profile,
|
||||||
|
input.gameToken
|
||||||
|
);
|
||||||
|
if (!gameSession) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const session = await ctx.sessions.getSession(gameSession.sessionToken);
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
profile: gameSession.profile,
|
||||||
|
sessionToken: gameSession.sessionToken,
|
||||||
|
user: {
|
||||||
|
id: gameSession.userId,
|
||||||
|
username: gameSession.username,
|
||||||
|
displayName: gameSession.displayName,
|
||||||
|
},
|
||||||
|
issuedAt: gameSession.issuedAt,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AppRouter = typeof appRouter;
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import fastify from 'fastify';
|
||||||
|
import cors from '@fastify/cors';
|
||||||
|
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||||
|
import { createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra';
|
||||||
|
|
||||||
|
import { resolveGatewayApiConfigFromEnv } from './config.js';
|
||||||
|
import { createGatewayApiContext } from './context.js';
|
||||||
|
import { createInMemoryUserRepository } from './auth/inMemoryUserRepository.js';
|
||||||
|
import { RedisGatewaySessionService } from './auth/redisSessionService.js';
|
||||||
|
import { appRouter } from './router.js';
|
||||||
|
|
||||||
|
export const createGatewayApiServer = async () => {
|
||||||
|
const config = resolveGatewayApiConfigFromEnv();
|
||||||
|
const redis = createRedisConnector(resolveRedisConfigFromEnv());
|
||||||
|
await redis.connect();
|
||||||
|
|
||||||
|
const users = createInMemoryUserRepository();
|
||||||
|
const sessions = new RedisGatewaySessionService(redis.client, {
|
||||||
|
keyPrefix: config.redisKeyPrefix,
|
||||||
|
sessionTtlSeconds: config.sessionTtlSeconds,
|
||||||
|
gameSessionTtlSeconds: config.gameSessionTtlSeconds,
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = fastify({
|
||||||
|
logger: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(cors, {
|
||||||
|
origin: true,
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(fastifyTRPCPlugin, {
|
||||||
|
prefix: config.trpcPath,
|
||||||
|
trpcOptions: {
|
||||||
|
router: appRouter,
|
||||||
|
createContext: () =>
|
||||||
|
createGatewayApiContext({
|
||||||
|
users,
|
||||||
|
sessions,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/healthz', async () => ({
|
||||||
|
ok: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.addHook('onClose', async () => {
|
||||||
|
await redis.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
app,
|
||||||
|
config,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const runGatewayApiServer = async (): Promise<void> => {
|
||||||
|
const { app, config } = await createGatewayApiServer();
|
||||||
|
await app.listen({
|
||||||
|
host: config.host,
|
||||||
|
port: config.port,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { initTRPC } from '@trpc/server';
|
||||||
|
|
||||||
|
import type { GatewayApiContext } from './context.js';
|
||||||
|
|
||||||
|
const t = initTRPC.context<GatewayApiContext>().create();
|
||||||
|
|
||||||
|
export const router = t.router;
|
||||||
|
export const procedure = t.procedure;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js';
|
||||||
|
import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js';
|
||||||
|
import { createGatewayApiContext } from '../src/context.js';
|
||||||
|
import { appRouter } from '../src/router.js';
|
||||||
|
|
||||||
|
const buildCaller = () => {
|
||||||
|
const users = createInMemoryUserRepository();
|
||||||
|
const sessions = new InMemoryGatewaySessionService({
|
||||||
|
sessionTtlSeconds: 3600,
|
||||||
|
gameSessionTtlSeconds: 600,
|
||||||
|
});
|
||||||
|
return appRouter.createCaller(createGatewayApiContext({ users, sessions }));
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('gateway auth flow', () => {
|
||||||
|
it('registers and issues a game session', async () => {
|
||||||
|
const caller = buildCaller();
|
||||||
|
const register = await caller.auth.register({
|
||||||
|
username: 'tester',
|
||||||
|
password: 'secretpass',
|
||||||
|
displayName: 'Tester',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(register.user.username).toBe('tester');
|
||||||
|
expect(register.sessionToken).toBeTruthy();
|
||||||
|
|
||||||
|
const issued = await caller.auth.issueGameSession({
|
||||||
|
sessionToken: register.sessionToken,
|
||||||
|
profile: 'che:default',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(issued.profile).toBe('che:default');
|
||||||
|
expect(issued.gameToken).toBeTruthy();
|
||||||
|
|
||||||
|
const validated = await caller.auth.validateGameSession({
|
||||||
|
profile: 'che:default',
|
||||||
|
gameToken: issued.gameToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(validated?.user.username).toBe('tester');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,5 +5,8 @@
|
|||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"composite": true
|
"composite": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"references": [
|
||||||
|
{ "path": "../../packages/infra" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
tsconfigPaths({
|
||||||
|
projects: [path.resolve(__dirname, '../../tsconfig.paths.json')],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
globals: true,
|
||||||
|
include: ['test/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Generated
+28
@@ -32,6 +32,9 @@ importers:
|
|||||||
fastify:
|
fastify:
|
||||||
specifier: ^5.3.3
|
specifier: ^5.3.3
|
||||||
version: 5.6.2
|
version: 5.6.2
|
||||||
|
redis:
|
||||||
|
specifier: ^4.7.0
|
||||||
|
version: 4.7.1
|
||||||
zod:
|
zod:
|
||||||
specifier: ^4.2.1
|
specifier: ^4.2.1
|
||||||
version: 4.2.1
|
version: 4.2.1
|
||||||
@@ -77,10 +80,35 @@ importers:
|
|||||||
app/game-frontend: {}
|
app/game-frontend: {}
|
||||||
|
|
||||||
app/gateway-api:
|
app/gateway-api:
|
||||||
|
dependencies:
|
||||||
|
'@fastify/cors':
|
||||||
|
specifier: ^10.0.1
|
||||||
|
version: 10.1.0
|
||||||
|
'@sammo-ts/infra':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/infra
|
||||||
|
'@trpc/server':
|
||||||
|
specifier: ^11.4.4
|
||||||
|
version: 11.8.1(typescript@5.9.3)
|
||||||
|
fastify:
|
||||||
|
specifier: ^5.3.3
|
||||||
|
version: 5.6.2
|
||||||
|
redis:
|
||||||
|
specifier: ^4.7.0
|
||||||
|
version: 4.7.1
|
||||||
|
zod:
|
||||||
|
specifier: ^4.2.1
|
||||||
|
version: 4.2.1
|
||||||
devDependencies:
|
devDependencies:
|
||||||
tsdown:
|
tsdown:
|
||||||
specifier: ^0.18.3
|
specifier: ^0.18.3
|
||||||
version: 0.18.3(typescript@5.9.3)
|
version: 0.18.3(typescript@5.9.3)
|
||||||
|
vite-tsconfig-paths:
|
||||||
|
specifier: ^6.0.0
|
||||||
|
version: 6.0.3(typescript@5.9.3)(vite@7.3.0(@types/node@20.19.27)(jiti@2.6.1))
|
||||||
|
vitest:
|
||||||
|
specifier: ^4.0.16
|
||||||
|
version: 4.0.16(@types/node@20.19.27)(jiti@2.6.1)
|
||||||
|
|
||||||
app/gateway-frontend: {}
|
app/gateway-frontend: {}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user