Files
core2026/app/game-api/src/router/auth/index.ts
T

128 lines
4.7 KiB
TypeScript

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,
};
}),
});