Skip to content

fix(security): stop putting long-lived JWTs in SSE/WebSocket URL query strings (#745) - #800

Merged
frankbria merged 7 commits into
mainfrom
fix/745-stream-ticket-auth
Jul 3, 2026
Merged

fix(security): stop putting long-lived JWTs in SSE/WebSocket URL query strings (#745)#800
frankbria merged 7 commits into
mainfrom
fix/745-stream-ticket-auth

Conversation

@frankbria

@frankbria frankbria commented Jul 3, 2026

Copy link
Copy Markdown
Owner

Summary

Implements #745 (P1.18, security): SSE/WS streams no longer authenticate with the raw 24h JWT in ?token= (which leaks via proxy/access logs, APM traces, and browser history). Streams now use a short-lived (60s), single-use ticket minted by an authenticated POST /auth/stream-ticket and redeemed as ?ticket= — one uniform mechanism for both SSE (EventSource can't send headers) and WebSocket.

  • Backend: new codeframe/auth/stream_tickets.py (opaque secrets.token_urlsafe(32) tickets, 60s TTL, consumed on first redemption, thread-safe in-process store mirroring the rate-limiter singleton pattern). POST /auth/stream-ticket requires require_auth + write scope (via has_scope, so admin implies write) and the auth rate limit. get_current_user's allowlisted SSE query fallback and authenticate_websocket now redeem ?ticket=; ?token= is no longer accepted anywhere.
  • Frontend: fetchStreamTicket() + async withStreamTicket() replace withTokenParam. useEventSource and useTerminalSocket migrated to an {enabled, connectionKey, buildUrl} API whose async buildUrl is re-resolved on the initial connect and every retry, so a consumed ticket is never replayed. All four stream sites (task SSE, stress-test SSE, session-chat WS, terminal WS) mint a fresh ticket per connection attempt, with a bare-URL fallback for auth-disabled dev mode.

Acceptance Criteria

  • Streams authenticate via a short-lived single-use ticket (from an authenticated POST) — not the long-lived JWT in the URL. Proven by: ticket-store unit tests (expiry, single-use), endpoint tests (401/403/200 incl. scope matrix), SSE query-param tests (valid/expired/reused/wrong-path 401s, old ?token=<valid JWT> now 401s), WS auth tests, and a server-level enforcement test.
  • (The alternative "if deferred, scrub logs" criterion is N/A — not deferred.)

Test Plan

  • Unit tests written first (TDD) — RED confirmed before each fix
  • All tests passing: backend 3940 passed / 11 skipped (full CI gate), web-ui 1043/1043 + build + lint
  • Diff coverage ≥85% on changed lines: backend 92%, frontend 100%
  • Linting clean (ruff, next lint)
  • Internal review (advisory): lead-reviewed diffs; fixed a stale-connect race in useAgentChat (epoch guard)
  • Cross-family review pass: codex (3 rounds). Round 1: terminal-retry ticket replay + stale-workspace reconnect — both fixed. Round 2: P1 read-only-API-key→WS escalation — fixed (write scope required to mint). Round 3: P2 admin-scope hierarchy — fixed (has_scope).
  • Mutation sanity check: disabling single-use pop → test fails; renaming ticket=token= → 5 tests fail

Known Limitations / Intentionally Deferred

  • In-process ticket store: a ticket minted by one worker can't be redeemed on another under --workers > 1. Same accepted trade-off as the in-memory rate limiter (documented in the module docstring); Redis-backed store is the upgrade path.
  • Breaking for out-of-tree clients: any external client that used ?token=<JWT> on the two SSE routes or the WS routes must switch to the ticket flow. The web UI (the only in-tree client) is migrated.
  • Read-only API keys cannot mint tickets (by design, prevents WS escalation); header-capable clients authenticate SSE with X-API-Key directly.

Implementation Notes

  • Plan was self-authored (no plan comment on the issue); persisted in tasks/todo.md.
  • Ticket-for-both chosen over a WS first-message auth frame: one code path covers SSE + WS, matching the issue's primary acceptance option.
  • _QUERY_TOKEN_PATHS renamed to _QUERY_TICKET_PATHS (same two SSE regexes — tickets redeem only on stream paths).

Closes #745

Summary by CodeRabbit

  • New Features

    • Added short-lived stream tickets for SSE and WebSocket connections, improving streaming authentication for clients that can’t send headers.
    • Added a new endpoint to request stream tickets for supported authenticated users.
  • Bug Fixes

    • Streaming connections now refresh credentials on reconnect, reducing auth failures during retries.
    • WebSocket and EventSource flows now reject reused or expired tickets and fall back cleanly when no ticket is available.
  • Tests

    • Expanded coverage for ticket minting, expiry, reconnect behavior, and SSE/WS authentication across the app and web UI.

frankbria added 5 commits July 3, 2026 11:06
…eams with single-use tickets (#745)

Streams (EventSource/WebSocket) cannot send an Authorization header, so
the two allowlisted SSE routes and both WS routes accepted the raw 24h
JWT as ?token=. Query strings land in proxy/access logs and APM traces,
so a leaked line granted a day-long session takeover.

Backend: new codeframe/auth/stream_tickets.py mints opaque single-use
tickets (60s TTL, in-process store mirroring the rate limiter's
singleton pattern); POST /auth/stream-ticket (require_auth +
enforce_auth_rate_limit) mints one; get_current_user's SSE query
fallback and authenticate_websocket now redeem ?ticket= and no longer
accept ?token= anywhere.

Frontend: fetchStreamTicket() + async withStreamTicket() replace
withTokenParam; useEventSource takes an async buildUrl re-resolved on
every connect AND retry (single-use tickets must never be replayed);
all four stream URL sites (task stream, stress-test stream, session
chat WS, terminal WS) mint a fresh ticket per connection attempt, with
a bare-URL fallback for auth-disabled mode.

Test fallout migrated: query-param, WS-auth, session-chat and terminal
WS suites now exercise tickets, including expired/reused/unknown-ticket
and 'old ?token= no longer authenticates' cases.
…-test stream on workspace change

Cross-family review (codex) findings on #745:
- useTerminalSocket retried with the same closed-over URL, replaying the
  already-consumed single-use ticket; migrated to the same
  {enabled, connectionKey, buildUrl} shape as useEventSource so every
  connect AND retry re-resolves a fresh ticketed URL.
- useStressTestStream's connectionKey lost the workspace identity the old
  URL-based effect had; include workspacePath so switching workspaces
  while streaming reconnects instead of staying on the stale one.
A redeemed ticket acts as a full user session on the WS routes (terminal
input / chat mutate state), so a read-only API key could previously
escalate to WS access by minting a ticket (codex review P1). Read-only
keys don't need tickets: header-capable clients hit the SSE routes with
X-API-Key directly.
Direct membership check rejected admin-only API keys even though the
scope hierarchy treats admin as implying write (codex review P2); use
the shared has_scope() helper like other guards.
@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 43 minutes

Your organization has reached its usage spending cap. Adjust your spending cap in the billing tab.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 848a5b6e-3ff5-40ba-8d98-5d387ba59275

📥 Commits

Reviewing files that changed from the base of the PR and between 0329c82 and 90b0c72.

📒 Files selected for processing (3)
  • CLAUDE.md
  • codeframe/auth/dependencies.py
  • tests/auth/test_query_param_token.py

Walkthrough

Replaces long-lived JWT query-string authentication for SSE/WebSocket streams with short-lived, single-use stream tickets. Adds a backend ticket store, a POST /auth/stream-ticket endpoint, updated REST/WebSocket auth dependencies, and frontend hooks/components that fetch fresh tickets per connection attempt.

Changes

Backend Stream Ticket System

Layer / File(s) Summary
Ticket store
codeframe/auth/stream_tickets.py, tests/auth/test_stream_tickets.py
Adds an in-process, thread-safe single-use ticket store with mint_ticket, redeem_ticket, TicketRedemptionError, TTL-based expiry, and lazy sweeping.
Auth dependency wiring
codeframe/auth/dependencies.py, tests/auth/test_query_param_token.py, tests/auth/test_websocket_auth.py, tests/ui/test_v2_auth_enforcement.py
get_current_user and authenticate_websocket accept ?ticket= on allowlisted SSE paths and redeem it via the ticket store, replacing the prior ?token=<JWT> fallback.
Stream ticket endpoint
codeframe/auth/router.py, tests/auth/test_stream_ticket_endpoint.py
Adds POST /auth/stream-ticket, requiring authentication and write scope, minting a ticket and returning StreamTicketResponse.
Session chat WS tests
tests/api/test_session_chat_ws.py
Migrates WebSocket chat tests from JWT tokens to minted tickets across auth, protocol, and cleanup scenarios.
Terminal WS tests
tests/ui/test_terminal_ws.py
Migrates terminal WebSocket tests to ?ticket= for auth, ownership, and relay revalidation scenarios.

Estimated code review effort: 4 (Complex) | ~75 minutes

Frontend Stream Ticket Integration

Layer / File(s) Summary
Auth/API helpers
web-ui/src/lib/auth.ts, web-ui/src/lib/api.ts, web-ui/src/__tests__/lib/*
Adds fetchStreamTicket() and withStreamTicket() helpers replacing withTokenParam() for injecting single-use tickets into URLs.
useEventSource refactor
web-ui/src/hooks/useEventSource.ts, web-ui/__tests__/hooks/useEventSource.test.ts
Replaces static url option with enabled/connectionKey/async buildUrl, re-resolving fresh tickets on each connect and retry.
Task/Stress stream hooks
web-ui/src/hooks/useTaskStream.ts, web-ui/src/hooks/useStressTestStream.ts, related tests
Rewires SSE hooks to use enabled/connectionKey/buildUrl with withStreamTicket/fetchStreamTicket.
Terminal socket & AgentTerminal
web-ui/src/hooks/useTerminalSocket.ts, web-ui/src/components/sessions/AgentTerminal.tsx, related tests
Refactors terminal WebSocket connection to async buildUrl minting fresh tickets per attempt.
useAgentChat ticket flow
web-ui/src/hooks/useAgentChat.ts, web-ui/src/__tests__/hooks/useAgentChat.test.ts
Makes chat connection async, fetches a fresh ticket per attempt, and guards against superseded connections via a connect epoch counter.

Estimated code review effort: 4 (Complex) | ~70 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Browser
  participant AuthAPI as "POST /auth/stream-ticket"
  participant TicketStore
  participant StreamEndpoint as "SSE/WS Endpoint"

  Browser->>AuthAPI: POST with Bearer JWT
  AuthAPI->>TicketStore: mint_ticket(user_id)
  TicketStore-->>AuthAPI: opaque ticket
  AuthAPI-->>Browser: { ticket, expires_in }
  Browser->>StreamEndpoint: connect with ?ticket=...
  StreamEndpoint->>TicketStore: redeem_ticket(ticket)
  TicketStore-->>StreamEndpoint: user_id or error
  StreamEndpoint-->>Browser: stream established or 401/close
Loading

Possibly related PRs

  • frankbria/codeframe#163: Extends the same JWT-based codeframe/auth/dependencies.py authentication that this PR replaces with ticket-based redemption.
  • frankbria/codeframe#513: Implements the original ?token=<JWT>-based WebSocket connection logic in useAgentChat.ts that this PR migrates to stream tickets.
  • frankbria/codeframe#516: Implements the token-based WebSocket auth flow in terminal WS tests that this PR migrates to ?ticket=.

Poem

A rabbit hopped through logs one night,
and spied a JWT shining bright.
"Too long-lived!" she cried with a frown,
so she minted tickets, single-use, all around.
Now streams connect with a wink and a hop —
🎫 safe and swift, from the very top!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise, specific, and accurately summarizes the main security change from JWT query strings to stream tickets.
Linked Issues check ✅ Passed The PR implements the accepted short-lived single-use ticket flow and removes JWT query-string auth for streams, matching #745.
Out of Scope Changes check ✅ Passed The backend, frontend, and test changes all support the stream-ticket migration, with no obvious unrelated scope creep.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/745-stream-ticket-auth

Comment @coderabbitai help to get the list of available commands.

@claude

claude Bot commented Jul 3, 2026

Copy link
Copy Markdown

This is a well-executed security fix. Replacing long-lived JWTs in URL query strings with short-lived single-use tickets closes a real exposure window (proxy/access logs, browser history, APM traces) and the implementation is careful across the board.

What the PR does

Replaces ?token=<JWT> on SSE and WebSocket routes with ?ticket=<value> where a ticket is minted via POST /auth/stream-ticket (authenticated, write-scoped, rate-limited), is opaque (32 bytes from secrets.token_urlsafe), single-use, and expires in 60 seconds. The frontend resolves a fresh ticket per connection attempt and per retry, so a consumed ticket is never replayed.

Security strengths

  • 60s TTL + single-use enforcement closes the JWT-in-URL exposure to a very narrow window
  • Write scope required to mint prevents read-only API key escalation to WebSocket sessions (the codex P1 finding is correctly fixed)
  • has_scope scope hierarchy means admin-only keys can also mint without being explicitly denied
  • Lazy sweep + _lock prevents unbounded store growth and is thread-safe
  • _now() indirection makes expiry logic fully testable without patching time directly
  • Mutation sanity check (disabling pop causes test failure; renaming ticket= to token= fails 5 tests) is excellent practice

One design point worth confirming

In no-auth mode (CODEFRAME_AUTH_REQUIRED=false), the endpoint mints a ticket with user_id=None, which _authenticate_stream_ticket would immediately reject with 401. This is safe because in no-auth mode neither get_current_user (SSE) nor authenticate_websocket (WS) ever reach the ticket-check path — they short-circuit earlier. The comment "Only mintable while auth was disabled at mint time" correctly explains this; just worth keeping in mind if the auth flow changes in a future PR.

Implementation quality

stream_tickets.py is clean and minimal. The module-singleton pattern mirrors rate_limiter.py as stated.

The dependencies.py refactor is a real improvement: breaking get_current_user into _authenticate_bearer_token, _load_active_user, and _authenticate_stream_ticket means the same active-user check is shared by both the JWT and ticket paths.

One minor note on router.py: the auth parameter is typed as Dict[str, Any], which is loose. If require_auth ever changes its return shape, a silent None from auth.get("user_id") would be the first signal. Not blocking for this PR, but narrowing the type would be safer long-term.

The frontend buildUrl: () => Promise<string | null> API is the right design — it ensures a consumed ticket is never passed to a retry. connectionKey gives callers a clean way to force re-resolution without toggling enabled. The AgentTerminal.test.tsx pattern of capturing the buildUrl closure and invoking it directly is a particularly good approach for testing async URL construction.

Multi-worker limitation

Documented in the module docstring. Mirrors the rate-limiter limitation. Acceptable for self-hosted single-worker deployments; the Redis upgrade path is the same one already documented for the rate limiter.

Test coverage

Comprehensive: unit tests for mint/redeem/expiry/single-use/sweep; endpoint tests for all scope combinations; SSE query-param regression confirming ?token=<valid JWT> now 401s; WS auth tests covering expired, reused, missing, and valid tickets; frontend tests verifying fresh ticket per connect and per retry, URL-encoding, and null-ticket fallback.

Small observation: _seed_active_user is defined inline in test_websocket_auth.py while test_stream_ticket_endpoint.py uses setup_test_user from tests/conftest.py. They appear equivalent — worth consolidating in a follow-up, but not blocking.

Breaking change

Old ?token=<JWT> clients on the two SSE routes or any WS route will get 401s immediately after this lands. The PR documents this correctly. The web UI (the only in-tree client) is fully migrated.

Summary

Solid, well-tested security improvement with conservative scope, documented trade-offs, and a test suite that would catch backsliding to the old pattern. No blocking concerns.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (5)
web-ui/src/__tests__/hooks/useAgentChat.test.ts (1)

149-264: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Missing test for the epoch-guard's actual race-condition path.

The async conversions and new "fresh ticket per reconnect" test look correct. However, none of the tests exercise the specific race connectEpochRef was introduced to guard against: disconnect() (unmount or sessionId change) firing while fetchStreamTicket() is still pending. Every existing test awaits flushConnect() (i.e., lets the ticket resolve) before triggering unmount/rerender, so the if (connectEpochRef.current !== epoch) return; branch in useAgentChat.ts is never actually hit by the suite.

Consider adding a test with a manually-controlled ticket promise, e.g.:

it('bails out if disconnected while the ticket fetch is in flight', async () => {
  let resolveTicket: (v: string | null) => void;
  mockFetchTicket.mockReturnValue(
    new Promise((res) => { resolveTicket = res; })
  );
  const { unmount } = renderHook(({ id }) => useAgentChat(id), {
    initialProps: { id: 'session-1' },
  });
  unmount();
  await act(async () => { resolveTicket('test-ticket'); await Promise.resolve(); });
  expect(MockWebSocket.instances).toHaveLength(0);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web-ui/src/__tests__/hooks/useAgentChat.test.ts` around lines 149 - 264, The
useAgentChat test suite is missing coverage for the connectEpochRef race guard
when disconnect happens while fetchStreamTicket is still pending. Add a test in
useAgentChat.test.ts that controls the ticket promise manually, starts
useAgentChat with a sessionId, triggers unmount or rerender before the promise
resolves, then resolves the ticket and asserts no WebSocket is created;
reference useAgentChat, fetchStreamTicket, and the connectEpochRef cancellation
branch to locate the path being exercised.
web-ui/src/hooks/useAgentChat.ts (1)

127-144: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Epoch guard is correctly placed — but ticket-URL logic duplicates withStreamTicket.

The connectEpochRef race guard is sound: epoch is captured before the only await, and rechecked immediately after, before any WebSocket is created — no interleaving window exists. No functional issue here.

However, lines 143-144 manually re-implement the same "append ticket= query param if present" logic that withStreamTicket() (web-ui/src/lib/auth.ts) already provides, and which sibling hooks in this cohort (e.g. useTerminalSocket) use. This diverges from the intended shared pattern and risks drift if the ticket param name/encoding ever changes.

♻️ Proposed refactor to reuse `withStreamTicket`
-import { fetchStreamTicket, verifyAuthAfterStreamFailure } from '`@/lib/api`';
+import { fetchStreamTicket, verifyAuthAfterStreamFailure } from '`@/lib/api`';
+import { withStreamTicket } from '`@/lib/auth`';
     const epoch = connectEpochRef.current;
-    const ticket = await fetchStreamTicket();
+    const url = await withStreamTicket(
+      `${WS_BASE_URL}/ws/sessions/${sessionId}/chat`,
+      fetchStreamTicket
+    );
     // disconnect() (unmount / session change) may have run while the ticket
     // request was in flight; bail rather than opening a stale connection.
     if (connectEpochRef.current !== epoch) return;
-
-    const ticketParam = ticket ? `?ticket=${encodeURIComponent(ticket)}` : '';
-    const url = `${WS_BASE_URL}/ws/sessions/${sessionId}/chat${ticketParam}`;
     const ws = new WebSocket(url);

withStreamTicket is a pure function (no side effects beyond invoking the passed fetchTicket), so the existing @/lib/api mock in the test file needs no changes for this refactor.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web-ui/src/hooks/useAgentChat.ts` around lines 127 - 144, The ticket URL
assembly in useAgentChat is duplicating the shared withStreamTicket helper
instead of using the existing auth utility. Refactor the WebSocket connect path
in useAgentChat to pass the base ws URL through withStreamTicket (the same
pattern used by useTerminalSocket), so ticket query-string construction stays
centralized and consistent with web-ui/src/lib/auth.ts.
web-ui/src/hooks/useTerminalSocket.ts (1)

92-171: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Guard connect() against thrown/rejected buildUrl/WebSocket construction.

await buildUrlRef.current() and new WebSocket(url) aren't wrapped in try/catch. Since connect() is invoked via void connect(), an unexpected throw (a future buildUrl implementation rejecting, or a malformed url making new WebSocket() throw synchronously) would leave status stuck at 'connecting' indefinitely with an unhandled promise rejection, rather than surfacing 'error'. Not currently reachable since fetchStreamTicket/withStreamTicket never throw, but worth hardening since this is a reusable, generic hook contract.

🛡️ Proposed fix to harden `connect()` against unexpected errors
     const connect = async () => {
       setStatus('connecting');

-      const url = await buildUrlRef.current();
-      if (cancelled) return;
-      if (!url) {
-        setStatus('error');
-        return;
-      }
-
-      const ws = new WebSocket(url);
+      let url: string | null;
+      try {
+        url = await buildUrlRef.current();
+      } catch {
+        if (!cancelled) setStatus('error');
+        return;
+      }
+      if (cancelled) return;
+      if (!url) {
+        setStatus('error');
+        return;
+      }
+
+      let ws: WebSocket;
+      try {
+        ws = new WebSocket(url);
+      } catch {
+        setStatus('error');
+        return;
+      }
       ws.binaryType = 'arraybuffer';
       wsRef.current = ws;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web-ui/src/hooks/useTerminalSocket.ts` around lines 92 - 171, `connect()` in
`useTerminalSocket` needs error hardening around `buildUrlRef.current()` and
`new WebSocket(url)`, since either can throw/reject and leave the hook stuck in
`connecting`. Wrap the async URL resolution and socket construction in
`try/catch` inside `connect`, and on any unexpected failure clear the in-flight
attempt and set status to `error` (while still respecting the `cancelled`
guard). Keep the retry/close flow in the existing `connect`, `close`, and
`ws.onclose` paths unchanged except for routing thrown setup failures into the
same error state.
web-ui/src/components/sessions/AgentTerminal.tsx (1)

66-76: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reuse withStreamTicket instead of duplicating ticket-append logic.

This manually reimplements the same ?ticket=<encoded> construction that withStreamTicket() (web-ui/src/lib/auth.ts) already provides and is used consistently by useStressTestStream/useTaskStream.

♻️ Proposed refactor to reuse `withStreamTicket`
+import { withStreamTicket } from '`@/lib/auth`';
+
   const buildUrl = useCallback(async (): Promise<string | null> => {
-    const ticket = await fetchStreamTicket();
-    const ticketParam = ticket ? `?ticket=${encodeURIComponent(ticket)}` : '';
-    return `${wsBase()}/ws/sessions/${sessionId}/terminal${ticketParam}`;
+    return withStreamTicket(`${wsBase()}/ws/sessions/${sessionId}/terminal`, fetchStreamTicket);
   }, [sessionId]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web-ui/src/components/sessions/AgentTerminal.tsx` around lines 66 - 76, Reuse
the existing withStreamTicket helper instead of manually building the ticket
query string in AgentTerminal’s buildUrl callback. Update buildUrl to delegate
ticket handling to withStreamTicket from web-ui/src/lib/auth.ts, using the same
pattern as useStressTestStream and useTaskStream, and keep the fallback behavior
for missing/failed tickets intact.
web-ui/src/hooks/useEventSource.ts (1)

113-122: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Consider guarding buildUrl() against thrown rejections.

connect() awaits buildUrlRef.current() with no try/catch. If a future buildUrl implementation throws instead of resolving (the type signature () => Promise<string | null> doesn't prevent this), the connection gets stuck in 'connecting' forever with an unhandled promise rejection and no retry. Today's callers (withStreamTicket/fetchStreamTicket) always resolve, so this isn't currently reachable, but it's a hidden expectation on all future buildUrl implementations.

🛡️ Proposed defensive fix
     const connect = async () => {
       setStatus('connecting');

-      const url = await buildUrlRef.current();
-      if (cancelled) return;
-      if (!url) {
-        setStatus('error');
-        return;
-      }
+      let url: string | null;
+      try {
+        url = await buildUrlRef.current();
+      } catch {
+        url = null;
+      }
+      if (cancelled) return;
+      if (!url) {
+        setStatus('error');
+        return;
+      }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web-ui/src/hooks/useEventSource.ts` around lines 113 - 122, The connect()
flow in useEventSource currently awaits buildUrlRef.current() without handling
thrown rejections, so a failing buildUrl can leave the hook stuck in
'connecting'. Update connect() to defensively wrap the buildUrlRef.current()
await in try/catch, and on error setStatus('error') and exit/abort the
connection path so future buildUrl implementations cannot leave the hook
hanging.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@codeframe/auth/dependencies.py`:
- Around line 198-227: Guard _authenticate_stream_ticket against failures from
_load_active_user just like authenticate_websocket does for auth-related
lookups. Wrap the await _load_active_user(user_id) call in a try/except for the
same operational/auth exceptions used elsewhere in codeframe.auth.dependencies,
and translate them into HTTPException responses instead of letting them bubble
up as 500s. Keep the existing TicketRedemptionError and user_id is None handling
intact.

---

Nitpick comments:
In `@web-ui/src/__tests__/hooks/useAgentChat.test.ts`:
- Around line 149-264: The useAgentChat test suite is missing coverage for the
connectEpochRef race guard when disconnect happens while fetchStreamTicket is
still pending. Add a test in useAgentChat.test.ts that controls the ticket
promise manually, starts useAgentChat with a sessionId, triggers unmount or
rerender before the promise resolves, then resolves the ticket and asserts no
WebSocket is created; reference useAgentChat, fetchStreamTicket, and the
connectEpochRef cancellation branch to locate the path being exercised.

In `@web-ui/src/components/sessions/AgentTerminal.tsx`:
- Around line 66-76: Reuse the existing withStreamTicket helper instead of
manually building the ticket query string in AgentTerminal’s buildUrl callback.
Update buildUrl to delegate ticket handling to withStreamTicket from
web-ui/src/lib/auth.ts, using the same pattern as useStressTestStream and
useTaskStream, and keep the fallback behavior for missing/failed tickets intact.

In `@web-ui/src/hooks/useAgentChat.ts`:
- Around line 127-144: The ticket URL assembly in useAgentChat is duplicating
the shared withStreamTicket helper instead of using the existing auth utility.
Refactor the WebSocket connect path in useAgentChat to pass the base ws URL
through withStreamTicket (the same pattern used by useTerminalSocket), so ticket
query-string construction stays centralized and consistent with
web-ui/src/lib/auth.ts.

In `@web-ui/src/hooks/useEventSource.ts`:
- Around line 113-122: The connect() flow in useEventSource currently awaits
buildUrlRef.current() without handling thrown rejections, so a failing buildUrl
can leave the hook stuck in 'connecting'. Update connect() to defensively wrap
the buildUrlRef.current() await in try/catch, and on error setStatus('error')
and exit/abort the connection path so future buildUrl implementations cannot
leave the hook hanging.

In `@web-ui/src/hooks/useTerminalSocket.ts`:
- Around line 92-171: `connect()` in `useTerminalSocket` needs error hardening
around `buildUrlRef.current()` and `new WebSocket(url)`, since either can
throw/reject and leave the hook stuck in `connecting`. Wrap the async URL
resolution and socket construction in `try/catch` inside `connect`, and on any
unexpected failure clear the in-flight attempt and set status to `error` (while
still respecting the `cancelled` guard). Keep the retry/close flow in the
existing `connect`, `close`, and `ws.onclose` paths unchanged except for routing
thrown setup failures into the same error state.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3301f20c-a701-48db-a022-8b6b5c98b192

📥 Commits

Reviewing files that changed from the base of the PR and between 48f26b8 and 0329c82.

📒 Files selected for processing (26)
  • codeframe/auth/dependencies.py
  • codeframe/auth/router.py
  • codeframe/auth/stream_tickets.py
  • tests/api/test_session_chat_ws.py
  • tests/auth/test_query_param_token.py
  • tests/auth/test_stream_ticket_endpoint.py
  • tests/auth/test_stream_tickets.py
  • tests/auth/test_websocket_auth.py
  • tests/ui/test_terminal_ws.py
  • tests/ui/test_v2_auth_enforcement.py
  • web-ui/__tests__/hooks/useEventSource.test.ts
  • web-ui/src/__tests__/components/sessions/AgentTerminal.test.tsx
  • web-ui/src/__tests__/hooks/useAgentChat.test.ts
  • web-ui/src/__tests__/hooks/useStressTestStream.test.ts
  • web-ui/src/__tests__/hooks/useTaskStream.test.ts
  • web-ui/src/__tests__/hooks/useTerminalSocket.test.ts
  • web-ui/src/__tests__/lib/auth.test.ts
  • web-ui/src/__tests__/lib/fetchStreamTicket.test.ts
  • web-ui/src/components/sessions/AgentTerminal.tsx
  • web-ui/src/hooks/useAgentChat.ts
  • web-ui/src/hooks/useEventSource.ts
  • web-ui/src/hooks/useStressTestStream.ts
  • web-ui/src/hooks/useTaskStream.ts
  • web-ui/src/hooks/useTerminalSocket.ts
  • web-ui/src/lib/api.ts
  • web-ui/src/lib/auth.ts

Comment thread codeframe/auth/dependencies.py
frankbria added 2 commits July 3, 2026 11:54
…et path

CodeRabbit (PR #800, Major): _authenticate_stream_ticket let a DB error
during _load_active_user bubble out as a 500; the bearer path and
authenticate_websocket both convert unexpected failures to a controlled
auth failure. Mirror them.
@claude

claude Bot commented Jul 3, 2026

Copy link
Copy Markdown

Follow-up Review (Round 2)

Building on my prior review and CodeRabbit's pass, here is a status snapshot on the open items.

Resolved since prior reviews

CodeRabbit's actionable: _authenticate_stream_ticket exception guard — already addressed. dependencies.py:225–237 wraps _load_active_user in try/except HTTPException: raise plus a catch-all except Exception → 401, matching the bearer path. No further action needed.


Still open (CodeRabbit nitpicks — low severity, but worth a pass before merge)

1. Manual ticket URL construction duplicates withStreamTicket (two places)

useAgentChat.ts:143 and AgentTerminal.tsx:74 both hand-build the ticket query string:

const ticketParam = ticket ? `?ticket=${encodeURIComponent(ticket)}` : '';

The shared withStreamTicket(url, fetchTicket) helper in auth.ts already handles this — including the ?/& separator for URLs that already carry a query string. Both call sites already import fetchStreamTicket from @/lib/api, so the fix is:

const url = await withStreamTicket(baseUrl, fetchStreamTicket);

The terminal and stress-test hooks use this pattern correctly; these two are the outliers. Not blocking, but the inconsistency is a copy-paste trap for future work.

2. useEventSource doesn't guard buildUrl rejections

useEventSource.ts:116: const url = await buildUrlRef.current(); is unguarded. If a future buildUrl implementation throws (the Promise<string | null> type doesn't prevent it), the hook gets stuck in 'connecting' with an unhandled rejection and no retry. Current callers always resolve, so this isn't reachable today — but it's a hidden contract on every future implementor. Minimal fix:

let url: string | null;
try {
  url = await buildUrlRef.current();
} catch {
  url = null;
}

3. Missing epoch-guard race test in useAgentChat.test.ts

The connectEpochRef guard (bail if disconnected while the ticket fetch is in flight) has no test that exercises the guard branch. The suite always awaits ticket resolution before unmounting, so if (connectEpochRef.current !== epoch) return is never hit. CodeRabbit's suggested approach is sound: hold a deferred ticket promise, unmount, resolve it, and assert no WebSocket was created.


Summary

All three are minor quality/robustness items — none are security concerns and the core ticket flow is correct. Items 1 and 2 are quick wins (~5 lines each). Item 3 covers a subtle async invariant. Happy to approve once these are addressed, or if items 2 and 3 are tracked in a follow-up issue, that's fine given the overall quality of this PR.

@claude

claude Bot commented Jul 3, 2026

Copy link
Copy Markdown

Follow-up review (second pass)

My initial review found this to be a solid, well-tested security fix. This pass checks the CodeRabbit findings and any new commits since then.


CodeRabbit Actionable: DB error → 500 in _authenticate_stream_ticket — Already resolved

The code already has the correct guard around _load_active_user, and TestTicketUserLookupFailure.test_db_error_during_user_load_degrades_to_401_not_500 covers it. Good — this was handled before or alongside the CodeRabbit review.


CodeRabbit Nitpicks: Outstanding

1. Duplicate ticket-append logic in useAgentChat.ts and AgentTerminal.tsx

Both still manually construct the query param instead of delegating to withStreamTicket:

  • useAgentChat.ts ~line 143: manually builds `?ticket=${encodeURIComponent(ticket)}`
  • AgentTerminal.tsx ~line 72: same pattern

withStreamTicket exists precisely for this. Three places to update if the param name ever changes. Suggest tracking as a follow-up. Not blocking.

2. buildUrl rejection not guarded in useEventSource.ts and useTerminalSocket.ts

Both connect() functions await buildUrlRef.current() with no try/catch. If buildUrl ever rejects (vs. resolving to null), the hook gets stuck at connecting permanently with an unhandled promise rejection. Today's callers always resolve, so this is not a live bug, but it breaks the generic hook contract. A one-liner try/catch (as CodeRabbit showed) would harden it before other callers add buildUrl implementations. Not blocking.

3. Missing epoch-guard race test in useAgentChat.test.ts

The connectEpochRef guard is sound by inspection, but the specific race path — unmount fires while fetchStreamTicket() is still in flight — is not exercised. Every test awaits flushConnect() before triggering unmount/rerender, so the guard branch is never hit. A manually-controlled deferred-promise mock would cover it. Nice-to-have.


My Prior Note (Still Stands)

create_stream_ticket in router.py types auth as Dict[str, Any]. Non-blocking, but narrowing the return type from require_auth would make auth.get("user_id") returning None a type error rather than a silent runtime surprise.


Overall

No blocking concerns. The core security goal is achieved, the critical CodeRabbit actionable was already handled, and the remaining items are all minor DRY/robustness nitpicks. Ready to merge if the open nitpicks are tracked as follow-up work.

@frankbria
frankbria merged commit 62586b4 into main Jul 3, 2026
11 checks passed
@frankbria
frankbria deleted the fix/745-stream-ticket-auth branch July 3, 2026 19:07
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.

[P1.18] Stop putting long-lived JWTs in SSE/WebSocket URL query strings

1 participant