feat(auth): require Bearer token auth on /api/* endpoints (ENG-1671) - #425
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 01c861fe46
ℹ️ 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".
| // Require authentication for /api/* (except public paths) — ENG-1671 | ||
| const authResponse = authMiddleware(req); |
There was a problem hiding this comment.
Authenticate upgrades before calling server.upgrade
The actual Bun request flow upgrades /api/ws/tasks in src/index.ts before calling handleRequest; when server.upgrade succeeds, it returns immediately and never reaches this middleware. Consequently, a client with no token or an invalid token can still open the production WebSocket and receive task events. Apply the authentication check in the pre-upgrade branch or ensure upgrades pass through the authenticated router first.
Useful? React with 👍 / 👎.
| if (!token || !isValidToken(token, keys)) { | ||
| return unauthorizedResponse(); |
There was a problem hiding this comment.
Provide credentials for the first-party web client
When either API-key variable is configured, this rejection path makes the existing first-party web application unusable: its requests in packages/web/src use plain fetch without an Authorization header, and services/sse.service.ts opens /api/logs/stream without the supported token query parameter. Dashboard reads, task actions, settings, plans, and live updates therefore receive 401 responses. Add a secure client authentication/session or proxy mechanism and wire both fetch and SSE requests through it rather than enabling the guard without a credential source.
Useful? React with 👍 / 👎.
| // Require authentication for /api/* (except public paths) — ENG-1671 | ||
| const authResponse = authMiddleware(req); |
There was a problem hiding this comment.
Declare bearer authentication in the OpenAPI document
This global authentication requirement is not reflected in core/openapi.ts: the published description still says most endpoints are public, and the only declared security scheme is webhookSignature. With API keys enabled, Swagger UI's try-it requests and clients generated from /openapi.json omit the bearer token and receive 401 responses. Add an HTTP bearer scheme and apply it globally or to protected operations, explicitly exempting the public endpoints.
Useful? React with 👍 / 👎.
limaronaldo
left a comment
There was a problem hiding this comment.
Review: PR #425 — ENG-1671, Bearer token auth on /api/*
Verdict: CHANGES-NEEDED (1 BLOCKER, 2 HIGH, 2 MEDIUM, 1 LOW)
Reviewed via gh pr diff/gh api (no clone/checkout), 3 independent passes (Security, Correctness, Tests/Quality) plus an external cross-model review via codex exec -m gpt-5.6-terra --sandbox read-only. Codex independently reproduced the BLOCKER finding and surfaced 2 additional HIGH findings that were folded into this consolidation.
Findings
| Sev | File:Line | Finding | Suggested fix |
|---|---|---|---|
| BLOCKER | packages/api/src/index.ts (pre-existing, not in diff) + packages/api/src/router.ts:7780 |
Bun.serve's fetch handler in index.ts special-cases GET /api/ws/tasks WebSocket upgrades and calls server.upgrade() before handleRequest() runs. authMiddleware() is only invoked inside handleRequest() (router.ts:7780), so it never executes for real WS upgrade traffic. The PR's own claim ("?token= authenticates WS") and its passing test (auth.test.ts, "WS upgrade path accepts ?token=") only exercise authMiddleware() in isolation against a synthetic Request — they never call through index.ts's actual fetch callback, so the bypass is invisible to the test suite. Net effect: /api/ws/tasks is unauthenticated in production despite this PR. |
Move the WS-upgrade special case in index.ts to run authMiddleware(req) (or an equivalent check) before calling server.upgrade(), and reject with a close/deny if it fails. Add an integration test that drives the real fetch handler (or a Bun.serve-backed test server) for an unauthenticated upgrade attempt and asserts it is rejected. |
| HIGH | packages/api/src/core/auth.ts (fail-open branch, ~line 124 in file / diff line 313) |
When no API keys are configured (getConfiguredApiKeys().length === 0), the middleware fails closed only when process.env.NODE_ENV === "production"; any other value (unset, "staging", a typo, "prod" instead of "production") falls through to fail-open, allowing all /api/* requests with only a console.warn. Given NODE_ENV is commonly unset or misconfigured in containerized/deploy environments, this is a realistic full-auth-bypass path, not just a dev convenience. |
Invert the check to an explicit allowlist: fail-closed by default, and only fail-open when a distinct, explicit dev flag is set (e.g. MULTIPLAI_ALLOW_UNAUTHENTICATED_API=true), independent of NODE_ENV string matching. At minimum, fail-closed on any NODE_ENV other than a small explicit dev/test set. |
| HIGH | packages/api/src/core/auth.ts:141-142 (diff lines 330-331), QUERY_TOKEN_PATHS |
/api/logs/stream and /api/ws/tasks accept the raw, long-lived MULTIPLAI_API_KEY/MULTIPLAI_API_KEYS value via ?token= query string. Query strings are commonly captured in reverse-proxy/access logs, APM/monitoring tools, and browser history — this leaks a reusable, non-expiring credential outside the Authorization header's usual handling. |
Issue a short-lived, scope-limited token (e.g. signed, single-purpose, expiring in seconds/minutes) for the SSE/WS handshake instead of accepting the primary API key verbatim in the URL. |
| MEDIUM | packages/api/src/core/auth.test.ts line ~169 (diff line 175), "WS upgrade path accepts ?token=" |
This test name/assertion implies WS auth is enforced end-to-end, but it only calls authMiddleware() directly — it does not exercise index.ts's fetch/server.upgrade() path. As written, this test will stay green even after the BLOCKER above is fixed or reintroduced, giving false confidence. |
Rename to clarify it tests only the middleware function in isolation (e.g. "authMiddleware accepts ?token= for WS path pattern"), and add the integration-level test recommended in the BLOCKER fix. |
| MEDIUM | packages/api/src/core/auth.ts:280-288, authNotConfiguredResponse() |
Returns HTTP 503 with body {"error":"Service Unavailable","message":"auth not configured"} for the "no keys configured, production" case. Semantically defensible (503 = server not correctly provisioned to serve the request) rather than 403 — this is fine, but flagging for explicit confirmation since the task brief called out 401-vs-403 semantics: no correctness issue found here, 401 is correctly used for actual invalid/missing-token cases and 503 for the misconfiguration case. No action needed; documenting as a verified-correct item. |
|
| LOW | packages/api/src/core/auth.test.ts (no line — coverage gap) |
No test covers: multiple/duplicate Authorization headers, a header value with only "Bearer" and no token, or Authorization header containing embedded newlines/extra whitespace beyond a single space. Low risk given extractBearerToken's regex (^Bearer\s+(.+)$/i) is reasonably strict, but worth covering given this is new security-critical surface. |
Add 2-3 negative tests for malformed/edge-case Authorization header values. |
Pass notes
SEGURANÇA (Security). Constant-time comparison is implemented correctly: both sides are SHA-256-hashed to fixed-length digests before timingSafeEqual (auth.ts:238-260), which avoids the common pitfall of timingSafeEqual throwing (or being skipped) on length-mismatched inputs — this specifically prevents a length-based timing oracle. No hardcoded secrets found in the diff; keys are sourced only from MULTIPLAI_API_KEYS/MULTIPLAI_API_KEY env vars. No token values are logged (the "no keys configured" warning does not include any token/key material). /webhooks/github is correctly left outside the /api/* guard (untouched, confirmed via router.ts inspection) since it's a different path prefix and has its own signature-based auth. CORS: addCorsHeaders() is applied to 401/503 auth-failure responses in router.ts (confirmed in full router.ts read, lines ~7776-7801), consistent with the PR's stated behavior; since auth here is Bearer-header/token based (not cookie-based), a permissive CORS origin does not itself create a CSRF/credential-leak vector — no issue found. The two BLOCKER/HIGH findings above (WS bypass, NODE_ENV fail-open default, token-in-URL) are the substantive security gaps.
CORREÇÃO (Correctness). 401 is used for missing/invalid tokens, 503 for "not configured in production" — both semantically appropriate, no 403 usage needed since this is authentication (who you are), not authorization (what you're allowed to do). Edge cases are well handled in the unit-level middleware: case-insensitive Bearer scheme (verified in test + regex /i flag), case-sensitive /api/ path-prefix match (explicit test + code comment), and dot-segment normalization relies on the URL API resolving ../. before matching (verified test: /api/../api/tasks → guarded). /api/health and non-/api paths (/webhooks/github, /docs, /redoc, /openapi.json) are correctly exempted. No regressions found in existing route registration or dispatch order — authMiddleware is inserted after the pre-existing rateLimitMiddleware check and before route dispatch, which is the correct position. The one real correctness/compatibility gap is the WS bypass (BLOCKER above): internal WS clients that expect the new auth to apply will observe it silently not applying.
TESTES/QUALIDADE (Tests/Quality). 21 tests added in auth.test.ts, and they cover the middleware's own logic thoroughly: key sourcing (single vs CSV, trimming, empty), token validation (correct, wrong, wrong-length, CSV membership, no-keys-configured), and middleware behavior (401/503/allow paths, case sensitivity of scheme and path, dot-segment normalization, production fail-closed vs non-production fail-open, SSE/WS query-token accept/reject, query-token NOT accepted on regular API paths). No flakiness concerns — tests are synchronous, deterministic, and properly reset env/global state via beforeEach/afterEach. The critical gap is structural, not a missing assertion: every test calls authMiddleware() directly against a synthetic Request, so none of them exercise the actual Bun.serve fetch callback in index.ts — this is precisely why the WS-bypass BLOCKER survives a fully green test suite (see MEDIUM finding above).
Codex (external cross-model reviewer, gpt-5.6-terra, read-only sandbox). Ran to completion (no timeout). Verdict: FAIL — Production WebSocket upgrades bypass the new authentication gate, and the configuration default can expose every API route. Codex independently identified the same BLOCKER (WS bypass via index.ts/router.ts:7780) without being told the answer — it was given only the factual context that the WS special-case exists and asked to assess impact, and it correctly reasoned through to the same architectural conclusion. It additionally surfaced both HIGH findings above (NODE_ENV fail-open default, API key in query string) independently of my own passes, which were merged into the table (deduplicated — same underlying issues, consistent file:line citations after re-verification against the diff). It confirmed the same positive findings: case-insensitive Bearer, fixed-length constant-time comparison, no hardcoded secrets/token leakage in logs or errors.
Consolidation / validation notes
All findings above were re-confirmed against /tmp/pr425-full.diff (the untruncated diff — an initial gh pr diff fetch was silently compacted by output filtering and had to be re-fetched raw) and, for the BLOCKER, against a direct read of packages/api/src/index.ts (pre-existing file, not part of this diff, confirmed via gh api that it was last touched by an unrelated prior PR #410 and has zero references in this PR's diff). No candidate finding was discarded as a false positive in this round — Codex's independent findings all corroborated or extended (not contradicted) the manual passes.
- New core/auth.ts: validates tokens against MULTIPLAI_API_KEYS (CSV) or MULTIPLAI_API_KEY with constant-time comparison (SHA-256 digests + crypto.timingSafeEqual) - Fail-closed 503 in production when no key configured; single warning and allow in non-production - /api/health stays public; /webhooks/github, /docs, /redoc, /openapi.json untouched - SSE /api/logs/stream and WS /api/ws/tasks also accept ?token= - 21 tests in core/auth.test.ts
…y default BLOCKER: server.upgrade() for /api/ws/tasks ran inside Bun.serve's fetch handler before handleRequest()/authMiddleware() ever executed, so the WS endpoint was completely unauthenticated in production. index.ts now calls authMiddleware() directly before upgrading and returns 401 on failure. HIGH: no-keys-configured behavior previously failed open unless NODE_ENV === "production" exactly, so unset/staging/typo'd NODE_ENV values bypassed auth entirely. Inverted the default to fail-closed (503) in all cases, with two explicit opt-in escape hatches: ALLOW_UNAUTHENTICATED=1 or NODE_ENV=test. MEDIUM: documented the ?token= query-string auth risk (log/APM/history leakage) directly in auth.ts, kept request logging to pathname only (no query string) for the affected paths, and left a tracked follow-up to move to a short-lived scoped token for the SSE/WS handshake. LOW: added negative Authorization-header regression tests (missing token after scheme, whitespace-only token, non-Bearer scheme). Also reflects the new bearer-auth requirement in the generated OpenAPI spec, with /api/health correctly exempted via a per-operation security: [] override.
… hangs - Extract createFetchHandler() from main() so the real Bun.serve fetch handler (including the pre-upgrade authMiddleware() gate on /api/ws/tasks) can be exercised directly in tests, instead of only testing authMiddleware() in isolation. - Add index.test.ts: integration tests against a real Bun.serve instance covering unauthenticated/invalid-token/no-key-configured rejection and a full valid-token WS handshake, closing the gap where server.upgrade() bypasses handleRequest()/authMiddleware() entirely. - Guard main() behind NODE_ENV !== "test" so importing index.ts in tests doesn't trigger production startup (DB connections, model config load, stale-task cleanup, port binding). - rate-limiter.ts: unref() the module-top-level cleanup setInterval so it no longer keeps the process/test runner alive after work completes (was hanging any script importing router.ts transitively). - package.json: run tests with NODE_ENV=test set explicitly.
01c861f to
f615c48
Compare
|
Addressed the blocker and both HIGHs identified in review, in BLOCKER — WS upgrade unauthenticated (fixed) HIGH — fail-open when no keys configured (fixed) HIGH — Test/tooling notes
|
…ow-up) (#433) 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.
Summary
packages/api/src/core/auth.ts: Bearer-token authentication middleware for all/api/*endpointsMULTIPLAI_API_KEYS(CSV) orMULTIPLAI_API_KEY, using constant-time comparison (SHA-256 digest +crypto.timingSafeEqual) to avoid timing side channels/api/health,/webhooks/github,/docs,/redoc,/openapi.json/api/logs/streamand WS/api/ws/tasksmay also authenticate via?token=query param (EventSource/WebSocket cannot set headers)handleRequestinrouter.tsbefore route dispatch; 401 responses get CORS headersTesting
bun testoncore/auth.test.ts: 21 pass, 0 failbun run typecheck: only pre-existing error atrouter.ts(Property 'count' does not exist on type 'any[]') — confirmed present on main viagit stashbefore/after comparison; no new type errors introducedNotes