/** * Runtime-neutral ACCESS_CODE session policy shared by Node routes and the * Edge-compatible middleware verifier. Keep this module free of Node APIs. */ export const ACCESS_CODE_COOKIE_NAME = 'openmaic_access'; export const DEFAULT_ACCESS_CODE_SESSION_TTL_SECONDS = 60 * 60 * 24 * 7; const MAX_ACCESS_CODE_SESSION_TTL_SECONDS = 60 * 60 * 24 * 365; const HMAC_SHA256_HEX_LENGTH = 64; export interface AccessTokenValidationOptions { nowMs?: number; ttlSeconds?: number; } export interface ParsedAccessToken { issuedAtMs: number; issuedAtText: string; signatureHex: string; } export function resolveAccessCodeSessionTtlSeconds(value: string | undefined): number { if (!value) return DEFAULT_ACCESS_CODE_SESSION_TTL_SECONDS; const parsed = Number(value); if ( !Number.isSafeInteger(parsed) || parsed <= 0 || parsed > MAX_ACCESS_CODE_SESSION_TTL_SECONDS ) { return DEFAULT_ACCESS_CODE_SESSION_TTL_SECONDS; } return parsed; } export function parseAccessToken(token: string): ParsedAccessToken | null { const dotIndex = token.indexOf('.'); if (dotIndex <= 0 || dotIndex !== token.lastIndexOf('.')) return null; const issuedAtText = token.slice(0, dotIndex); const signatureHex = token.slice(dotIndex + 1); if (!/^\d+$/.test(issuedAtText)) return null; if (!new RegExp(`^[0-9a-f]{${HMAC_SHA256_HEX_LENGTH}}$`, 'i').test(signatureHex)) return null; const issuedAtMs = Number(issuedAtText); if (!Number.isSafeInteger(issuedAtMs) || issuedAtMs < 0) return null; return { issuedAtMs, issuedAtText, signatureHex }; } /** * Parse a token and reject sessions issued in the future or whose TTL has * elapsed. Expiration is exclusive: a token is invalid at issuedAt + TTL. */ export function parseFreshAccessToken( token: string, options: AccessTokenValidationOptions = {}, ): ParsedAccessToken | null { const parsed = parseAccessToken(token); if (!parsed) return null; const nowMs = options.nowMs ?? Date.now(); const ttlSeconds = options.ttlSeconds ?? DEFAULT_ACCESS_CODE_SESSION_TTL_SECONDS; const ttlMs = ttlSeconds * 1000; if (!Number.isFinite(nowMs) || !Number.isSafeInteger(ttlMs) || ttlMs <= 0) return null; if (parsed.issuedAtMs > nowMs) return null; if (nowMs - parsed.issuedAtMs >= ttlMs) return null; return parsed; } export function hexToBytes(hex: string): Uint8Array | null { if (hex.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(hex)) return null; const bytes = new Uint8Array(hex.length / 2); for (let index = 0; index < bytes.length; index += 1) { bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16); } return bytes; }