/** * T8 · Upgrade authorization edge — a THIN ADAPTER over P5 (FIX 6a). Holds NO independent authz * logic: it (a) parses the upgrade (subdomain + FIX-5 token extraction + salted remoteAddr hash), * (b) resolves the subdomain → requestedHostId via P3's RouteResolver, (c) builds P5's * UpgradeContext, (d) calls the injected P5 Authorizer, (e) maps AuthzOutcome → §4.1 MuxOpen or a * 401/403. Every accept/reject verdict is P5's; the only LOCAL failures are the three parse guards. * * Security: capability token rides ONLY the two FIX-5 carriers (subprotocol PREFERRED, cookie * FALLBACK) — NEVER the query string (bearer-equivalent secrets must not leak into logs/history/ * Referer). The accepted subprotocol echoed back is ALWAYS `APP_SUBPROTOCOL`, never the token * entry (INV15). Identity (`host_id`/`account_id`) derives only from the signed token + registry * via P5 (INV3). Logging discipline (INV9): callers MUST NOT log req.url / Authorization / * Sec-WebSocket-Protocol — only `{subdomain, hostId, jti, decision, ts}`. */ import { createHmac } from 'node:crypto' import { APP_SUBPROTOCOL, extractTokenFromSubprotocols, type MuxOpen, type CapabilityRight } from 'relay-contracts' import { extractSubdomain, type RouteResolver } from './subdomain-router.js' import type { Authorizer, UpgradeContext } from './authz-port.js' /** Short-lived HttpOnly; Secure; SameSite=Strict cookie carrying the capability token (fallback). */ export const TOKEN_COOKIE_NAME = 'term_cap' export interface UpgradeRequest { readonly host: string readonly origin: string | undefined readonly url: string // requestPath ONLY (opaque passthrough) — NEVER a token source readonly subprotocols: readonly string[] // parsed Sec-WebSocket-Protocol values (FIX-5 transport) readonly cookies: Readonly> // parsed Cookie header (fallback transport) readonly remoteAddr: string readonly dpop: UpgradeContext['dpop'] readonly activeSessionCount: number readonly sessionId?: string // present ⇒ reattach path (→ onReattach) } export type UpgradeDecision = | { readonly ok: true readonly open: MuxOpen readonly hostId: string readonly acceptedSubprotocol: string } | { readonly ok: false; readonly status: 401 | 403 } export interface AuthorizeDeps { authorizer: Authorizer resolver: RouteResolver baseDomain: string now: () => number remoteAddrSalt: string requiredRight: CapabilityRight } /** * Extract the capability token from EXACTLY the two allowed FIX-5 carriers, in order: * (1) `Sec-WebSocket-Protocol` `term.token.` entry, (2) the `term_cap` cookie. Returns the * opaque token + which carrier, or null. NEVER reads `req.url` (query-string tokens forbidden). */ export function extractCapabilityToken( req: UpgradeRequest, ): { token: string; via: 'subprotocol' | 'cookie' } | null { const fromSub = extractTokenFromSubprotocols(req.subprotocols) if (fromSub !== null && fromSub.length > 0) return { token: fromSub, via: 'subprotocol' } const fromCookie = req.cookies[TOKEN_COOKIE_NAME] if (fromCookie !== undefined && fromCookie.length > 0) return { token: fromCookie, via: 'cookie' } return null } /** Salted HMAC-SHA256 of the client IP, truncated — audit-only, never reversible (INV10). */ function hashRemoteAddr(salt: string, remoteAddr: string): string { return createHmac('sha256', salt).update(remoteAddr).digest('hex').slice(0, 32) } /** The opaque request path (everything the relay forwards but NEVER parses for authz). */ function pathOf(url: string): string { return url } /** * Parse → resolve → build ctx → delegate to P5 → map. No decision branches beyond the three * LOCAL parse guards (unparseable Host → 401, no token carrier → 401, unknown subdomain → 403). */ export async function authorizeUpgrade( req: UpgradeRequest, deps: AuthorizeDeps, ): Promise { const subdomain = extractSubdomain(req.host, deps.baseDomain) if (subdomain === null) return { ok: false, status: 401 } // unparseable Host (local) const extracted = extractCapabilityToken(req) if (extracted === null) return { ok: false, status: 401 } // no token carrier present (local) const resolved = await deps.resolver.resolveSubdomain(subdomain) if (resolved === null) return { ok: false, status: 403 } // unknown subdomain (local) const remoteAddrHash = hashRemoteAddr(deps.remoteAddrSalt, req.remoteAddr) const ctx: UpgradeContext = { capabilityRaw: extracted.token, originHeader: req.origin ?? '', expectedAud: subdomain, requestedHostId: resolved.hostId, // P5 gates token.host against this (INV1) requiredRight: deps.requiredRight, remoteAddrHash, activeSessionCount: req.activeSessionCount, dpop: req.dpop, principal: null, // P5 resolves from the signed token (INV3) } const outcome = req.sessionId !== undefined ? await deps.authorizer.onReattach({ ...ctx, sessionId: req.sessionId }, deps.now()) : await deps.authorizer.onUpgrade(ctx, deps.now()) if (!outcome.ok) return { ok: false, status: outcome.status } const open: MuxOpen = { streamId: 0, // allocated by MuxSession subdomain, requestPath: pathOf(req.url), originHeader: req.origin ?? '', remoteAddrHash, capabilityTokenRef: outcome.jti, // OQ5 resolved: P5 surfaces the verified jti } return { ok: true, open, hostId: outcome.hostId, acceptedSubprotocol: APP_SUBPROTOCOL } }