378 lines
14 KiB
Python
378 lines
14 KiB
Python
"""Auth-gate middleware for the dashboard.
|
|
|
|
Engaged when ``app.state.auth_required is True``; a no-op otherwise (loopback
|
|
mode is handled by the legacy ``_SESSION_TOKEN`` ``auth_middleware``). Allows
|
|
the auth-bootstrap routes and static assets through unauthenticated; for
|
|
everything else demands a bearer token or a valid session cookie and attaches
|
|
the verified :class:`Session` to ``request.state.session``. HTML routes are
|
|
redirected to ``/login``; ``/api/*`` routes get a 401 JSON envelope.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Awaitable, Callable
|
|
from urllib.parse import quote
|
|
|
|
from fastapi import Request
|
|
from fastapi.responses import JSONResponse, RedirectResponse, Response
|
|
|
|
from hermes_cli.dashboard_auth import list_session_providers
|
|
from hermes_cli.dashboard_auth.audit import AuditEvent, audit_log
|
|
from hermes_cli.dashboard_auth.base import (
|
|
DashboardAuthProvider,
|
|
ProviderError,
|
|
RefreshExpiredError,
|
|
)
|
|
from hermes_cli.dashboard_auth.cookies import (
|
|
clear_session_cookies,
|
|
clear_sso_attempt_cookie,
|
|
detect_https,
|
|
read_session_cookies,
|
|
read_session_provider,
|
|
read_sso_attempt_cookie,
|
|
set_session_cookies,
|
|
set_session_provider_cookie,
|
|
set_sso_attempt_cookie,
|
|
)
|
|
from hermes_cli.dashboard_auth.prefix import prefix_from_request
|
|
from hermes_cli.dashboard_auth.public_paths import PUBLIC_API_PATHS
|
|
from hermes_cli.dashboard_auth.request_utils import (
|
|
access_token_max_age as _expires_in_seconds,
|
|
client_ip as _client_ip,
|
|
extract_bearer as _extract_bearer,
|
|
is_safe_next_path,
|
|
unreachable_response,
|
|
)
|
|
|
|
_log = logging.getLogger(__name__)
|
|
|
|
# Prefix-matched (``path == p or path.startswith(p)``) bypass list: auth
|
|
# bootstrap routes and static asset mounts. ``/assets/`` with the trailing
|
|
# slash matches ``/assets/foo.css`` but not ``/assetsleak``.
|
|
_GATE_PUBLIC_PREFIXES: tuple[str, ...] = (
|
|
"/auth/login",
|
|
"/auth/callback",
|
|
"/auth/native/authorize",
|
|
"/auth/native/token",
|
|
"/auth/native/refresh",
|
|
"/auth/password-login",
|
|
"/auth/logout",
|
|
"/login",
|
|
"/api/auth/providers",
|
|
"/api/mcp/oauth/callback/",
|
|
"/assets/",
|
|
"/favicon.ico",
|
|
"/ds-assets/",
|
|
"/fonts/",
|
|
"/fonts-terminal/",
|
|
)
|
|
|
|
|
|
def _path_is_public(path: str) -> bool:
|
|
"""True if ``path`` bypasses the gate.
|
|
|
|
:data:`PUBLIC_API_PATHS` (shared with the legacy middleware) is matched
|
|
exactly so ``/api/status`` never exposes ``/api/status/extension``;
|
|
:data:`_GATE_PUBLIC_PREFIXES` is prefix-matched.
|
|
"""
|
|
if path in PUBLIC_API_PATHS:
|
|
return True
|
|
return any(path == p or path.startswith(p) for p in _GATE_PUBLIC_PREFIXES)
|
|
|
|
|
|
def _ordered_session_providers(
|
|
provider_hint: str | None,
|
|
) -> list[DashboardAuthProvider]:
|
|
"""Session providers with the hinted one first (stable sort).
|
|
|
|
The hint is a routing preference, not authoritative: a stale/unknown hint
|
|
(provider renamed or removed) leaves the normal registration order intact.
|
|
"""
|
|
providers = list_session_providers()
|
|
if provider_hint:
|
|
providers.sort(key=lambda provider: provider.name != provider_hint)
|
|
return providers
|
|
|
|
|
|
def _safe_next_target(request: Request) -> str:
|
|
"""URL-encoded ``next`` value for the login redirect, or ``""``.
|
|
|
|
Only same-origin relative paths outside the auth flow and ``/api`` are
|
|
kept (see :func:`is_safe_next_path`); the query string is preserved. SPA
|
|
deep links that are dropped fall back to the SPA's own
|
|
``sessionStorage["hermes.lastLocation"]``.
|
|
"""
|
|
path = request.url.path
|
|
if not path or not is_safe_next_path(path):
|
|
return ""
|
|
query = request.url.query
|
|
return quote(f"{path}?{query}" if query else path, safe="")
|
|
|
|
|
|
def _unauth_response(request: Request, *, reason: str) -> Response:
|
|
"""API routes -> 401 JSON with ``login_url``; HTML routes -> 302 -> /login.
|
|
|
|
fetch() follows a 302 opaquely into the cross-origin OAuth dance, so API
|
|
routes never get redirects; the SPA's global 401 handler navigates to
|
|
``login_url`` when ``error`` is ``unauthenticated`` or ``session_expired``.
|
|
Both shapes carry ``next=`` and the active proxy prefix.
|
|
"""
|
|
next_param = _safe_next_target(request)
|
|
prefix = prefix_from_request(request)
|
|
login_url = f"{prefix}/login?next={next_param}" if next_param else f"{prefix}/login"
|
|
|
|
if request.url.path.startswith("/api/"):
|
|
error_code = (
|
|
"session_expired" if reason == "invalid_or_expired_session"
|
|
else "unauthenticated"
|
|
)
|
|
return JSONResponse(
|
|
{
|
|
"error": error_code,
|
|
"detail": "Unauthorized",
|
|
"reason": reason,
|
|
"login_url": login_url,
|
|
},
|
|
status_code=401,
|
|
)
|
|
return RedirectResponse(url=login_url, status_code=302)
|
|
|
|
|
|
def _auto_sso_response(request: Request) -> Response | None:
|
|
"""302 straight to ``/auth/login`` on an unauthenticated HTML load, or ``None``.
|
|
|
|
Only when: the request is a document load (not ``/api/*``); exactly one
|
|
interactive provider is registered and it is OAuth-style (a password
|
|
provider must render the form); and the one-shot loop-guard cookie is
|
|
absent. A present marker means the portal had no session for us last time
|
|
— clear it and fall back to ``/login`` rather than ping-pong. Removes the
|
|
interstitial click, not a security check: ``/auth/login`` runs the
|
|
unchanged PKCE flow.
|
|
"""
|
|
if request.url.path.startswith("/api/"):
|
|
return None
|
|
|
|
if read_sso_attempt_cookie(request):
|
|
resp = _unauth_response(request, reason="no_cookie")
|
|
clear_sso_attempt_cookie(resp, prefix=prefix_from_request(request))
|
|
return resp
|
|
|
|
providers = list_session_providers()
|
|
if len(providers) != 1:
|
|
return None
|
|
provider = providers[0]
|
|
if getattr(provider, "supports_password", False):
|
|
return None
|
|
|
|
prefix = prefix_from_request(request)
|
|
next_param = _safe_next_target(request)
|
|
auth_login = f"{prefix}/auth/login?provider={quote(provider.name, safe='')}"
|
|
if next_param:
|
|
auth_login = f"{auth_login}&next={next_param}"
|
|
|
|
resp = RedirectResponse(url=auth_login, status_code=302)
|
|
set_sso_attempt_cookie(resp, use_https=detect_https(request), prefix=prefix)
|
|
audit_log(
|
|
AuditEvent.LOGIN_START,
|
|
provider=provider.name, reason="auto_sso", ip=_client_ip(request),
|
|
)
|
|
return resp
|
|
|
|
|
|
def _verify_access_token(
|
|
request: Request, *, access_token: str, provider_hint: str | None = None,
|
|
audit: bool = True,
|
|
):
|
|
"""Run ``verify_session`` across the provider stack; Session or ``None``.
|
|
|
|
A provider that does not recognise the token returns ``None`` and the
|
|
next is tried. A ``ProviderError`` (IDP/JWKS unreachable) must NOT abort
|
|
the chain — the token may belong to a different, reachable provider. If
|
|
no provider verifies AND at least one was unreachable, raises
|
|
``ProviderError(name)`` so the caller returns 503 instead of forcing a
|
|
re-login through a possibly-unreachable refresh.
|
|
"""
|
|
unreachable_provider: str | None = None
|
|
for provider in _ordered_session_providers(provider_hint):
|
|
try:
|
|
session = provider.verify_session(access_token=access_token)
|
|
except ProviderError as e:
|
|
_log.warning(
|
|
"dashboard-auth: provider %r unreachable during %s: %s",
|
|
provider.name, "verify" if audit else "bearer verify", e,
|
|
)
|
|
if audit:
|
|
audit_log(
|
|
AuditEvent.SESSION_VERIFY_FAILURE,
|
|
provider=provider.name,
|
|
reason="provider_unreachable",
|
|
ip=_client_ip(request),
|
|
)
|
|
if unreachable_provider is None:
|
|
unreachable_provider = provider.name
|
|
continue
|
|
if session is not None:
|
|
return session
|
|
if unreachable_provider is not None:
|
|
raise ProviderError(unreachable_provider)
|
|
return None
|
|
|
|
|
|
def _verify_bearer(request: Request, *, access_token: str):
|
|
"""Verify a native-app bearer token (no cookie, no server-side refresh —
|
|
the desktop rotates via ``/auth/native/refresh``). Same 503-on-outage
|
|
semantics as the cookie path.
|
|
"""
|
|
return _verify_access_token(request, access_token=access_token, audit=False)
|
|
|
|
|
|
async def gated_auth_middleware(
|
|
request: Request,
|
|
call_next: Callable[[Request], Awaitable[Response]],
|
|
) -> Response:
|
|
"""Engaged only when ``app.state.auth_required is True``."""
|
|
if not getattr(request.app.state, "auth_required", False):
|
|
return await call_next(request)
|
|
|
|
# Already authenticated by the token-auth seam (service caller on a
|
|
# registered token route): not a cookie session, must not bounce to /login.
|
|
if getattr(request.state, "token_authenticated", False):
|
|
return await call_next(request)
|
|
|
|
if _path_is_public(request.url.path):
|
|
return await call_next(request)
|
|
|
|
# RFC 8252 native-app bearer path: the same provider-minted access token
|
|
# the cookie flow stores, verified with the same provider stack, no cookie
|
|
# read or set. A presented-but-invalid bearer gets the structured 401 so
|
|
# the desktop refreshes/re-logs instead of following a cookie redirect.
|
|
bearer = _extract_bearer(request)
|
|
if bearer:
|
|
try:
|
|
bearer_session = _verify_bearer(request, access_token=bearer)
|
|
except ProviderError as e:
|
|
return unreachable_response(str(e))
|
|
if bearer_session is not None:
|
|
request.state.session = bearer_session
|
|
return await call_next(request)
|
|
return _unauth_response(request, reason="invalid_or_expired_session")
|
|
|
|
at, _rt = read_session_cookies(request)
|
|
provider_hint = read_session_provider(request)
|
|
if not at and not _rt:
|
|
# No session at all: try the silent portal bounce before /login.
|
|
auto = _auto_sso_response(request)
|
|
if auto is not None:
|
|
return auto
|
|
return _unauth_response(request, reason="no_cookie")
|
|
|
|
# An absent AT with a present RT is the COMMON expiry case (the AT cookie's
|
|
# Max-Age tracks the token TTL, so the browser evicts it first) — skip
|
|
# straight to refresh rather than bouncing to /login.
|
|
session = None
|
|
if at:
|
|
try:
|
|
session = _verify_access_token(
|
|
request, access_token=at, provider_hint=provider_hint,
|
|
)
|
|
except ProviderError as e:
|
|
return unreachable_response(str(e))
|
|
|
|
if session is None:
|
|
# Rotate via the refresh token before forcing re-login. On success the
|
|
# rotated cookies are re-set and the request served transparently.
|
|
try:
|
|
refreshed = _attempt_refresh(
|
|
request, refresh_token=_rt, provider_hint=provider_hint,
|
|
)
|
|
except ProviderError as e:
|
|
# Uncertain (provider unreachable), not rejected: keep the cookies.
|
|
return unreachable_response(str(e))
|
|
if refreshed is not None:
|
|
new_session, refreshing_provider = refreshed
|
|
request.state.session = new_session
|
|
response = await call_next(request)
|
|
# Writing the ROTATED RT back is mandatory: Portal runs reuse
|
|
# detection, so replaying the stale RT would revoke the session.
|
|
set_session_cookies(
|
|
response,
|
|
access_token=new_session.access_token,
|
|
refresh_token=new_session.refresh_token,
|
|
access_token_expires_in=_expires_in_seconds(new_session),
|
|
use_https=detect_https(request),
|
|
prefix=prefix_from_request(request),
|
|
provider=refreshing_provider,
|
|
)
|
|
audit_log(
|
|
AuditEvent.REFRESH_SUCCESS,
|
|
provider=refreshing_provider,
|
|
user_id=new_session.user_id,
|
|
ip=_client_ip(request),
|
|
)
|
|
return response
|
|
|
|
audit_log(
|
|
AuditEvent.SESSION_VERIFY_FAILURE,
|
|
reason="no_provider_recognises",
|
|
ip=_client_ip(request),
|
|
)
|
|
response = _unauth_response(request, reason="invalid_or_expired_session")
|
|
# Refresh failed (or no RT): clear the dead cookies under the active
|
|
# prefix so the deletion Path matches the set Path.
|
|
clear_session_cookies(response, prefix=prefix_from_request(request))
|
|
return response
|
|
|
|
request.state.session = session
|
|
response = await call_next(request)
|
|
if not provider_hint and session.provider:
|
|
set_session_provider_cookie(
|
|
response,
|
|
provider=session.provider,
|
|
use_https=detect_https(request),
|
|
prefix=prefix_from_request(request),
|
|
)
|
|
return response
|
|
|
|
|
|
def _attempt_refresh(request: Request, *, refresh_token, provider_hint: str | None = None):
|
|
"""Rotate an expired session via the refresh token; ``(Session, provider_name)`` or ``None``.
|
|
|
|
``RefreshExpiredError`` rejects the token for that candidate only (Basic
|
|
raises it for foreign opaque tokens too), so remaining providers are
|
|
tried. If none succeeds and any raised ``ProviderError``, re-raises with
|
|
that provider's name so the caller returns 503 without clearing cookies.
|
|
"""
|
|
if not refresh_token:
|
|
return None
|
|
unavailable_provider: str | None = None
|
|
for provider in _ordered_session_providers(provider_hint):
|
|
try:
|
|
new_session = provider.refresh_session(refresh_token=refresh_token)
|
|
except RefreshExpiredError:
|
|
audit_log(
|
|
AuditEvent.REFRESH_FAILURE,
|
|
provider=provider.name,
|
|
reason="refresh_expired",
|
|
ip=_client_ip(request),
|
|
)
|
|
continue
|
|
except ProviderError as e:
|
|
_log.warning(
|
|
"dashboard-auth: provider %r unreachable during refresh: %s",
|
|
provider.name, e,
|
|
)
|
|
audit_log(
|
|
AuditEvent.REFRESH_FAILURE,
|
|
provider=provider.name,
|
|
reason="provider_unreachable",
|
|
ip=_client_ip(request),
|
|
)
|
|
if unavailable_provider is None:
|
|
unavailable_provider = provider.name
|
|
continue
|
|
if new_session is not None:
|
|
return new_session, provider.name
|
|
if unavailable_provider is not None:
|
|
raise ProviderError(unavailable_provider)
|
|
return None
|