Commit Graph

8 Commits

Author SHA1 Message Date
Teknium f0f7f55f76 refactor(web): dashboard_auth/web_routers/kanban dedupe + _common helpers (resume — verified partial work) 2026-09-02 13:30:05 -07:00
Teknium 2f44998353 fix(dashboard-auth): a non-JWT bearer is "not my token", not "provider unreachable" (#94558)
NousDashboardAuthProvider._verify_jwt (and the identical hunk in the
self-hosted OIDC provider) folded EVERY PyJWKClient failure into
ProviderError, which the gate translates to HTTP 503
{"detail":"Auth provider 'nous' unreachable"}. That branch fires for
jwt.DecodeError('Not enough segments') — i.e. the bearer is not a JWT at all
(an opaque peer key, a legacy token, garbage) — and for PyJWKSetError (JWKS
fetched fine, foreign kid). Neither involves reaching Portal, which is why
the hosted sjc agents in #94558 returned a fast, well-formed 503 that
survived token re-mint and instance restart while Portal was healthy.

Add one shared classifier, hermes_cli.dashboard_auth.classify_jwks_lookup_error:
only PyJWKClientConnectionError (transport) and an unexpected bare
PyJWKClientError stay ProviderError; DecodeError / PyJWKSetError /
InvalidTokenError become InvalidCodeError so verify_session() returns None
and the middleware proceeds to the next provider / refresh / 401 exactly as
the protocol documents. Both providers now use it.

Live repro (real NousDashboardAuthProvider against a local reachable JWKS
server; and the real gated web_server app): before — opaque bearer ->
ProviderError "JWKS lookup failed: DecodeError('Not enough segments')" ->
503 unreachable; after — verify_session() -> None, gated GET /api/auth/me
with the opaque bearer -> 401; a real JWT against an unreachable JWKS still
-> ProviderError (503).

This does not add /api/v1/message to the public-path allowlist (#94579):
that route has no verifier in this repo, so bypassing the gate would leave a
state-changing ingress fail-open. The correct fix is classification, which
also covers every other opaque-bearer surface.

Refs #94558
2026-09-02 01:15:58 -07:00
Ben Barclay 91191123ed docs(auth): correct the SameSite contract in the cookie source docs
The SameSite=None change updated the website docs but left two
source-level contracts asserting the opposite:

  - base.py: LoginStart.cookie_payload said cookies set there "MUST"
    be SameSite=Lax.
  - cookies.py: the module docstring said all three cookies are
    SameSite=Lax.

Both now describe the actual behaviour: session cookies stay Lax, the
short-lived PKCE cookie is SameSite=None; Secure over HTTPS and Lax
over plain HTTP. A provider author following the old base.py contract
would have had a documented reason to undo the fix.

Also records the forwarded_allow_ips caveat in cookies.py: uvicorn only
honours X-Forwarded-Proto from a peer inside forwarded_allow_ips
(default 127.0.0.1), so a TLS terminator reaching the dashboard from a
non-loopback address (a reverse proxy in its own container) leaves the
request looking like HTTP and the cookies written in their HTTP shape.

Docstrings only; no behaviour change.
2026-08-24 16:41:13 +10:00
mark 96a0708448 fix(auth): preserve provider fallback during refresh 2026-07-14 07:02:05 -07:00
Nacho Avecilla dbe734beff fix(dashboard-auth): exclude non-interactive providers from interactive login surfaces (#53239)
* Return None instead of erroring on drain login failure

* Fix login on drain

* Remove login for drained endpoints flow and clean the code

* chore: drop unrelated credits changes from this PR

* Remove extra comments that were not really necessary
2026-06-27 10:08:13 +10:00
Ben cb9cb6ba1c feat(dashboard-auth): generic non-interactive API-token capability
Task 2.0a of the safe-shutdown drain-coordination plan. Widens the dashboard
auth framework GENERICALLY to support non-interactive (service-to-service)
bearer-token auth, mirroring the existing supports_password precedent. This is
a reusable capability — any future machine-credential provider plugs in without
core changes (decisions.md Q-C). The drain bearer-secret plugin (Task 2.0b) is
the first consumer, not the definition.

- base.py: add TokenPrincipal dataclass (the token analog of Session) +
  supports_token capability flag + verify_token() on the ABC (default raises
  NotImplementedError so a misconfigured provider fails loud). Contract mirrors
  verify_session stacking: return None for unrecognised tokens (never raise),
  raise ProviderError only on a genuine backing-store outage.
- registry.py: list_token_providers() — the supports_token subset, in
  registration order. Empty when none registered (token routes fail closed).
- token_auth.py (new): route-agnostic seam. Routes opt in via
  register_token_route(exact path); token_auth_middleware owns the auth
  decision for those routes only — authenticate via stacked providers, attach
  request.state.token_principal + token_authenticated, pass through. 401 on
  missing/unrecognised token, 503 when a provider was unreachable, untouched
  passthrough for non-token routes. Fails closed (never open).
- web_server.py: install the seam OUTERMOST (registered last → runs first).
  Both downstream gates (legacy auth_middleware + gated_auth_middleware) honour
  request.state.token_authenticated and skip enforcement, so a token-authed
  service request is never bounced to /login.
- audit.py: TOKEN_AUTH_SUCCESS / TOKEN_AUTH_FAILURE events.

Tests: tests/hermes_cli/test_dashboard_token_auth.py — ABC flag default,
verify_token NotImplementedError, registry filter, bearer extraction
(case-insensitive scheme, malformed/non-bearer → ""), provider stacking
(first-match-wins, unreachable-remembered, unreachable-then-valid, buggy
provider doesn't crash the gate), and the seam's passthrough/401/503/
fail-closed behaviour. 29 new tests; full dashboard-auth suite 169 passed.

Intentionally deferred:
- The concrete shared-bearer-secret provider plugin — Task 2.0b.
- The begin/cancel-drain endpoint that registers itself as a token route —
  Task 2.1.

Build status: dashboard-auth + plugin-hook suites green.
2026-06-26 00:47:19 -07:00
Ben ed9e8ba097 feat(dashboard-auth): add pluggable password (non-redirect) login
The dashboard auth gate was OAuth-only: a DashboardAuthProvider could
authenticate only via a redirect to an IDP (start_login -> /auth/callback
-> complete_login). There was no first-class path for username/password
auth, so self-hosters who just want a password on their dashboard had no
clean option short of an external OAuth IDP.

Extend the provider framework with a parallel, non-redirect front door
that converges on the same Session + cookie + refresh machinery:

  - base.py: add the optional supports_password flag and
    complete_password_login(username, password) -> Session (default
    raises NotImplementedError so an OAuth-only provider that forgets the
    flag fails loudly). Add InvalidCredentialsError. OAuth providers are
    unaffected (flag defaults False; the method is never called).
  - routes.py: add POST /auth/password-login, mirroring the cookie-minting
    tail of /auth/callback but skipping PKCE/state/code. Returns JSON
    {ok, next} (the form POSTs via fetch). Generic 401 for both unknown
    user and wrong password (no enumeration oracle); 404 hides whether a
    provider exists or supports passwords; per-IP sliding-window rate
    limit (10/min -> 429). /api/auth/providers now reports
    supports_password so the login page can branch.
  - middleware.py: allowlist /auth/password-login (a bootstrap route).
    verify/refresh/revoke/ws-tickets/logout need zero changes — a password
    session is just a Session with provider-minted opaque tokens.
  - login_page.py: render a credential form (instead of a redirect button)
    for supports_password providers, wired by a small inline script that
    POSTs to /auth/password-login and navigates on success. OAuth-only
    pages stay script-free.
2026-06-04 01:02:25 -07:00
Ben 2dc6d03a3d feat(dashboard-auth): define DashboardAuthProvider ABC + Session dataclass
Phase 1, Task 1.1. New package hermes_cli/dashboard_auth/ contains:

  base.py     - DashboardAuthProvider ABC with 5 abstract methods
                (start_login, complete_login, verify_session,
                refresh_session, revoke_session), Session + LoginStart
                frozen dataclasses, three exception types
                (ProviderError / InvalidCodeError / RefreshExpiredError),
                and assert_protocol_compliance() for plugins to call
                in their own tests.
  registry.py - Module-level register/get/list/clear with a lock.

Nothing reads the registry yet — Phase 2 adds the StubAuthProvider and
Phase 3 wires the gate middleware. The plugin hook lands in Task 1.3.
2026-05-27 02:12:27 -07:00