Skip to content

fix(dashboard): persist sign-in with an HttpOnly session cookie - #384

Merged
njbrake merged 6 commits into
mainfrom
fix/persist-dashboard-auth
Jul 23, 2026
Merged

fix(dashboard): persist sign-in with an HttpOnly session cookie#384
njbrake merged 6 commits into
mainfrom
fix/persist-dashboard-auth

Conversation

@njbrake

@njbrake njbrake commented Jul 23, 2026

Copy link
Copy Markdown
Member

Note: this PR was implemented and drafted by Claude via back-and-forth with @njbrake. The decisions (option B from the issue, SameSite instead of CSRF-token machinery, the 7-day default TTL) are his; the prose and code are Claude's.

Description

The dashboard forced operators to re-paste the master key on almost every open: the raw key lived in per-tab sessionStorage, so a new tab, a closed tab, or a browser restart lost the session.

This implements option B from #338, the server-issued session cookie. Signing in now sends the master key once to a new POST /v1/auth/session endpoint, which verifies it and answers with an opaque session token in an HttpOnly cookie. The sign-in survives tabs and restarts, and the browser never stores a JS-readable credential, so this is more convenient and more secure than the status quo at the same time.

Backend

  • New dashboard_sessions table (plus Alembic migration) storing only SHA-256 token hashes. DB-backed rather than in-memory so every worker and replica accepts a session and revocation is global, following the same reasoning as the alias/provider TTL refreshers.
  • POST /v1/auth/session mints the session; DELETE signs out (idempotent, revokes only the caller's own cookie session). Standalone-only, like the rest of the management API.
  • Cookie attributes: HttpOnly, SameSite=Strict, Path=/, Max-Age from the new dashboard_session_ttl_hours config field (default 168h). Secure mirrors the request scheme instead of being hard-coded, so plain-HTTP LAN and localhost deployments still receive the cookie back; that is no worse than such deployments already sending the raw key in cleartext.
  • api/deps.py: verify_master_key and verify_api_key_or_master_key accept the cookie only when the request carries no header credentials at all, so API clients keep exactly the previous behavior, including failure modes.
  • CSRF: SameSite=Strict is the primary control (the dashboard and API are same-origin, and the gateway origin serves no user-generated content); a Sec-Fetch-Site check on the cookie path adds defense in depth. same-site is deliberately rejected so a sibling-subdomain page cannot ride the cookie.
  • Master-key rotation revokes every dashboard session in the same transaction (a session only proves possession of the now-dead key) and re-mints the caller's cookie on the rotation response, so the tab performing the rotation stays signed in.

Frontend

  • The key is sent once to mint the session and never stored (previously it sat in sessionStorage). A non-secret localStorage marker (otari.dashboard.hasSession) keeps the signed-in state synchronous on load; if it is ever stale, the first 401 drops it and bounces to sign-in, the same path as any mid-session revocation.
  • Sign-out revokes the session server-side before clearing local state; the React Query cache is still cleared on login/logout as before.
  • replaceMasterKey plumbing is gone: the rotation response re-mints the cookie server-side, so the client no longer swaps credentials at all.

Verified end-to-end against a live gateway: cookie mint, cookie-only management reads, wrong-key/no-credential/cross-site rejections, server-side sign-out revocation, header auth unaffected, and a session minted before a full gateway restart still signs in (the exact friction in the issue).

PR Type

  • New Feature
  • Bug Fix
  • Refactor
  • Documentation
  • Infrastructure / CI

Relevant issues

Fixes #338

Checklist

  • I understand the code I am submitting.
  • I have added or updated tests that cover my change (tests/unit, tests/integration).
  • I ran the Definition of Done checks locally (make lint, make typecheck, make test).
  • Documentation was updated where necessary.
  • If the API contract changed, I regenerated the OpenAPI spec (uv run python scripts/generate_openapi.py).

Note on tests: lint, typecheck, the full unit suite (881 passed, including 8 new session tests), the web checks (typecheck, 199 tests, fresh committed bundle), and make openapi-check all ran locally. The integration suite needs Docker/testcontainers, which the sandbox does not have, so it is covered by CI on this PR.

AI Usage

  • No AI was used.
  • AI was used for drafting/refactoring.
  • This is fully AI-generated.

AI Model/Tool used:

Claude Code (Claude Fable 5)

Any additional AI details you'd like to share:

Implemented via the repo's fix-github-issue workflow with @njbrake choosing the approach (option B) and reviewing before push.

  • I am an AI Agent filling out this form (check box if true)

🤖 Generated with Claude Code

Summary

  • Added persistent dashboard sign-in using server-managed sessions stored in secure, HttpOnly cookies.
  • Added sign-in and sign-out endpoints, session storage, configurable expiration, and server-side revocation.
  • Sessions are revoked when the master key changes, including during startup and key rotation.
  • Updated the dashboard to use a non-secret local sign-in marker instead of storing the master key in browser storage.
  • Preserved existing API header authentication and added protections against cross-site cookie use.
  • Updated documentation, API definitions, generated dashboard assets, and backend/frontend tests.

Benefits

  • Sign-in persists across tabs and browser restarts.
  • The master key is no longer stored in JavaScript-readable browser storage.
  • Operators can sign out and sessions can be revoked centrally.

@njbrake
njbrake temporarily deployed to integration-tests July 23, 2026 12:58 — with GitHub Actions Inactive
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@njbrake, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 53 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b1deb459-11e0-40fc-bc46-00e47f4628e0

📥 Commits

Reviewing files that changed from the base of the PR and between fa03b02 and 7297756.

⛔ Files ignored due to path filters (1)
  • docs/public/openapi.json is excluded by !docs/public/openapi.json
📒 Files selected for processing (49)
  • AGENTS.md
  • alembic/versions/a9c1e3b5d7f9_add_dashboard_sessions_table.py
  • docs/public/otari.postman_collection.json
  • src/gateway/api/deps.py
  • src/gateway/api/main.py
  • src/gateway/api/routes/auth_session.py
  • src/gateway/api/routes/settings.py
  • src/gateway/core/config.py
  • src/gateway/main.py
  • src/gateway/models/entities.py
  • src/gateway/services/dashboard_session_service.py
  • src/gateway/static/dashboard/assets/ActivityPage-Ixi7_M5Z.js
  • src/gateway/static/dashboard/assets/AliasesPage-BZiGtRPE.js
  • src/gateway/static/dashboard/assets/BudgetsPage-D0M-Cd5s.js
  • src/gateway/static/dashboard/assets/KeysPage-CQj72SEP.js
  • src/gateway/static/dashboard/assets/ModelScopeControl-DX341Q9L.js
  • src/gateway/static/dashboard/assets/ModelsPage-BIdxI_hW.js
  • src/gateway/static/dashboard/assets/OverviewPage-CtvPFoWc.js
  • src/gateway/static/dashboard/assets/ProvidersPage-DimvEH7H.js
  • src/gateway/static/dashboard/assets/SettingsPage-iRP-TsYX.js
  • src/gateway/static/dashboard/assets/ToolsGuardrailsPage-B3QvV7KI.js
  • src/gateway/static/dashboard/assets/UsagePage-CsWlUWCg.js
  • src/gateway/static/dashboard/assets/UsersPage-ETfq5MXh.js
  • src/gateway/static/dashboard/assets/index-hDVMDLdX.js
  • src/gateway/static/dashboard/index.html
  • tests/unit/test_dashboard_session.py
  • tests/unit/test_deps_db_error.py
  • tests/unit/test_master_key_service.py
  • web/src/App.test.tsx
  • web/src/api/client.ts
  • web/src/auth/AuthContext.test.tsx
  • web/src/auth/AuthContext.tsx
  • web/src/components/Login.test.tsx
  • web/src/components/Login.tsx
  • web/src/components/PricingWarning.test.tsx
  • web/src/components/UpdatePrompt.test.tsx
  • web/src/components/UpdatePrompt.tsx
  • web/src/pages/ActivityPage.test.tsx
  • web/src/pages/AliasesPage.test.tsx
  • web/src/pages/BudgetsPage.test.tsx
  • web/src/pages/KeysPage.test.tsx
  • web/src/pages/ModelsPage.test.tsx
  • web/src/pages/OverviewPage.test.tsx
  • web/src/pages/ProvidersPage.test.tsx
  • web/src/pages/SettingsPage.test.tsx
  • web/src/pages/SettingsPage.tsx
  • web/src/pages/ToolsGuardrailsPage.test.tsx
  • web/src/pages/UsagePage.test.tsx
  • web/src/pages/UsersPage.test.tsx

Walkthrough

The dashboard now exchanges the master key for a server-side, TTL-limited HttpOnly session cookie. Backend authorization, rotation, persistence, startup invalidation, frontend state, tests, documentation, and generated assets were updated accordingly.

Changes

Dashboard session authentication

Layer / File(s) Summary
Session storage and lifecycle
alembic/versions/..., src/gateway/models/entities.py, src/gateway/services/dashboard_session_service.py, src/gateway/core/config.py
Adds the dashboard_sessions table and model, configurable session TTL, hashed session tokens, expiry validation, revocation, cookie helpers, and master-key change tracking.
API authentication and rotation
src/gateway/api/deps.py, src/gateway/api/routes/auth_session.py, src/gateway/api/routes/settings.py, src/gateway/main.py, src/gateway/api/main.py, tests/unit/test_dashboard_session.py, tests/unit/test_*master*key*.py, docs/public/otari.postman_collection.json
Adds sign-in/sign-out endpoints, guarded cookie authentication, header precedence, session revocation and reminting during key rotation, startup invalidation, router wiring, API examples, and backend coverage.
Web authentication client and generated dashboard
web/src/api/client.ts, web/src/auth/*, web/src/components/Login*, web/src/pages/SettingsPage*, web/src/pages/*test.tsx, src/gateway/static/dashboard/*, AGENTS.md
Removes browser-held master-key credentials, uses session exchange and a local session marker, adds cookie-based logout handling, updates tests and guidance, and regenerates dashboard bundles.

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

Possibly related PRs

Suggested reviewers: khaledosman

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title describes the main change, but it does not use the required Conventional Commit prefix format. Change it to a valid prefix like "fix: persist dashboard sign-in with an HttpOnly session cookie".
Docstring Coverage ⚠️ Warning Docstring coverage is 8.31% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed The PR description follows the template well and includes the required sections, checklist, and issue reference.
Linked Issues check ✅ Passed The changes satisfy #338 by moving dashboard auth to an HttpOnly session cookie with TTL, logout, rotation revocation, and preserved header auth.
Out of Scope Changes check ✅ Passed The bundle and asset updates appear to be generated as part of the dashboard auth change, with no clear unrelated scope creep.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/persist-dashboard-auth
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch fix/persist-dashboard-auth

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Copilot AI 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.

Pull request overview

Implements persistent admin-dashboard authentication by exchanging the master key once for a server-issued, HttpOnly session cookie, replacing the previous per-tab sessionStorage bearer-key approach. This improves operator UX (survives new tabs/restarts) while reducing browser-side exposure of the master key.

Changes:

  • Backend: adds DB-backed dashboard sessions (dashboard_sessions) plus /v1/auth/session (POST to mint, DELETE to revoke) and cookie-aware auth in api/deps.py.
  • Backend: revokes sessions on master-key change across restarts and re-mints the rotating caller’s session during generated master-key rotation.
  • Frontend: removes master-key storage/header auth, switches login/logout flows to session-cookie semantics, and updates tests/build artifacts accordingly.

Reviewed changes

Copilot reviewed 50 out of 52 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
web/src/pages/UsersPage.test.tsx Removes master-key header setup from page tests (cookie-auth model).
web/src/pages/UsagePage.test.tsx Removes master-key header setup from page tests (cookie-auth model).
web/src/pages/ToolsGuardrailsPage.test.tsx Removes master-key header setup from page tests (cookie-auth model).
web/src/pages/SettingsPage.tsx Removes client-side master-key swapping on rotation; relies on server re-mint.
web/src/pages/SettingsPage.test.tsx Updates tests to session marker + “no Authorization header” expectations.
web/src/pages/ProvidersPage.test.tsx Removes master-key header setup from page tests (cookie-auth model).
web/src/pages/OverviewPage.test.tsx Removes master-key header setup from page tests (cookie-auth model).
web/src/pages/ModelsPage.test.tsx Removes apiClient master-key setup from page tests (cookie-auth model).
web/src/pages/KeysPage.test.tsx Removes master-key header setup from page tests (cookie-auth model).
web/src/pages/BudgetsPage.test.tsx Removes master-key header setup from page tests (cookie-auth model).
web/src/pages/AliasesPage.test.tsx Removes master-key header setup from page tests (cookie-auth model).
web/src/pages/ActivityPage.test.tsx Removes master-key header setup from page tests (cookie-auth model).
web/src/components/UpdatePrompt.tsx Updates copy to reflect cookie-based sign-in persistence.
web/src/components/UpdatePrompt.test.tsx Removes master-key header setup from component tests (cookie-auth model).
web/src/components/PricingWarning.test.tsx Removes master-key header setup from component tests (cookie-auth model).
web/src/components/Login.tsx Switches login to POST /v1/auth/session, then marks authenticated state.
web/src/components/Login.test.tsx Verifies key exchange flow and that the raw key is not stored client-side.
web/src/auth/AuthContext.tsx Replaces stored master key with a non-secret localStorage session marker; logout revokes server session best-effort.
web/src/auth/AuthContext.test.tsx Adds coverage for restoring marker state and logout revocation behavior.
web/src/App.test.tsx Updates app auth setup from sessionStorage key to localStorage marker.
web/src/api/client.ts Removes master-key header injection; adds createSession and deleteSession helpers and keeps 401/403 bounce behavior.
tests/unit/test_master_key_service.py Updates unit tests to new is_valid_master_key symbol name.
tests/unit/test_deps_db_error.py Updates unit tests to new is_valid_master_key symbol name.
tests/unit/test_dashboard_session.py Adds unit tests covering mint, cookie auth, revocation, expiry, restart behavior, and rotation reminting.
src/gateway/static/dashboard/index.html Updates committed dashboard entrypoint to new asset hash.
src/gateway/static/dashboard/assets/UsersPage-DjQ_32XA.js Committed rebuilt dashboard bundle reflecting cookie-auth changes.
src/gateway/static/dashboard/assets/UsagePage-DU8IagMv.js Committed rebuilt dashboard bundle reflecting cookie-auth changes.
src/gateway/static/dashboard/assets/ToolsGuardrailsPage-qp13etCg.js Committed rebuilt dashboard bundle reflecting cookie-auth changes.
src/gateway/static/dashboard/assets/SettingsPage-C0JYm9ni.js Removes old committed settings bundle artifact.
src/gateway/static/dashboard/assets/SettingsPage-BzPdd2gR.js Adds new committed settings bundle artifact.
src/gateway/static/dashboard/assets/OverviewPage-DXIwdDWG.js Committed rebuilt dashboard bundle reflecting cookie-auth changes.
src/gateway/static/dashboard/assets/ModelScopeControl-Bpbo36Ko.js Committed rebuilt dashboard bundle reflecting rebuild/hash changes.
src/gateway/static/dashboard/assets/KeysPage-CbUCEimJ.js Committed rebuilt dashboard bundle reflecting rebuild/hash changes.
src/gateway/static/dashboard/assets/AliasesPage-AOThQmDL.js Committed rebuilt dashboard bundle reflecting rebuild/hash changes.
src/gateway/static/dashboard/assets/ActivityPage-Dm6r6wPy.js Committed rebuilt dashboard bundle reflecting rebuild/hash changes.
src/gateway/services/dashboard_session_service.py Adds session token mint/verify/revoke helpers, cookie helpers, and startup key-change revocation.
src/gateway/models/entities.py Adds DashboardSession ORM entity for DB-backed dashboard sessions.
src/gateway/main.py Hooks startup revocation on master-key change; excludes cookie-auth paths from OpenAPI security stamping.
src/gateway/core/config.py Adds dashboard_session_ttl_hours config field for session TTL.
src/gateway/api/routes/settings.py Updates master-key rotation to revoke all sessions and re-mint the caller’s session cookie when applicable.
src/gateway/api/routes/auth_session.py Adds /v1/auth/session POST/DELETE endpoints for cookie-based dashboard sessions.
src/gateway/api/main.py Registers the new auth-session router in standalone mode.
src/gateway/api/deps.py Adds cookie-session authentication path and makes verify_master_key cookie-aware.
docs/public/otari.postman_collection.json Adds Postman requests for session create/delete; updates rotation description.
docs/public/openapi.json Adds OpenAPI schemas/paths for /v1/auth/session and updates rotation docs.
alembic/versions/a9c1e3b5d7f9_add_dashboard_sessions_table.py Adds migration creating dashboard_sessions table + index.
AGENTS.md Updates repository docs to describe cookie-based dashboard sessions and new endpoint.
Files not reviewed (2)
  • src/gateway/static/dashboard/assets/SettingsPage-BzPdd2gR.js: Generated file
  • src/gateway/static/dashboard/assets/index-D1FfVwkg.js: Generated file

Comment thread src/gateway/api/routes/auth_session.py

@khaledosman khaledosman left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approved. Solid, well-tested implementation. Correcting two findings from my initial review below.

Correction (both withdrawn):

  • My "Secure dropped behind a TLS-terminating proxy" concern was wrong. request_is_https() (dashboard_session_service.py) deliberately honors X-Forwarded-Proto regardless of source, and create_session mints the cookie with secure=request_is_https(request), so the PaaS/proxy case is handled, not request.url.scheme == "https" as I stated.
  • The related "HTTPS Secure test gap" was also wrong: test_https_requests_get_a_secure_cookie and test_forwarded_https_gets_a_secure_cookie both cover it. The cross-PR #388 concern rested on these, so it's withdrawn too.

Remaining points (both minor, inline): the unauthenticated/unthrottled sign-in endpoint, and Path=/ extending the cookie to inference routes (the code owns both tradeoffs in docstrings).

Verified correct: token entropy/hashing (secrets.token_urlsafe(32) → SHA-256, PK lookup no meaningful timing oracle); the str | None return change on verify_master_key (only rotation binds it); rotation-via-cookie CAS preserves concurrent-rotation rejection; restart survival via revoke_sessions_on_master_key_change + re-mint. Lifecycle coverage (header-wins-over-cookie, cross-site rejection, server-side revocation, expiry, restart) is thorough.

@coderabbitai
coderabbitai Bot requested a review from khaledosman July 23, 2026 13:59

@coderabbitai coderabbitai 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.

Actionable comments posted: 3

🧹 Nitpick comments (3)
src/gateway/api/routes/auth_session.py (1)

33-37: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Bound master_key in the request schema.

master_key has no length limits, so an unbounded string can be POSTed to this pre-auth endpoint. As per coding guidelines, src/gateway/api/**/*.py requires validating request bodies with constrained Pydantic models, including bounds.

🔒 Suggested bound
-    master_key: str = Field(description="The gateway master key; verified once and never stored by the browser.")
+    master_key: str = Field(
+        min_length=1,
+        max_length=256,
+        description="The gateway master key; verified once and never stored by the browser.",
+    )
🤖 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 `@src/gateway/api/routes/auth_session.py` around lines 33 - 37, Update the
CreateSessionRequest Pydantic model to constrain master_key with an explicit
maximum length appropriate for the gateway master key, while preserving its
required string behavior and existing description.

Source: Coding guidelines

tests/unit/test_dashboard_session.py (1)

1-24: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Nice, thorough coverage — one gap: hybrid-mode cookie rejection isn't tested.

_session_cookie_authenticates in deps.py explicitly disables cookie auth when config.is_hybrid_mode is true, but no test here builds a hybrid-mode config and confirms a valid cookie still gets a 401. Since this is a real security boundary the PR introduces (dashboard cookie must never work in hybrid mode), it'd be worth a quick test alongside the others.

🤖 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 `@tests/unit/test_dashboard_session.py` around lines 1 - 24, Add a unit test in
tests/unit/test_dashboard_session.py that builds a GatewayConfig with
is_hybrid_mode enabled, establishes or supplies a valid SESSION_COOKIE_NAME
cookie, and verifies the protected dashboard request returns 401. Keep the test
focused on the _session_cookie_authenticates hybrid-mode rejection path and
preserve the existing non-hybrid cookie authentication coverage.
web/src/api/client.ts (1)

65-81: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

deleteSession bypassing apiFetch is well-justified, but it makes the guideline's exception clause stale.

The reasoning in the comment (avoid re-entering unauthorizedHandler during a logout-triggered revoke) is solid — routing this through apiFetch really would risk a recursive bounce. That said, worth a documentation touch-up since the exception clause currently only names the (now-renamed) validateMasterKey.

As per coding guidelines, "avoid direct fetch() for authenticated management requests except pre-auth validateMasterKey" — consider updating this clause to also cover createSession/deleteSession so future readers don't flag this as an unexplained deviation.

🤖 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/src/api/client.ts` around lines 65 - 81, Update the authenticated-request
guideline documentation to explicitly exempt createSession and deleteSession,
alongside validateMasterKey, from the direct fetch restriction. Keep the
existing rationale for deleteSession: it must bypass apiFetch to avoid
re-entering unauthorizedHandler during logout-triggered revocation.

Source: Coding guidelines

🤖 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 `@src/gateway/api/routes/auth_session.py`:
- Around line 57-65: Update the SQLAlchemyError handler around
create_dashboard_session to log the caught database exception with the module’s
existing logger and exc_info=True before rolling back and raising the generic
HTTPException. Preserve the client-facing “Database error” detail and from None
behavior, matching the logging approach used by delete_session.
- Line 66: Update the secure-cookie decision in the authentication session route
to use the trusted `request_is_https()` result only when forwarded protocol
headers are validated by trusted proxy evidence such as `X-Client-Tls: true` or
`X-Tls-Endpoint: https`; otherwise treat the request as non-HTTPS. Preserve
direct HTTPS detection and keep the existing session-cookie application flow.

In `@src/gateway/static/dashboard/assets/index-D1FfVwkg.js`:
- Line 2: The AuthProvider flow around Me, logout, and login must serialize
sign-out before sign-in: make logout retain authenticated state and return a
promise that resolves only after DELETE /v1/auth/session and local cleanup
complete, have the sign-in form await that promise before invoking login(), then
regenerate the dashboard bundle and add a regression test covering rapid
logout/login.

---

Nitpick comments:
In `@src/gateway/api/routes/auth_session.py`:
- Around line 33-37: Update the CreateSessionRequest Pydantic model to constrain
master_key with an explicit maximum length appropriate for the gateway master
key, while preserving its required string behavior and existing description.

In `@tests/unit/test_dashboard_session.py`:
- Around line 1-24: Add a unit test in tests/unit/test_dashboard_session.py that
builds a GatewayConfig with is_hybrid_mode enabled, establishes or supplies a
valid SESSION_COOKIE_NAME cookie, and verifies the protected dashboard request
returns 401. Keep the test focused on the _session_cookie_authenticates
hybrid-mode rejection path and preserve the existing non-hybrid cookie
authentication coverage.

In `@web/src/api/client.ts`:
- Around line 65-81: Update the authenticated-request guideline documentation to
explicitly exempt createSession and deleteSession, alongside validateMasterKey,
from the direct fetch restriction. Keep the existing rationale for
deleteSession: it must bypass apiFetch to avoid re-entering unauthorizedHandler
during logout-triggered revocation.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5b00e056-b38f-497f-a9ce-fbc6c0d3f6d2

📥 Commits

Reviewing files that changed from the base of the PR and between 7559257 and fa03b02.

⛔ Files ignored due to path filters (1)
  • docs/public/openapi.json is excluded by !docs/public/openapi.json
📒 Files selected for processing (51)
  • AGENTS.md
  • alembic/versions/a9c1e3b5d7f9_add_dashboard_sessions_table.py
  • docs/public/otari.postman_collection.json
  • src/gateway/api/deps.py
  • src/gateway/api/main.py
  • src/gateway/api/routes/auth_session.py
  • src/gateway/api/routes/settings.py
  • src/gateway/core/config.py
  • src/gateway/main.py
  • src/gateway/models/entities.py
  • src/gateway/services/dashboard_session_service.py
  • src/gateway/static/dashboard/assets/ActivityPage-Dm6r6wPy.js
  • src/gateway/static/dashboard/assets/AliasesPage-AOThQmDL.js
  • src/gateway/static/dashboard/assets/BudgetsPage-o3Sj5U5B.js
  • src/gateway/static/dashboard/assets/KeysPage-CbUCEimJ.js
  • src/gateway/static/dashboard/assets/ModelScopeControl-Bpbo36Ko.js
  • src/gateway/static/dashboard/assets/ModelsPage-WLlH9ed9.js
  • src/gateway/static/dashboard/assets/OverviewPage-DXIwdDWG.js
  • src/gateway/static/dashboard/assets/ProvidersPage-Bz-mLWy0.js
  • src/gateway/static/dashboard/assets/SettingsPage-BzPdd2gR.js
  • src/gateway/static/dashboard/assets/SettingsPage-C0JYm9ni.js
  • src/gateway/static/dashboard/assets/ToolsGuardrailsPage-qp13etCg.js
  • src/gateway/static/dashboard/assets/UsagePage-DU8IagMv.js
  • src/gateway/static/dashboard/assets/UsersPage-DjQ_32XA.js
  • src/gateway/static/dashboard/assets/index-D1FfVwkg.js
  • src/gateway/static/dashboard/assets/index-DkHDQ_jO.js
  • src/gateway/static/dashboard/index.html
  • tests/unit/test_dashboard_session.py
  • tests/unit/test_deps_db_error.py
  • tests/unit/test_master_key_service.py
  • web/src/App.test.tsx
  • web/src/api/client.ts
  • web/src/auth/AuthContext.test.tsx
  • web/src/auth/AuthContext.tsx
  • web/src/components/Login.test.tsx
  • web/src/components/Login.tsx
  • web/src/components/PricingWarning.test.tsx
  • web/src/components/UpdatePrompt.test.tsx
  • web/src/components/UpdatePrompt.tsx
  • web/src/pages/ActivityPage.test.tsx
  • web/src/pages/AliasesPage.test.tsx
  • web/src/pages/BudgetsPage.test.tsx
  • web/src/pages/KeysPage.test.tsx
  • web/src/pages/ModelsPage.test.tsx
  • web/src/pages/OverviewPage.test.tsx
  • web/src/pages/ProvidersPage.test.tsx
  • web/src/pages/SettingsPage.test.tsx
  • web/src/pages/SettingsPage.tsx
  • web/src/pages/ToolsGuardrailsPage.test.tsx
  • web/src/pages/UsagePage.test.tsx
  • web/src/pages/UsersPage.test.tsx
💤 Files with no reviewable changes (4)
  • web/src/pages/ModelsPage.test.tsx
  • src/gateway/static/dashboard/assets/SettingsPage-C0JYm9ni.js
  • web/src/components/UpdatePrompt.test.tsx
  • src/gateway/static/dashboard/assets/index-DkHDQ_jO.js

Comment thread src/gateway/api/routes/auth_session.py
Comment thread src/gateway/api/routes/auth_session.py Outdated
Comment thread src/gateway/static/dashboard/assets/index-D1FfVwkg.js Outdated

@khaledosman khaledosman left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Two inline notes below (both minor). See the updated review summary for the correction to my earlier findings.

Comment thread src/gateway/api/routes/auth_session.py
Comment thread src/gateway/services/dashboard_session_service.py
@njbrake
njbrake force-pushed the fix/persist-dashboard-auth branch 2 times, most recently from 2d75fac to f0cb4c4 Compare July 23, 2026 16:40
@njbrake
njbrake temporarily deployed to integration-tests July 23, 2026 16:40 — with GitHub Actions Inactive
njbrake and others added 6 commits July 23, 2026 16:43
The dashboard forced operators to re-paste the master key on every new
tab, closed tab, or browser restart, because the raw key lived in
per-tab sessionStorage. Signing in now exchanges the key once
(POST /v1/auth/session) for a server-issued opaque session held in an
HttpOnly cookie, which is both more convenient (survives tabs and
restarts) and more secure (no JS-readable credential, TTL, server-side
revocation).

Backend:
- dashboard_sessions table stores only SHA-256 token hashes, so every
  worker and replica accepts a session and revocation is global.
- POST /v1/auth/session verifies the master key and sets the cookie
  (HttpOnly; SameSite=Strict; Secure mirrors the request scheme so
  plain-HTTP LAN deployments keep working); DELETE signs out.
- The master-key auth dependencies accept the cookie only when a
  request carries no header credentials, so API clients are untouched.
  A Sec-Fetch-Site check backs up SameSite as CSRF defense in depth.
- Master-key rotation revokes every session and re-mints the caller's
  cookie on the response, so the rotating tab stays signed in.
- Session TTL is configurable via dashboard_session_ttl_hours
  (default 7 days).

Frontend:
- The raw key is sent once to mint the session and never stored; a
  non-secret localStorage marker keeps the signed-in state synchronous,
  and any 401 drops it exactly like a mid-session revocation.
- Sign-out revokes the session server-side before clearing local state.

Fixes #338

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two follow-ups from self-review of the session-cookie change:

- A dashboard session only proves possession of the master key at mint
  time, so it must not outlive the key. The generated key already
  revoked sessions inline at rotation, but a configured key rotates by
  changing OTARI_MASTER_KEY and restarting, which no request handler
  observes; pre-rotation sessions survived until their TTL. Store a
  hash of the effective key in runtime_settings and revoke every
  session at startup when it changes (covering configured-key rotation
  and configured/generated regime switches). The rotation endpoint
  keeps the marker in step so its re-minted session survives restarts.

- Regenerate the Postman collection from the updated OpenAPI spec
  (make postman), which the openapi-spec CI job checks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Raising a 500 on a DB failure during DELETE /v1/auth/session made
FastAPI discard the injected response, so the cookie was never cleared
while the frontend had already dropped its local marker: the browser
kept a live session cookie the operator believed was gone. Sign-out now
always clears the cookie and returns 204, logging the failed revocation;
the unrevoked row dies on its TTL.

Flagged by Copilot review on #384.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… flag

Behind a TLS-terminating proxy, uvicorn only trusts X-Forwarded-Proto
from loopback by default, so request.url.scheme reads "http" on typical
PaaS ingress and the session cookie was minted without Secure despite an
HTTPS browser leg. Decide the Secure attribute from the effective scheme
(direct https, or the first X-Forwarded-Proto value). Honoring the
header regardless of source is safe for this decision alone: a spoofed
"https" over plain HTTP only denies the spoofer their own cookie.

Also covers the Secure=true branch in tests (direct HTTPS and forwarded
proto), and documents two review-noted asymmetries: DELETE skipping the
Sec-Fetch-Site check (SameSite already covers it; worst case is a forced
sign-out) and Path=/ (management routes live beside inference under /v1,
and the cookie grants nothing beyond master authority).

Addresses khaledosman's review on #384.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The create_session 500 branch discarded the SQLAlchemyError without a
trace, unlike the parallel branch in delete_session; a sign-in outage
would have been invisible in the logs. Flagged by CodeRabbit on #384.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rebuild the committed dashboard bundle, OpenAPI spec, and Postman
collection from the tree rebased onto main, folding in main's dashboard
and API changes (#383, #385, #388).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@njbrake
njbrake force-pushed the fix/persist-dashboard-auth branch from f0cb4c4 to 7297756 Compare July 23, 2026 16:44
@njbrake
njbrake temporarily deployed to integration-tests July 23, 2026 16:44 — with GitHub Actions Inactive
@njbrake
njbrake merged commit f462b03 into main Jul 23, 2026
11 checks passed
@njbrake
njbrake deleted the fix/persist-dashboard-auth branch July 23, 2026 16:48
@njbrake njbrake mentioned this pull request Jul 24, 2026
4 tasks
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.

Dashboard: persist auth so operators don't re-paste the master key on every open

3 participants