| import { createHmac, timingSafeEqual } from 'node:crypto' |
| import type { Request, Response } from 'express' |
| import { nanoid } from 'nanoid' |
| import { config } from '../config.js' |
| import { logger } from '../utils/logger.js' |
|
|
| export const IDENTITY_COOKIE_NAME = 'mt_identity' |
| const IDENTITY_TOKEN_VERSION = 1 |
| const COOKIE_SAME_SITE_LABEL = { |
| lax: 'Lax', |
| strict: 'Strict', |
| none: 'None', |
| } as const |
|
|
| interface IdentityPayload { |
| uid: string |
| iat: number |
| exp: number |
| ver: number |
| } |
|
|
| type IdentitySource = 'http' | 'socket' |
|
|
| 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(unsignedToken: string): string { |
| return createHmac('sha256', config.identity.secret).update(unsignedToken).digest('base64url') |
| } |
|
|
| function parseCookies(cookieHeader?: string): Record<string, string> { |
| if (!cookieHeader) return {} |
| const result: Record<string, string> = {} |
| for (const chunk of cookieHeader.split(';')) { |
| const [k, ...rest] = chunk.trim().split('=') |
| if (!k) continue |
| const rawValue = rest.join('=') || '' |
| try { |
| result[k] = decodeURIComponent(rawValue) |
| } catch { |
| result[k] = rawValue |
| } |
| } |
| return result |
| } |
|
|
| function isSecureRequest(req: Request): boolean { |
| if (req.secure) return true |
|
|
| const forwardedProto = req.headers['x-forwarded-proto'] |
| const proto = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto |
| return typeof proto === 'string' && proto.split(',')[0].trim().toLowerCase() === 'https' |
| } |
|
|
| function serializeIdentityCookie(token: string, secure: boolean): string { |
| const maxAgeSec = config.identity.ttlDays * 24 * 60 * 60 |
| const attrs = [`${IDENTITY_COOKIE_NAME}=${encodeURIComponent(token)}`, 'Path=/', 'HttpOnly', `Max-Age=${maxAgeSec}`] |
| attrs.push(`SameSite=${COOKIE_SAME_SITE_LABEL[config.identity.cookieSameSite]}`) |
| if (secure) attrs.push('Secure') |
| if (config.identity.cookiePartitioned) attrs.push('Partitioned') |
| return attrs.join('; ') |
| } |
|
|
| function issueToken(userId: string): { token: string; payload: IdentityPayload } { |
| const now = Date.now() |
| const payload: IdentityPayload = { |
| uid: userId, |
| iat: now, |
| exp: now + config.identity.ttlDays * 24 * 60 * 60 * 1000, |
| ver: IDENTITY_TOKEN_VERSION, |
| } |
| const encodedPayload = base64urlEncode(JSON.stringify(payload)) |
| const unsigned = encodedPayload |
| const signature = sign(unsigned) |
| return { token: `${unsigned}.${signature}`, payload } |
| } |
|
|
| export function generateIdentityUserId(): string { |
| return nanoid(20) |
| } |
|
|
| export function issueIdentityCookie(req: Request, res: Response, userId?: string): { userId: string; expiresAt: number } { |
| const uid = userId ?? generateIdentityUserId() |
| const { token, payload } = issueToken(uid) |
| const secureRequired = config.identity.cookieSameSite === 'none' || config.identity.cookiePartitioned |
| const secure = secureRequired || (config.identity.cookieSecure ?? (config.isProd && isSecureRequest(req))) |
| res.setHeader('Set-Cookie', serializeIdentityCookie(token, secure)) |
| return { userId: uid, expiresAt: payload.exp } |
| } |
|
|
| function logTokenInvalid(source: IdentitySource, reason: string, extra?: Record<string, unknown>): void { |
| logger.warn('Identity token verification failed', { source, reason, ...extra }) |
| } |
|
|
| export function verifyIdentityToken(token: string, source: IdentitySource): IdentityPayload | null { |
| const parts = token.split('.') |
| if (parts.length !== 2) { |
| logTokenInvalid(source, 'invalid_parts', { parts: parts.length }) |
| return null |
| } |
|
|
| const [encodedPayload, encodedSignature] = parts |
| if (!encodedPayload || !encodedSignature) { |
| logTokenInvalid(source, 'missing_payload_or_signature') |
| return null |
| } |
|
|
| const expectedSignature = sign(encodedPayload) |
| const expectedBuffer = Buffer.from(expectedSignature) |
| const actualBuffer = Buffer.from(encodedSignature) |
| if (expectedBuffer.length !== actualBuffer.length) { |
| logTokenInvalid(source, 'signature_length_mismatch') |
| return null |
| } |
| if (!timingSafeEqual(expectedBuffer, actualBuffer)) { |
| logTokenInvalid(source, 'signature_mismatch') |
| return null |
| } |
|
|
| try { |
| const payload = JSON.parse(base64urlDecode(encodedPayload)) as IdentityPayload |
| if (payload.ver !== IDENTITY_TOKEN_VERSION) { |
| logTokenInvalid(source, 'version_mismatch', { version: payload.ver }) |
| return null |
| } |
| if (typeof payload.uid !== 'string' || payload.uid.length === 0) { |
| logTokenInvalid(source, 'invalid_uid') |
| return null |
| } |
| if (!Number.isFinite(payload.exp)) { |
| logTokenInvalid(source, 'invalid_exp') |
| return null |
| } |
| if (payload.exp <= Date.now()) { |
| logTokenInvalid(source, 'expired_token', { userId: payload.uid, exp: payload.exp }) |
| return null |
| } |
| return payload |
| } catch { |
| logTokenInvalid(source, 'invalid_payload_json') |
| return null |
| } |
| } |
|
|
| export function getIdentityFromRequest(req: Request): { userId: string } | null { |
| const cookies = parseCookies(req.headers.cookie) |
| const token = cookies[IDENTITY_COOKIE_NAME] |
| if (!token) return null |
| const payload = verifyIdentityToken(token, 'http') |
| if (!payload) return null |
| return { userId: payload.uid } |
| } |
|
|
| export function getIdentityFromCookieHeader(cookieHeader?: string): { userId: string } | null { |
| const token = parseCookies(cookieHeader)[IDENTITY_COOKIE_NAME] |
| if (!token) return null |
| const payload = verifyIdentityToken(token, 'socket') |
| if (!payload) return null |
| return { userId: payload.uid } |
| } |
|
|