/** * WS handshake auth (TASK-0091 R1). * * Verifies the access JWT (HS256, shared API secret) with * `@factory/auth`'s `verifyAccessToken` — the same crypto the API guard * uses (never a copy). Resolutions recorded in the task file: * * - **note 13** — the shipping API signs `{sub, workspaces: string[]}` * (no single `workspaceId` claim, no `iss`). The handshake therefore: * requires `sub`; when `iss` IS present it must be `factory`; the * room's workspace (resolved server-side, note 14) must be a member * of `workspaces[]`. * - **doc §2.1 grace** — a token expired by < 5 min is accepted (a tab * left open across the 15 min boundary survives until the client's * BFF refresh). `verifyAccessToken` throws on `exp <= now`, so the * grace is applied here with the injectable clock. * * The token is read from the upgrade (query `?token=` or the * `Authorization: Bearer` header) and is NEVER logged. */ import { verifyAccessToken, type DecodedAccessToken } from "@factory/auth/tokens"; /** The accepted JWT issuer (note 13 — checked only when present). */ export const EXPECTED_ISSUER = "factory"; /** doc §2.1: expired-by-< 5 min is accepted (grace for open tabs). */ export const EXPIRY_GRACE_MS = 5 * 60 * 1000; /** A verified handshake identity. */ export interface SessionIdentity { readonly userId: string; readonly workspaces: readonly string[]; /** `jti` (audit correlation). */ readonly jti: string; /** Unix seconds. */ readonly exp: number; } export type VerifyOutcome = | { readonly ok: true; readonly session: SessionIdentity } | { readonly ok: false; readonly reason: "missing_token" | "invalid_token" | "wrong_issuer" | "no_workspaces" }; /** * Verify an access token for the WS handshake. * * @param token the raw JWT (from the upgrade query/header) * @param secret the shared JWT secret (env) * @param nowMs injectable clock (deterministic tests) */ export function verifyHandshakeToken( token: string | null | undefined, secret: string, nowMs: number = Date.now(), ): VerifyOutcome { if (token === undefined || token === null || token.length === 0) { return { ok: false, reason: "missing_token" }; } let claims: DecodedAccessToken; try { claims = verifyAccessToken(token, secret, nowMs); } catch (err) { // Expired-by-< grace → re-verify against (now - grace) so ONLY exp // can pass; every other failure (signature, alg, shape) stays fatal. const msg = err instanceof Error ? err.message : String(err); if (/expired/i.test(msg)) { try { claims = verifyAccessToken(token, secret, nowMs - EXPIRY_GRACE_MS); } catch { return { ok: false, reason: "invalid_token" }; } } else { return { ok: false, reason: "invalid_token" }; } } // note 13: `iss` when present must be `factory`. const iss = (claims as { iss?: unknown }).iss; if (iss !== undefined && iss !== EXPECTED_ISSUER) { return { ok: false, reason: "wrong_issuer" }; } // The shipping API mints `workspaces: string[]` (auth.service.ts) but // `DecodedAccessToken` does not declare it — read it defensively, same // as `iss` above. const workspacesRaw = (claims as { workspaces?: unknown }).workspaces; const workspaces = Array.isArray(workspacesRaw) ? (workspacesRaw as unknown[]).filter((w): w is string => typeof w === "string") : []; if (workspaces.length === 0) { // A token without any workspace claim cannot be bound to a room — // fail closed (4001): the API always mints `workspaces`. return { ok: false, reason: "no_workspaces" }; } return { ok: true, session: { userId: claims.sub, workspaces, jti: claims.jti, exp: claims.exp, }, }; }