Skip to content

feat(web-ui): proactive auth guard + SSE/WS token-expiry re-auth path (#651)#690

Merged
frankbria merged 5 commits into
mainfrom
feature/issue-651-auth-guard-reauth
Jun 17, 2026
Merged

feat(web-ui): proactive auth guard + SSE/WS token-expiry re-auth path (#651)#690
frankbria merged 5 commits into
mainfrom
feature/issue-651-auth-guard-reauth

Conversation

@frankbria

Copy link
Copy Markdown
Owner

Summary

Implements #651: the web UI had no proactive auth guard — the only gate was a reactive axios 401 interceptor, so an unauthenticated visitor rendered the full shell before flickering to /login, and SSE/WebSocket token expiry (which auth via ?token=) died silently with no path back to re-auth.

  • Proactive route guard in AppLayout (client-side, because the JWT lives in localStorage and a server/edge middleware.ts can't read it). A visitor with no token only ever sees a neutral loader — the sidebar/shell never renders for them, so there's no shell flicker.
  • /login already-authenticated redirect/.
  • SSE/WS re-auth path: a shared probe hits /api/v2/settings/keys (a require_auth-gated endpoint that respects CODEFRAME_AUTH_REQUIRED). On genuine expiry the existing 401 interceptor clears the token and redirects to /login; transient failures don't falsely bounce the user. Wired into useEventSource (SSE; covers task + stress-test streams), useAgentChat (WS), and useTerminalSocket (WS).
  • Auth-disabled mode respected: with CODEFRAME_AUTH_REQUIRED=false the probe returns 200 without a token, so token-less local-dev access still works (the guard uses checkAuthAccess(), a bare-axios probe that returns allow/deny/fail-open without triggering the global redirect).

Acceptance Criteria

  • Unauthenticated navigation to a protected page redirects to /login without shell flicker.
  • Authenticated user hitting /login is redirected to /.
  • SSE/WS token expiry surfaces a re-auth path.

Test Plan

  • Unit tests written (TDD), incl. guard allow/deny/auth-disabled/fail-open, probe endpoint + semantics, and SSE/WS close-code handling
  • All tests passing (1006 web-ui tests)
  • Diff coverage: every new function/branch is exercised by an added test (new code only)
  • Linting clean (0 errors)
  • npm run build succeeds (TypeScript clean)
  • Internal code review (advisory) completed — surfaced the WS close-code issue below
  • Cross-family review pass: codex (3 rounds; final clean — auth-disabled + SSE-retry regressions found and fixed)
  • Test mutation sanity check completed (WS close-code gate + SSE retry-budget guard)

Known Limitations / Intentionally Deferred

  • WS auth close codes: the backend denies WS auth before accept(), so browsers report expiry as close code 1006 (abnormal), not the server's 1008/4001. The hooks therefore fire the re-auth probe on any non-normal close (!= 1000); the probe self-filters (only a genuine 401 redirects), so transient closes still reconnect. The server-side close codes are left unchanged (out of scope).
  • Guard is client-side only — unavoidable given the localStorage-stored JWT; a server middleware cannot gate it.
  • Mid-session expiry on a token already in the shell still relies on the reactive interceptor for axios calls (AC1 targets the unauthenticated visitor, which is now fully proactive).

Implementation Notes

  • Self-authored plan (the issue had no implementation-plan comment).
  • The probe targets /api/v2/settings/keys rather than fastapi-users /users/me specifically because the latter 401s even when auth is disabled; the former honors require_auth/CODEFRAME_AUTH_REQUIRED.

Closes #651

…#651)

Add a client-side route guard in AppLayout (the JWT lives in localStorage, so
server/edge middleware can't gate it): unauthenticated visitors to a protected
route see a neutral loader and are redirected to /login — the sidebar shell
never renders, so there is no shell flicker. The /login page redirects an
already-authenticated visitor to /.

For SSE/WebSocket streams (which auth via ?token= and whose failures the axios
401 interceptor never sees), add a shared verifyAuthAfterStreamFailure() probe
that hits /users/me via the axios client: a genuine expiry returns 401 and the
existing interceptor clears the token and redirects to /login, while transient
failures (200/network) don't falsely bounce the user. Wired into useEventSource
(terminal CLOSED — covers task + stress-test SSE), useAgentChat (WS close 1008),
and useTerminalSocket (WS close 4001). Proxy /users/* in next.config.js so the
probe reaches the backend.

Closes #651
…h probe (#651)

Cross-family review (codex) found the guard and stream re-auth probe broke the
supported CODEFRAME_AUTH_REQUIRED=false local-dev mode:
- AppLayout redirected every protected route to /login whenever no JWT was
  stored, trapping token-less auth-disabled users on the login page.
- the stream probe hit fastapi-users /users/me, which 401s without a JWT even
  when auth is disabled, redirecting closed streams to /login.

Both now probe /api/v2/settings/keys, which is gated by the backend require_auth
dependency and therefore RESPECTS CODEFRAME_AUTH_REQUIRED (returns 200 without a
token when auth is disabled, 401 only when auth is required). AppLayout uses a
new checkAuthAccess() (bare axios, no global-interceptor redirect) to decide
allow/deny/fail-open; the stream probe keeps the intercepted client. The
now-unneeded /users/* proxy is reverted.

Also harden the WS re-auth: the backend denies WS auth before accepting the
handshake, so browsers report expiry as close code 1006 (not 1008/4001) —
fire the probe on any non-normal (!= 1000) close instead of specific codes;
the probe self-filters so transient closes still reconnect.

Addresses codex P1/P2 and internal-review Major.
)

Cross-family review (codex round 2) found a regression: resetting retriesRef in
useEventSource.onopen meant an endpoint that accepts then immediately closes
without sending a message refunded its retry budget every cycle, so maxRetries
was never reached — an infinite reconnect loop. Restore the original contract
(reset the retry counter only on a received message, not on open); the auth-probe
guard re-arms on the same message signal. Add a regression test proving the
budget is exhausted even when each connection opens before closing.
@coderabbitai

coderabbitai Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@frankbria, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 1 hour, 5 minutes, and 25 seconds. Learn how PR review limits work.

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

⌛ How to resolve this issue?

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.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 55e8a0d7-c413-4021-a155-6f53a9f50e1c

📥 Commits

Reviewing files that changed from the base of the PR and between 831a4d0 and 806983e.

📒 Files selected for processing (14)
  • CLAUDE.md
  • web-ui/__tests__/hooks/useEventSource.test.ts
  • web-ui/src/__tests__/app/login.test.tsx
  • web-ui/src/__tests__/components/layout/AppLayout.test.tsx
  • web-ui/src/__tests__/hooks/useAgentChat.test.ts
  • web-ui/src/__tests__/hooks/useTerminalSocket.test.ts
  • web-ui/src/__tests__/lib/verifyAuthAfterStreamFailure.test.ts
  • web-ui/src/app/login/page.tsx
  • web-ui/src/components/layout/AppLayout.tsx
  • web-ui/src/hooks/useAgentChat.ts
  • web-ui/src/hooks/useEventSource.ts
  • web-ui/src/hooks/useTerminalSocket.ts
  • web-ui/src/lib/api.ts
  • web-ui/src/lib/auth.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/issue-651-auth-guard-reauth

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

@claude

claude Bot commented Jun 17, 2026

Copy link
Copy Markdown

Code Review for PR 690: proactive auth guard + SSE/WS token-expiry re-auth path

This is a solid, well-scoped implementation. The two-probe design (shared intercepted client for stream failures, bare axios for route guard) is the right architecture for the problem.

What is working well:

  1. Correct probe separation: verifyAuthAfterStreamFailure routes through the shared axios client so the 401 interceptor fires and redirects; checkAuthAccess uses bare axios so the route guard decides, no surprise redirects in AppLayout.

  2. Auth-disabled mode fully respected: checkAuthAccess returns allowed on 200 regardless of whether a token was sent, so CODEFRAME_AUTH_REQUIRED=false keeps working.

  3. Fail-open on probe errors: error from checkAuthAccess renders the shell rather than trapping the user, correct for a network/5xx failure.

  4. Hydration-safe guard: two-effect pattern (setMounted, then auth check conditioned on mounted) means server and first client render both produce FullPageLoader, avoiding hydration mismatches.

  5. authProbeFiredRef in useEventSource prevents probe spam across repeated retries. Re-armed on a successful message, right reset point.

  6. Cleanup flag (cancelled) prevents a stale async probe from dispatching setAccess after unmount.

  7. Test coverage: guard allow/deny/fail-open, probe semantics, SSE retry budget, WS close code discrimination, and the already-authenticated redirect on /login are all covered.


Issues and observations:

Issue 1 - WS hooks can probe on every retry cycle (minor): useAgentChat and useTerminalSocket fire the probe on every non-normal close. With MAX_RETRIES=5 a repeated 1006 loop emits up to 5 probes before giving up. Functionally harmless (probe self-filters) but inconsistent with the SSE hook explicit once-per-sequence guard. Consider adding an authProbeFiredRef analog to the WS hooks, resetting on successful open. Low priority if intentional.

Issue 2 - WS_NORMAL_CLOSURE_CODE = 1000 duplicated: defined independently in useAgentChat.ts and useTerminalSocket.ts. A shared constant would be one source of truth. Low-priority nit given the no-premature-abstractions rule in CLAUDE.md.

Issue 3 - access state has no explicit denied/redirecting value: when checkAuthAccess returns denied, the component calls router.replace('/login') but access stays pending forever. UX is correct (loader shown during redirect), but the implicit terminal state could surprise a future reader. Consider a redirecting variant to make the state machine explicit. Not blocking.

Issue 4 - Verify getToken is in scope in checkAuthAccess (confirm only): checkAuthAccess calls getToken() but the diff starts at line 165, so the earlier definition is not visible. This should already be a local helper in api.ts used by createApiClient. Just confirm it is not accidentally calling something undefined; if getToken only lives in auth.ts an explicit import is needed.

Issue 5 - API_ORIGIN evaluated at module load: module-level, inlined at Next.js build time. Fine for this use case, consistent with the existing pattern.


Summary: The core cases are handled correctly -- no shell flicker for unauthenticated visitors, auth-disabled mode preserved, and a re-auth path wired into all three stream types. Test coverage is thorough. Issue 1 (WS probe frequency) and Issue 3 (explicit denied state) are most worth considering before merge; the rest are confirmation or nit. Approve conditional on confirming Issue 4.

@frankbria

Copy link
Copy Markdown
Owner Author

Review triage (claude[bot])

Thanks — triaged, no blocking findings. Summary:

  • Issue 4 (confirm getToken scope) — ✅ confirmed in scope: api.ts imports { getToken, clearToken } from './auth' (line 9); npm run build / TypeScript is green.
  • Issue 1 (WS probes up to 5×/retry loop) — accepted as-is. The probe self-filters (only a genuine 401 redirects) and during a real network outage the probes fail at the network level and are swallowed, so the cost is a handful of lightweight GETs. A once-guard risks suppressing a re-probe if expiry occurs after a transient reconnect, so the safety-net behavior is intentional.
  • Issue 2 (dup WS_NORMAL_CLOSURE_CODE = 1000) — skipped: two one-line constants; a shared module would be a premature abstraction per CLAUDE.md.
  • Issue 3 (no explicit denied/redirecting access state) — skipped: UX is correct (loader shown while router.replace('/login') runs); documented via the comment on the effect.
  • Issue 5 (API_ORIGIN at module load) — confirmation; matches existing pattern.

CodeRabbit was rate-limited and posted no detailed findings.

Demo (Phase 11): all three acceptance criteria verified with outcome evidence against a live stack (FastAPI :8000 + Next :3001 + headless browser):

  • AC1 — visiting /tasks unauthenticated redirected to /login with no sidebar/<aside> rendered (no shell flicker).
  • AC2 — an authenticated visit to /login redirected to / (workspace selector rendered, form gone).
  • AC3 — an SSE stream with an expired token failed (readyState CLOSED), and the app's probe to /api/v2/settings/keys with that token returned 401 → token cleared → redirected to /login. Auth-disabled mode separately verified: same endpoint returns 200 without a token, so the guard renders the shell (no false redirect).

@claude

claude Bot commented Jun 17, 2026

Copy link
Copy Markdown

Follow-up review (after triage)

All five issues from the first pass were resolved or explicitly accepted — no prior findings remain open.

Prior items — confirmed closed:

  • Issue 4 (getToken scope): confirmed in scope via import on line 9.
  • Issues 1/2/3/5: accepted as-is per triage; rationale is sound (WS probe self-filters, WS_NORMAL_CLOSURE_CODE duplication is sub-threshold for abstraction, pending state implicit-but-correct, API_ORIGIN matches existing pattern).

New observation (trivial, non-blocking):

In useEventSource.ts, the authProbeFiredRef comment reads: "to avoid spamming /users/me". The probe was changed to /api/v2/settings/keys (correctly documented in the PR notes), but this comment was not updated. Worth a one-word fix if you want it accurate, but it does not affect correctness.


Implementation quality:
The two-client design (verifyAuthAfterStreamFailure via shared intercepted client, checkAuthAccess via bare axios) is clean and correctly scoped. The two-effect hydration guard (setMounted then auth check conditioned on mounted) is the right pattern for SSR-safe localStorage access. Test coverage is thorough: guard allow/deny/fail-open, probe endpoint semantics, SSE once-per-sequence guard, WS close-code discrimination, and already-authenticated /login redirect.

Nothing blocking. Ready to merge.

@claude

claude Bot commented Jun 17, 2026

Copy link
Copy Markdown

Follow-up review (continuing from prior [claude] comment)


Issue 4 (getToken in checkAuthAccess) — CONFIRMED CLEAR. getToken is imported at the top of api.ts (line 9: import { getToken, clearToken } from './auth';), so it is fully in scope inside checkAuthAccess. No bug here.


Open items from prior review — still unresolved (no new commits observed):

  • Issue 1 (useAgentChat / useTerminalSocket probe fires on every retry): Both WS hooks call verifyAuthAfterStreamFailure on every non-normal close with no once-per-sequence guard, unlike useEventSource's authProbeFiredRef. With MAX_RETRIES = 5 this can emit up to 5 probe requests per expiry event. Functionally safe (the probe self-filters), but worth aligning for consistency if you want to keep the retry count low.

  • Issue 2 (WS_NORMAL_CLOSURE_CODE duplicated): The constant is defined identically in both useAgentChat.ts (line 20) and useTerminalSocket.ts (line 8). Low-priority given the project's no-premature-abstraction rule, but easy to fix if you share it from a lib/websocket.ts constants module.

  • Issue 3 (access state): When checkAuthAccess returns 'denied', router.replace('/login') is called but access stays 'pending' for the lifetime of the redirect. UX is correct (loader is shown), but the state machine has an implicit terminal. Adding a 'redirecting' variant or commenting the intent at the denied branch would make the logic easier to follow for a future reader. Not blocking.


Overall: The implementation is clean and the core auth guard architecture is sound. Issues 1–3 are all minor or stylistic. No correctness bugs found. This looks ready to merge when the author is comfortable with the open items.

@frankbria frankbria merged commit 305726b into main Jun 17, 2026
10 checks passed
@frankbria frankbria deleted the feature/issue-651-auth-guard-reauth branch June 17, 2026 00:41
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.

[P6.7.1] Add proactive auth guard + SSE/WS token-expiry re-auth path

1 participant