Dashboard gated/basic-auth session token and OAuth fixes

NousResearch/hermes-agent · cluster 34755 · dashboard

Data as of

Nothing open on GitHub. Fix merged in #34793. Check it covers your case.

The short answer

0open issues
0open PRs
18items grouped
Known?
Yes. 18 related items grouped here, 7 issues already closed.
Fix event
A fix was merged to main in PR #34793. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #34793. That is separate from the fix event.
Still open
Nothing open on GitHub.

A cluster of bugs and fixes around the dashboard failing to authenticate requests in gated or basic-auth mode, covering missing session token injection, _require_token endpoints returning 401 for cookie-authenticated users, and OAuth provider login breakage across chat, plugins, and key-reveal flows.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #34793 fix(dashboard): chat tab works in gated (OAuth) mode

    merged to main

    files: web/src/pages/ChatPage.tsx

    Show the diff
    • web/src/pages/ChatPage.tsx
      @@ -119,8 +119,13 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
         const [searchParams, setSearchParams] = useSearchParams();
         // Lazy-init: the missing-token check happens at construction so the effect
         // body doesn't have to setState (React 19's set-state-in-effect rule).
      +  // In gated (OAuth) mode the server intentionally omits the session token —
      +  // the SPA authenticates the WS via a single-use ticket (buildWsAuthParam),
      +  // so a missing token there is expected, not an error.
         const [banner, setBanner] = useState<string | null>(() =>
      -    typeof window !== "undefined" && !window.__HERMES_SESSION_TOKEN__
      +    typeof window !== "undefined" &&
      +    !window.__HERMES_SESSION_TOKEN__ &&
      +    !window.__HERMES_AUTH_REQUIRED__
             ? "Session token unavailable. Open this page through `hermes dashboard`, not directly."
             : null,
         );
      @@ -273,8 +278,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
           if (!host) return;
       
           const token = [redacted:secret-assignment]
      +    const gated = !!window.__HERMES_AUTH_REQUIRED__;
           // Banner already initialised above; just bail before wiring xterm/WS.
      -    if (!token) {
      +    // In gated mode the token is absent by design — buildWsAuthParam() mints
      +    // a WS ticket instead, so don't bail; let the effect reach that path.
      +    if (!token && !gated) {
             return;
           }
       
      @@ -876,5 +884,6 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
       declare global {
         interface Window {
           __HERMES_SESSION_TOKEN__?: string;
      +    __HERMES_AUTH_REQUIRED__?: boolean;
         }
       }
  • #42578 fix(dashboard): _require_token endpoints all 401 behind the OAuth gate

    merged to main

    files: hermes_cli/web_server.py, tests/hermes_cli/test_dashboard_auth_middleware.py

    Show the diff
    • hermes_cli/web_server.py
      @@ -246,7 +246,31 @@ def _has_valid_session_token(request: Request) -> bool:
       
       
       def _require_token(request: Request) -> None:
      -    """Validate the ephemeral session token.  Raises 401 on mismatch."""
      +    """Authorize a sensitive endpoint, raising 401 if the caller isn't allowed.
      +
      +    Two auth schemes protect the dashboard, exactly one active per bind:
      +
      +    * **Loopback / ``--insecure`` mode** (``auth_required`` False): the
      +      ephemeral ``_SESSION_TOKEN`` is injected into the SPA HTML and echoed
      +      back via ``X-Hermes-Session-Token`` (or the legacy ``Bearer`` header).
      +      Validate it here.
      +    * **Gated / OAuth mode** (``auth_required`` True): ``_SESSION_TOKEN`` is
      +      NOT injected (the SPA authenticates with a session cookie), so there is
      +      no token to check. The ``gated_auth_middleware`` has already verified the
      +      cookie before the request reached this handler — any non-public ``/api/``
      +      route it lets through carries a verified ``request.state.session``. The
      +      legacy ``auth_middleware`` likewise short-circuits in this mode. Requiring
      +      the (absent) token here would 401 every cookie-authenticated request,
      +      making plugin install/enable/disable and the other ``_require_token``
      +      endpoints permanently unreachable behind the gate. Defer to the gate.
      +    """
      +    if getattr(request.app.state, "auth_required", False):
      +        # Gate is authoritative. It attaches ``request.state.session`` on
      +        # success and 401s otherwise, so a request that reached us is already
      +        # authenticated. Belt-and-braces: confirm the session is present.
      +        if getattr(request.state, "session", None) is not None:
      +            return
      +        raise HTTPException(status_code=401, detail="Unauthorized")
           if not _has_valid_session_token(request):
               raise HTTPException(status_code=401, detail="Unauthorized")
       
    • tests/hermes_cli/test_dashboard_auth_middleware.py
      @@ -191,6 +191,111 @@ def test_full_login_round_trip_unlocks_gated_api(gated_app):
           )
       
       
      +def _complete_stub_login(client) -> None:
      +    """Walk the stub OAuth round trip so ``client`` carries a valid session.
      +
      +    TestClient persists Set-Cookie across calls, so after this returns the
      +    client's cookie jar holds ``hermes_session_at`` / ``hermes_session_rt``
      +    and subsequent gated requests authenticate.
      +    """
      +    r1 = client.get("/auth/login?provider=stub", follow_redirects=False)
      +    assert r1.status_code == 302
      +    state = r1.headers["location"].split("state=")[1]
      +    r2 = client.get(
      +        f"/auth/callback?code=stub_code&state={state}",
      +        follow_redirects=False,
      +    )
      +    assert r2.status_code == 302
      +
      +
      +def test_gated_require_token_endpoint_accepts_cookie_session(gated_app):
      +    """Regression: ``_require_token`` endpoints must work under the OAuth gate.
      +
      +    In gated mode the legacy ``_SESSION_TOKEN`` is NOT injected into the SPA
      +    (it authenticates with the session cookie). Endpoints that call
      +    ``_require_token`` directly — plugin install/enable/disable,
      +    ``/api/dashboard/plugins/hub``, and others — used to re-check the absent
      +    token and 401 every cookie-authenticated request, making them permanently
      +    unreachable behind the gate (the dashboard surfaced a
      +    ``401: {"detail":"Unauthorized"}`` popup on plugin install). The fix makes
      +    ``_require_token`` defer to the gate, which has already verified the cookie
      +    and attached ``request.state.session`` before the handler runs.
      +
      +    We POST a deliberately invalid plugin identifier: a passing auth layer
      +    lets the request reach the handler, which rejects the identifier with a
      +    400. The assertion is simply "not 401" — proving auth succeeded without
      +    coupling to the validation message.
      +    """
      +    _complete_stub_login(gated_app)
      +    r = gated_app.post(
      +        "/api/dashboard/agent-plugins/install",
      +        json={"identifier": "definitely not a valid identifier",
      +              "force": False, "enable": False},
      +    )
      +    assert r.status_code != 401, (
      +        "A _require_token endpoint 401'd a cookie-authenticated request under "
      +        f"the OAuth gate (the install-popup bug). Body: {r.text}"
      +    )
      +    # And specifically: it reached the handler's own validation.
      +    assert r.status_code == 400, (
      +        f"Expected the install handler's 400 (bad identifier), got "
      +        f"{r.status_code}: {r.text}"
      +    )
      +
      +
      +def test_gated_require_token_endpoint_still_rejects_no_cookie(gated_app):
      +    """The gate must still 401 a ``_require_token`` endpoint with no session.
      +
      +    The fix defers to the gate — it does not make these endpoints public. A
      +    request with no cookie is rejected by ``gated_auth_middleware`` before the
      +    handler runs, so the install endpoint stays protected.
      +    """
      +    r = gated_app.post(
      +        "/api/dashboard/agent-plugins/install",
      +        json={"identifier": "owner/repo", "force": False, "enable": False},
      +    )
      +    assert r.status_code == 401, (
      +        f"Expected 401 for an unauthenticated install POST under the gate, "
      +        f"got {r.status_code}: {r.text}"
      +    )
      +
      +
      +# A representative spread of the OTHER ``_require_token`` endpoints (there are
      +# 14 in total). The install popup was just the reported symptom; the same bug
      +# made API-key reveal, provider validation, the OAuth-provider connect flow,
      +# and the rest of plugin management unreachable behind the gate. Each entry is
      +# (method, path, json_body); we assert only that a logged-in request is NOT
      +# 401'd — i.e. it cleared the auth layer and reached the handler. The
      +# handler's own status (400/404/429/etc.) is route-specific and not asserted.
      +_GATED_REQUIRE_TOKEN_ROUTES = [
      +    ("get", "/api/dashboard/plugins/hub", None),
      +    ("post", "/api/env/reveal", {"key": "NONEXISTENT_ENV_VAR_FOR_TEST"}),
      +    ("post", "/api/providers/validate", {"key": "OPENAI_API_KEY", "value": ""}),
      +    ("delete", "/api/providers/oauth/__not_a_real_provider__", None),
      +    ("post", "/api/dashboard/agent-plugins/__nope__/enable", None),
      +]
      +
      +
      [redacted:email]("method,path,body", _GATED_REQUIRE_TOKEN_ROUTES)
      +def test_gated_require_token_routes_accept_cookie_session(
      +    gated_app, method, path, body
      +):
      +    """Every ``_require_token`` route must clear auth for a logged-in caller.
      +
      +    Same root cause and fix as
      +    ``test_gated_require_token_endpoint_accepts_cookie_session`` — this just
      +    proves the fix covers the whole class, not only ``agent-plugins/install``.
      +    """
      +    _complete_stub_login(gated_app)
      +    kwargs = {"json": body} if body is not None else {}
      +    r = gated_app.request(method.upper(), path, **kwargs)
      +    assert r.status_code != 401, (
      +        f"{method.upper()} {path} 401'd a cookie-authenticated request under "
      +        f"the OAuth gate — _require_token still rejecting a valid session. "
      +        f"Body: {r.text}"
      +    )
      +
      +
       def test_login_unknown_provider_returns_404(gated_app):
           r = gated_app.get("/auth/login?provider=nonexistent", follow_redirects=False)
           assert r.status_code == 404

