File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
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;
}