import 'server-only'; import { createHmac, timingSafeEqual } from 'node:crypto'; import { getSessionSecret } from '@/lib/env'; /** * 세션 토큰 발급·검증 — Node 내장 crypto로 서명하는 stateless HMAC-SHA256 토큰. * 형식: `base64url(JSON payload).base64url(signature)`. * 서명 불일치·만료·파싱 실패 등 어떤 이유로든 검증에 실패하면 null을 반환한다(fail-closed). * * 세션 payload는 백엔드(edupay-backend) 로그인 응답의 accessToken/refreshToken을 그대로 * 담는다 — 백엔드 토큰을 우리가 서명 검증할 수 없는 채로 쿠키에 그대로 두면(우리는 백엔드 * 시크릿이 없다) 공격자가 `exp`를 조작한 위조 토큰으로 라우트 가드를 통과시킬 수 있다. 이 * HMAC 서명이 그 위조를 막는 경계다. * * loginId는 accessToken 클레임에 없어(백엔드가 싣지 않는다) 로그인 입력값을 그대로 담는다 — * 이 서명 안에 들어가므로 이후 위조는 불가능하다. * * 세션 만료(exp)는 고정 TTL 상수가 아니라 호출부(로그인 Server Action)가 백엔드 accessToken의 * `exp` 클레임을 그대로 넘겨받아 정렬시킨다(`CreateSessionTokenInput.exp`) — 세션은 결국 * accessToken을 담는 그릇이므로 수명이 어긋나면 두 가지 문제가 생긴다: (1) 세션이 더 오래 * 살면 이미 만료된 accessToken을 유효한 세션이 계속 들고 있다가 실제 API 호출 시점에야 실패가 * 드러나고, (2) 세션이 accessToken보다 먼저 끊기면 아직 쓸 수 있는 토큰을 조기 폐기하게 된다. * 과거의 고정 12시간 TTL(mock 인증 시절 자체 발급 토큰 기준)은 이제 의미가 없다 — 백엔드가 * accessToken 수명(현재 24시간)의 유일한 소유자이므로 그 값을 그대로 따른다. */ export type SessionTokenPayload = { userId: string; loginId: string; userNm: string; roleId: string; accessToken: string; refreshToken: string; iat: number; exp: number; }; export type CreateSessionTokenInput = { userId: string; loginId: string; userNm: string; roleId: string; accessToken: string; refreshToken: string; /** 백엔드 accessToken의 `exp` 클레임(unix seconds)을 그대로 전달한다. */ exp: number; }; function base64UrlEncode(input: string): string { return Buffer.from(input, 'utf8').toString('base64url'); } function base64UrlDecode(input: string): string { return Buffer.from(input, 'base64url').toString('utf8'); } function sign(encodedPayload: string): string { return createHmac('sha256', getSessionSecret()) .update(encodedPayload) .digest('base64url'); } export function createSessionToken(input: CreateSessionTokenInput): string { const issuedAt = Math.floor(Date.now() / 1000); const payload: SessionTokenPayload = { userId: input.userId, loginId: input.loginId, userNm: input.userNm, roleId: input.roleId, accessToken: input.accessToken, refreshToken: input.refreshToken, iat: issuedAt, exp: input.exp, }; const encodedPayload = base64UrlEncode(JSON.stringify(payload)); const signature = sign(encodedPayload); return `${encodedPayload}.${signature}`; } export function verifySessionToken(token: string): SessionTokenPayload | null { const [encodedPayload, signature] = token.split('.'); if (!encodedPayload || !signature) { return null; } const expectedSignature = sign(encodedPayload); const signatureBuffer = Buffer.from(signature); const expectedBuffer = Buffer.from(expectedSignature); if ( signatureBuffer.length !== expectedBuffer.length || !timingSafeEqual(signatureBuffer, expectedBuffer) ) { return null; } let payload: SessionTokenPayload; try { payload = JSON.parse(base64UrlDecode(encodedPayload)) as SessionTokenPayload; } catch { return null; } // roleId·userNm은 빈 문자열일 수 있다 — 백엔드 TB_ADM_USER에 값이 없으면 클레임도 비어 온다. if ( typeof payload.userId !== 'string' || !payload.userId || typeof payload.loginId !== 'string' || !payload.loginId || typeof payload.userNm !== 'string' || typeof payload.roleId !== 'string' || typeof payload.accessToken !== 'string' || !payload.accessToken || typeof payload.refreshToken !== 'string' || !payload.refreshToken ) { return null; } const now = Math.floor(Date.now() / 1000); if (typeof payload.exp !== 'number' || payload.exp <= now) { return null; } return payload; }