Skip to content

feat(api): short-lived HMAC stream tickets for WS/SSE auth (#425 follow-up) - #433

Merged
limaronaldo merged 1 commit into
mainfrom
rm/ws-sse-ticket-token
Aug 11, 2026
Merged

feat(api): short-lived HMAC stream tickets for WS/SSE auth (#425 follow-up)#433
limaronaldo merged 1 commit into
mainfrom
rm/ws-sse-ticket-token

Conversation

@limaronaldo

Copy link
Copy Markdown
Owner

No description provided.

…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.
@limaronaldo
limaronaldo merged commit 5d2aea1 into main Aug 11, 2026
4 checks passed
@limaronaldo
limaronaldo deleted the rm/ws-sse-ticket-token branch August 11, 2026 03:07

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

const effectiveTtl = Number.isFinite(ttlMs) && ttlMs > 0 ? ttlMs : TICKET_DEFAULT_TTL_MS;
const exp = now + effectiveTtl;

P2 Badge Validate the configured TTL before issuing tickets

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


const material = [...keys].sort().join("");

P2 Badge Escape the NUL separator in source

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 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +201 to +204
if (isRawQueryTokenAllowed()) {
const rawToken = url.searchParams.get("token");
if (rawToken && isValidToken(rawToken, keys)) {
return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant