138 lines
3.7 KiB
TypeScript
138 lines
3.7 KiB
TypeScript
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;
|
|
/** 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<T extends object>(data: T): string {
|
|
const payloadB64 = b64urlEncode(JSON.stringify(data));
|
|
return `${payloadB64}.${sign(payloadB64)}`;
|
|
}
|
|
|
|
function unseal<T extends object>(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<AuthSession>(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<AuthSession | null> {
|
|
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;
|
|
}) {
|
|
const state = b64urlEncode(randomBytes(24));
|
|
const payload: OAuthStatePayload = {
|
|
state,
|
|
cheesePerDice: input.cheesePerDice,
|
|
multiplyByCheese: input.multiplyByCheese,
|
|
maxMultiplier: input.maxMultiplier,
|
|
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<OAuthStatePayload>(token);
|
|
if (!data?.state || !data.exp) return null;
|
|
if (Date.now() > data.exp) return null;
|
|
return data;
|
|
}
|