9 PRs closed without merging

From the close comments: done elsewhere 6 · superseded by another PR 1 · other 2

  • #35500 fix(dashboard): skip legacy token check in gated mode for _require_token endpoints · done elsewhere → 63a421d, #42578
  • #35589 fix(dashboard): honor OAuth sessions for plugin endpoints · done elsewhere → 63a421d, #35500, #42578
  • #39319 fix(dashboard): honor gated OAuth sessions in token guard · done elsewhere → #42578
  • #40547 fix(dashboard): skip session token check when using basic auth · other → 1596bb2, #40485
  • #42148 fix(dashboard): bypass _require_token in gated mode to trust cookie auth · done elsewhere → 63a421d, #42578, #42139, #55210
Show 4 more
  • #55220 fix(dashboard): use fetchJSON auth in revealEnvVar for gated mode · superseded by another PR → #51156
  • #55221 fix(dashboard): inject placeholder session token in gated mode so reveal works · done elsewhere → 3e24b16, #51156, #55220
  • #58211 fix(dashboard): allow provider oauth with cookie auth · other → 3e24b16
  • #59663 fix(dashboard): inject session token in gated index.html so model-provider OAuth works under basic_auth (#59274) · done elsewhere → 3e24b16, #59274
Closed issues · 7
  • Closed#34755[Bug]: Chat tab unusable in gated (OAuth) mode — "Session token unavailable" false positive
  • Closed#35492[Bug]: Gated mode - Dashbaord not loading available plugins
  • Closed#40485fix(dashboard): 'Session token unavailable' error on chat page when using basic auth
  • Closed#42139Gated dashboard mode: endpoints calling _require_token() 401 cookie-authenticated clients (breaks custom-endpoint validation)
  • Closed#55210Bug: Dashboard Keys page reveal button throws in gated (basic_auth) mode — frontend never sends the request
  • Closed#58207[Bug]: Dashboard OAuth provider login fails in gated mode because startOAuthLogin requires legacy session token
  • Closed#59274Dashboard model-provider OAuth fails under basic_auth: `__HERMES_SESSION_TOKEN__` never injected ("Session token not available")
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.12: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
dashboard
Merged PRs
#34793, #42578
Closed issues
7
Attempts
9 PRs closed without merging
Fix link checked
10 Jun 2026

For your agent

The same facts, typed.