import { TRPCError } from '@trpc/server'; import { decryptGameSessionToken } from '@sammo-ts/common/auth/gameToken'; import { isGameAccessBlocked } from '@sammo-ts/common/auth/sanctions'; import { isAfter, isValid, parseISO } from 'date-fns'; import { z } from 'zod'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import { authedProcedure, engineProcedure, router } from '../../trpc.js'; import { enqueueProfileIconResetForUser } from '../../services/accountIconSync.js'; import { enqueueAccountIdentityForUser } from '../../services/accountIdentitySync.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({ status: authedProcedure.query(({ ctx }) => { const userId = ctx.auth?.user.id; if (!userId) { throw new TRPCError({ code: 'UNAUTHORIZED' }); } return { userId }; }), exchangeGatewayToken: engineProcedure .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.', }); } if (isGameAccessBlocked(payload.sanctions, [ctx.profile.name, ctx.profile.id])) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Game access is restricted for this account.', }); } let flushedAt: Date | null; try { flushedAt = await ctx.flushStore.getFlushedAt(payload.user.id); } catch { throw new TRPCError({ code: 'SERVICE_UNAVAILABLE', message: 'Authentication protection is unavailable.', }); } 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.', }); } if (payload.user.profileIconResetAt && payload.user.profileIconResetAt === payload.user.iconUpdatedAt) { // 일반 계정 아이콘 변경은 Ref처럼 사용자가 고른 서버에만 적용한다. // 관리자 reset만 다음 인증 경계에서 durable하게 복구한다. await enqueueProfileIconResetForUser(ctx, payload.user.id, payload.user.profileIconResetAt); } if (payload.user.identityRevision) { await enqueueAccountIdentityForUser( ctx.turnDaemon, payload.user.id, payload.user.displayName, payload.user.identityRevision ); } const created = await ctx.accessTokenStore.issueFromGateway(payload); if (created === 'ALREADY_USED') { throw new TRPCError({ code: 'CONFLICT', message: 'Gateway token already used.', }); } 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, }; }), });