Skip to content

fix(dashboard-auth): drop /api/* paths from OAuth next= round trip#36244

Merged
benbarclay merged 1 commit into
mainfrom
oauth-redirect
Jun 1, 2026
Merged

fix(dashboard-auth): drop /api/* paths from OAuth next= round trip#36244
benbarclay merged 1 commit into
mainfrom
oauth-redirect

Conversation

@benbarclay

Copy link
Copy Markdown
Collaborator

Symptom

Reported by Ben: open the dashboard with an expired/missing session while on /models (or any analytics-style page), get bounced through Nous Portal's OAuth screen, and land on $DOMAIN/api/analytics/models?days=30 — raw JSON in the address bar instead of the dashboard.

Reproduces deterministically: whichever /api/* call a page fires first on mount owns the redirect race, so the bad landing URL varies by page but the bug fires every time.

Root cause

Single bug at two boundaries:

  1. Gate side (_safe_next_target in hermes_cli/dashboard_auth/middleware.py): the gated middleware computes next= from the request's own path. When an unauthenticated SPA fetch hits a gated endpoint, the path going into next= is the API URL the SPA was calling.

  2. Callback side (_validate_post_login_target in hermes_cli/dashboard_auth/routes.py): only filters out /login, /auth/, and /api/auth/. Any other /api/* value is accepted as same-origin.

End-to-end flow:

ModelsPage.tsx mounts
  → api.getModelsAnalytics(30)  →  GET /api/analytics/models?days=30
  → 401 { login_url: "/login?next=%2Fapi%2Fanalytics%2Fmodels%3Fdays%3D30" }
  → web/src/lib/api.ts: window.location.assign(login_url)
  → /login → /auth/login (stores next in PKCE cookie)
  → Portal OAuth dance
  → /auth/callback (reads next= from cookie, validates as "same-origin", redirects)
  → /api/analytics/models?days=30  →  raw JSON in address bar

The SPA's own sessionStorage["hermes.lastLocation"] fallback in api.ts already saves the actual browser location, so dropping the server-stamped next= on /api/* requests doesn't lose any UX — the SPA recovers the deep link itself.

Fix

Both validators now reject /api/* targets in addition to the existing /login, /auth/, /api/auth/ exclusions. The match is anchored (decoded == "/api" or decoded.startswith("/api/")) so SPA route lookalikes like /apidocs or /api-keys remain valid landing targets.

Why both validators get the same fix: either layer alone is sufficient; pairing them means a regression in one is caught by the other. The callback-side validator is the load-bearing one (it's the last line of defence against a legacy cookie / regressed middleware / attacker-crafted /auth/login?next=/api/...), and the gate-side fix means the SPA's 401 handler navigates to a bare /login (clean, no useless redirect parameter) in the common case.

Tests

Added to tests/hermes_cli/test_dashboard_auth_401_reauth.py:

  • TestApi401Envelope: rewrote test_login_url_carries_next_for_deep_api_path (which had locked in the pre-fix behaviour) into test_login_url_drops_next_for_deep_api_path, plus added test_login_url_drops_next_for_analytics_path as the specific repro for Ben's report.
  • TestNextSameOriginValidation: test_safe_next_validator_rejects_api_paths (covers /api, /api/analytics/models, /api/sessions, /api/config, /api/status) + test_safe_next_validator_does_not_reject_api_prefix_lookalikes (covers /apidocs, /api-keys).
  • TestAuthCallbackNext: end-to-end test_callback_with_api_next_lands_at_root drives /auth/login?next=/api/analytics/models?days=30 through to the callback and asserts the user lands at /, not the API URL.
  • TestValidatePostLoginTarget: new class covering the callback-side validator directly, including the URL-encoded %2Fapi%2F... form the PKCE cookie actually carries.

Verification

  • Targeted: bash scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_401_reauth.py → 36/36 pass.
  • Wider: bash scripts/run_tests.sh tests/hermes_cli/ → 288 files, 5,938 tests, 100% pass.
  • Mutation-tested: reverted both validators in place, ran the file, saw exactly the 5 new/rewritten /api/* assertions fail and the 31 other tests remain green. Each fix layer is independently tested.

Files

File Change
hermes_cli/dashboard_auth/middleware.py _safe_next_target drops /api/*
hermes_cli/dashboard_auth/routes.py _validate_post_login_target drops /api/*
tests/hermes_cli/test_dashboard_auth_401_reauth.py +150 lines of coverage

No SPA changes — sessionStorage["hermes.lastLocation"] was already saving the right value; this PR just stops the server from emitting a useless (and harmful) override.

When an unauthenticated SPA fetch hit a gated /api/* endpoint (e.g.
GET /api/analytics/models?days=30 fired from ModelsPage on mount or
after a session expiry), the gated middleware stamped the request's
own path into next= on the 401 envelope's login_url. The SPA's global
401 handler in web/src/lib/api.ts full-page-navigated to that URL,
the PKCE cookie carried the encoded /api/* value through the OAuth
round trip to Portal, and /auth/callback's _validate_post_login_target
accepted it as same-origin and redirected the user to the raw JSON
endpoint instead of the dashboard.

Symptom Ben reported: after the OAuth screen he kept landing on
$DOMAIN/api/analytics/models?days=30 (raw JSON) rather than /models.
The bug was deterministic per page — whichever /api/* call ModelsPage,
AnalyticsPage, or SessionsPage fired first owned the redirect race.

Fix: both validators now reject /api/* targets in addition to the
existing /login, /auth/, /api/auth/ exclusions:

  - _safe_next_target in middleware.py drops the value before it ever
    enters login_url, so the SPA's 401 handler navigates to a bare
    /login (which the SPA itself can return-from via its own
    sessionStorage["hermes.lastLocation"] fallback that was already
    saving the actual browser location).
  - _validate_post_login_target in routes.py drops it as second-line
    defence at the callback boundary, so a legacy cookie, a regressed
    middleware, or an attacker-crafted /auth/login?next=/api/... value
    can't smuggle the redirect through. Either layer alone is enough;
    pairing them means a regression in one is caught by the other.

The match is anchored: ``decoded == "/api"`` or
``decoded.startswith("/api/")``. SPA route lookalikes like /apidocs
or /api-keys remain valid landing targets — tests pin that.

Test additions in test_dashboard_auth_401_reauth.py:

  - TestApi401Envelope: rewrote test_login_url_carries_next_for_deep_
    api_path (which asserted the pre-fix behaviour) as
    test_login_url_drops_next_for_deep_api_path, plus added the
    specific analytics-models repro case from Ben's report.
  - TestNextSameOriginValidation: rejects-api-paths + does-not-reject-
    api-prefix-lookalikes (covers /apidocs, /api-keys).
  - TestAuthCallbackNext: end-to-end test_callback_with_api_next_
    lands_at_root drives /auth/login?next=/api/... through to the
    callback and asserts the user lands at "/", not the API URL.
  - TestValidatePostLoginTarget: new class covering the callback-side
    validator directly, including the URL-encoded ``%2Fapi%2F...``
    form the PKCE cookie actually carries.

Mutation-tested: reverting both validators causes exactly the 5 new
or rewritten /api/*-related assertions to fail (each fix layer is
independently tested), while the 31 other assertions in the file
remain green. Full tests/hermes_cli/ suite (288 files, 5,938 tests)
passes with the fix applied.
@github-actions

github-actions Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

🔎 Lint report: oauth-redirect vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 9572 on HEAD, 9572 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 4961 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/cli CLI entry point, hermes_cli/, setup wizard area/auth Authentication, OAuth, credential pools labels Jun 1, 2026
@benbarclay benbarclay merged commit e1eba6f into main Jun 1, 2026
19 of 23 checks passed
@benbarclay benbarclay deleted the oauth-redirect branch June 1, 2026 05:10
JoeKowal pushed a commit to JoeKowal/hermes-agent that referenced this pull request Jun 4, 2026
…ousResearch#36244)

When an unauthenticated SPA fetch hit a gated /api/* endpoint (e.g.
GET /api/analytics/models?days=30 fired from ModelsPage on mount or
after a session expiry), the gated middleware stamped the request's
own path into next= on the 401 envelope's login_url. The SPA's global
401 handler in web/src/lib/api.ts full-page-navigated to that URL,
the PKCE cookie carried the encoded /api/* value through the OAuth
round trip to Portal, and /auth/callback's _validate_post_login_target
accepted it as same-origin and redirected the user to the raw JSON
endpoint instead of the dashboard.

Symptom Ben reported: after the OAuth screen he kept landing on
$DOMAIN/api/analytics/models?days=30 (raw JSON) rather than /models.
The bug was deterministic per page — whichever /api/* call ModelsPage,
AnalyticsPage, or SessionsPage fired first owned the redirect race.

Fix: both validators now reject /api/* targets in addition to the
existing /login, /auth/, /api/auth/ exclusions:

  - _safe_next_target in middleware.py drops the value before it ever
    enters login_url, so the SPA's 401 handler navigates to a bare
    /login (which the SPA itself can return-from via its own
    sessionStorage["hermes.lastLocation"] fallback that was already
    saving the actual browser location).
  - _validate_post_login_target in routes.py drops it as second-line
    defence at the callback boundary, so a legacy cookie, a regressed
    middleware, or an attacker-crafted /auth/login?next=/api/... value
    can't smuggle the redirect through. Either layer alone is enough;
    pairing them means a regression in one is caught by the other.

The match is anchored: ``decoded == "/api"`` or
``decoded.startswith("/api/")``. SPA route lookalikes like /apidocs
or /api-keys remain valid landing targets — tests pin that.

Test additions in test_dashboard_auth_401_reauth.py:

  - TestApi401Envelope: rewrote test_login_url_carries_next_for_deep_
    api_path (which asserted the pre-fix behaviour) as
    test_login_url_drops_next_for_deep_api_path, plus added the
    specific analytics-models repro case from Ben's report.
  - TestNextSameOriginValidation: rejects-api-paths + does-not-reject-
    api-prefix-lookalikes (covers /apidocs, /api-keys).
  - TestAuthCallbackNext: end-to-end test_callback_with_api_next_
    lands_at_root drives /auth/login?next=/api/... through to the
    callback and asserts the user lands at "/", not the API URL.
  - TestValidatePostLoginTarget: new class covering the callback-side
    validator directly, including the URL-encoded ``%2Fapi%2F...``
    form the PKCE cookie actually carries.

Mutation-tested: reverting both validators causes exactly the 5 new
or rewritten /api/*-related assertions to fail (each fix layer is
independently tested), while the 31 other assertions in the file
remain green. Full tests/hermes_cli/ suite (288 files, 5,938 tests)
passes with the fix applied.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/auth Authentication, OAuth, credential pools comp/cli CLI entry point, hermes_cli/, setup wizard P2 Medium — degraded but workaround exists type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants