feat(api): short-lived HMAC stream tickets for WS/SSE auth (#425 follow-up) - #433
Conversation
…ow-up) PR #425 let SSE/WebSocket clients authenticate with `?token=<raw API key>` in the query string. A raw, reusable key in a URL leaks through access logs, Referer headers, APM and browser history. This replaces it with a short-lived, single-purpose ticket. - New POST /api/auth/ticket (header-authed) mints an HMAC-signed ticket bound to a purpose ("ws" | "sse"), ~60s TTL, single-use jti. - WS upgrade and SSE /api/logs/stream accept ?ticket= (signature + expiry + purpose validated). Auth still runs before the ENG-1670 slot caps, so the 401-before-429 ordering is preserved. - Signing secret is derived from the configured API keys (rotating keys invalidates tickets) or an explicit MULTIPLAI_TICKET_SECRET; fails closed when neither is set. - Legacy ?token=<raw key> kept behind ALLOW_QUERY_TOKEN=1 (default OFF) for a migration window; documented in .env.example. Tests: real Bun.serve integration (mint -> WS handshake ok; expired/tampered -> 401; raw ?token= rejected by default; SSE with ticket ok; ws-ticket on SSE path -> 401) plus ticket unit coverage. tsc --noEmit clean. The 36 Playwright/CUA failures are the known baseline (browser binary not installed) and are unchanged.
There was a problem hiding this comment.
💡 Codex Review
MultiplAI/packages/api/src/core/ticket.ts
Lines 119 to 120 in ffc35aa
When MULTIPLAI_TICKET_TTL_MS is malformed, zero, or negative, TICKET_DEFAULT_TTL_MS becomes invalid and this fallback simply selects that same invalid value. POST /api/auth/ticket consequently returns 201 with expiresAt: NaN or an already-expired ticket that every stream request rejects; validate the environment value at startup and either fail clearly or fall back to a known-valid literal TTL.
AGENTS.md reference: AGENTS.md:L83-L85
MultiplAI/packages/api/src/core/ticket.ts
Line 91 in ffc35aa
This string contains a literal NUL byte in the TypeScript file. Git consequently classifies the entire new security-sensitive module as binary (git show cannot render a line-level diff and rg reports a binary match), impairing future reviews, merges, and history inspection; represent the same runtime separator with a source escape such as "\u0000" instead.
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (ticketPurpose) { | ||
| const ticket = url.searchParams.get("ticket"); | ||
| if (ticket) { | ||
| const result = validateTicket(ticket, ticketPurpose, { markUsed: true }); |
There was a problem hiding this comment.
Preserve reconnection for SSE tickets
When an established EventSource connection is interrupted, the browser reconnects using the same URL, but this call records the SSE ticket as used during the initial handshake, so the reconnect receives 401 even while the ticket remains unexpired. This defeats the endpoint's Last-Event-ID/cursor recovery path and causes live updates to stop after any transient disconnect; avoid single-use marking for SSE or provide a reconnection flow that can mint a fresh ticket.
Useful? React with 👍 / 👎.
| if (isRawQueryTokenAllowed()) { | ||
| const rawToken = url.searchParams.get("token"); | ||
| if (rawToken && isValidToken(rawToken, keys)) { | ||
| return null; |
There was a problem hiding this comment.
Document the ticket flow in generated OpenAPI
With the migration flag unset, this condition rejects the raw ?token= flow, but core/openapi.ts still explicitly tells SSE/WebSocket consumers to authenticate using ?token= and does not expose POST /api/auth/ticket. Clients generated from or following /openapi.json therefore receive 401 and cannot discover the replacement authentication flow; update the generated contract together with this behavior change.
Useful? React with 👍 / 👎.
No description provided.