130 lines
4.5 KiB
Python
130 lines
4.5 KiB
Python
"""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,
|
|
)
|