"""Route-agnostic non-interactive (bearer-token) auth seam for the dashboard. The generic API-token capability any machine-credential provider plugs into (the drain bearer-secret plugin is merely the first consumer). A route opts in by registering its exact path via :func:`register_token_route`; only registered paths are token-authable, so the auth surface of existing routes never widens. :func:`token_auth_middleware` runs OUTERMOST (installed last in web_server.py) and fully owns the decision for a token route: a recognised token attaches ``request.state.token_principal`` + ``token_authenticated`` (the cookie gates honour that flag and never bounce the request to /login); otherwise 401, or 503 when a provider's backing store was unreachable. Fails closed: no provider, no token, or an unrecognised token is always 401. """ from __future__ import annotations import logging import threading from typing import Awaitable, Callable, Optional, Tuple from fastapi import Request from fastapi.responses import JSONResponse, Response from hermes_cli.dashboard_auth import list_token_providers from hermes_cli.dashboard_auth.audit import AuditEvent, audit_log from hermes_cli.dashboard_auth.base import ProviderError, TokenPrincipal from hermes_cli.dashboard_auth.request_utils import ( client_ip as _client_ip, extract_bearer as extract_bearer_token, unreachable_response, ) _log = logging.getLogger(__name__) _token_routes: set[str] = set() # exact paths that accept bearer-token auth _lock = threading.Lock() def register_token_route(path: str) -> None: """Mark ``path`` (exact match) as token-authable. Idempotent; does NOT make the route public — it authenticates by token instead of by cookie.""" with _lock: _token_routes.add(path) def is_token_route(path: str) -> bool: """True if ``path`` was registered as token-authable (exact match).""" with _lock: return path in _token_routes def clear_token_routes() -> None: """Test-only: drop all registered token routes.""" with _lock: _token_routes.clear() def authenticate_token( request: Request, ) -> Tuple[Optional[TokenPrincipal], Optional[str]]: """Try every token provider against the request's bearer token. Returns ``(principal, None)`` on success; ``(None, None)`` for no token or no recogniser (401); ``(None, name)`` when no provider accepted it AND at least one was unreachable (caller surfaces 503, not "bad credentials"). Never raises — a buggy provider must not 500 the gate. """ token = extract_bearer_token(request) if not token: return None, None unreachable: Optional[str] = None for provider in list_token_providers(): try: principal = provider.verify_token(token=token) except ProviderError as e: _log.warning( "dashboard-auth: token provider %r unreachable during verify: %s", provider.name, e, ) if unreachable is None: unreachable = provider.name continue except Exception as e: # noqa: BLE001 — a buggy provider must not 500 the gate _log.warning( "dashboard-auth: token provider %r raised during verify: %s", provider.name, e, ) continue if principal is not None: return principal, None return None, unreachable async def token_auth_middleware( request: Request, call_next: Callable[[Request], Awaitable[Response]], ) -> Response: """Outermost auth seam: pass-through for unregistered paths; for a token route, valid token -> attach principal + flag, unreachable -> 503, else 401.""" path = request.url.path if not is_token_route(path): return await call_next(request) principal, unreachable = authenticate_token(request) if principal is not None: request.state.token_principal = principal request.state.token_authenticated = True return await call_next(request) if unreachable: audit_log( AuditEvent.TOKEN_AUTH_FAILURE, provider=unreachable, reason="provider_unreachable", path=path, ip=_client_ip(request), ) return unreachable_response(unreachable) audit_log( AuditEvent.TOKEN_AUTH_FAILURE, reason="no_provider_recognises_token", path=path, ip=_client_ip(request), ) return JSONResponse( {"error": "unauthenticated", "detail": "Unauthorized"}, status_code=401, )