128 lines
4.7 KiB
TypeScript
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,
|
|
};
|
|
}),
|
|
});
|