import { createHmac, randomBytes, timingSafeEqual } from 'crypto'; import { cookies } from 'next/headers'; import { getAuthSessionSecret } from './mode'; export const AUTH_COOKIE = 'drinkmarble_auth'; export const OAUTH_STATE_COOKIE = 'drinkmarble_oauth_state'; export type AuthSession = { channelId: string; channelName: string | null; /** unix ms */ exp: number; }; export type OAuthStatePayload = { state: string; cheesePerDice?: number; multiplyByCheese?: boolean; maxMultiplier?: number; resetDiceQueue?: boolean; /** unix ms */ exp: number; }; function b64urlEncode(buf: Buffer | string) { const b = typeof buf === 'string' ? Buffer.from(buf, 'utf8') : buf; return b .toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/g, ''); } function b64urlDecode(s: string) { const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4)); const b64 = s.replace(/-/g, '+').replace(/_/g, '/') + pad; return Buffer.from(b64, 'base64'); } function sign(payloadB64: string) { return createHmac('sha256', getAuthSessionSecret()) .update(payloadB64) .digest('base64url'); } function seal(data: T): string { const payloadB64 = b64urlEncode(JSON.stringify(data)); return `${payloadB64}.${sign(payloadB64)}`; } function unseal(token: string): T | null { const parts = token.split('.'); if (parts.length !== 2) return null; const [payloadB64, sig] = parts; if (!payloadB64 || !sig) return null; const expected = sign(payloadB64); try { const a = Buffer.from(sig); const b = Buffer.from(expected); if (a.length !== b.length || !timingSafeEqual(a, b)) return null; } catch { return null; } try { return JSON.parse(b64urlDecode(payloadB64).toString('utf8')) as T; } catch { return null; } } /** 브라우저 종료 시까지 + 최대 12시간 (치지직 토큰은 저장하지 않음) */ const SESSION_MAX_MS = 12 * 60 * 60 * 1000; export function createAuthSession( channelId: string, channelName: string | null, ): AuthSession { return { channelId: channelId.trim().toLowerCase(), channelName, exp: Date.now() + SESSION_MAX_MS, }; } export function serializeAuthSession(session: AuthSession) { return seal(session); } export function parseAuthSession(token: string | undefined | null): AuthSession | null { if (!token) return null; const data = unseal(token); if (!data?.channelId || !data.exp) return null; if (Date.now() > data.exp) return null; return { channelId: String(data.channelId).toLowerCase(), channelName: data.channelName ?? null, exp: data.exp, }; } export async function readAuthSession(): Promise { const jar = await cookies(); return parseAuthSession(jar.get(AUTH_COOKIE)?.value); } export function authCookieOptions(maxAgeSec = Math.floor(SESSION_MAX_MS / 1000)) { return { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' as const, path: '/', maxAge: maxAgeSec, }; } export function createOAuthState(input: { cheesePerDice?: number; multiplyByCheese?: boolean; maxMultiplier?: number; resetDiceQueue?: boolean; }) { const state = b64urlEncode(randomBytes(24)); const payload: OAuthStatePayload = { state, cheesePerDice: input.cheesePerDice, multiplyByCheese: input.multiplyByCheese, maxMultiplier: input.maxMultiplier, resetDiceQueue: input.resetDiceQueue, exp: Date.now() + 10 * 60 * 1000, }; return { state, sealed: seal(payload) }; } export function parseOAuthStateCookie(token: string | undefined | null): OAuthStatePayload | null { if (!token) return null; const data = unseal(token); if (!data?.state || !data.exp) return null; if (Date.now() > data.exp) return null; return data; }