Files
hermes-agent/hermes_cli/dashboard_auth/routes.py
T

759 lines
28 KiB
Python

"""HTTP routes for the dashboard-auth OAuth round trip.
Mounted at root (no prefix) by ``web_server.py``. The router does not
auto-gate; ``gated_auth_middleware`` allowlists ``/auth/*``, ``/login`` and
``/api/auth/providers``.
GET /login server-rendered login page
GET /auth/login?provider=N 302 to IDP, sets PKCE cookie
GET /auth/native/authorize RFC 8252 native-app (desktop) login start
GET /auth/callback completes login, sets session cookies
POST /auth/password-login username/password login (JSON)
POST /auth/logout clears cookies, best-effort revoke
POST /auth/native/token loopback code -> bearer tokens
POST /auth/native/refresh desktop-held refresh token rotation
GET /api/auth/providers list registered providers (login bootstrap)
GET /api/auth/me current Session as JSON (auth-required)
POST /api/auth/ws-ticket single-use WS upgrade ticket (auth-required)
"""
from __future__ import annotations
import logging
import threading
import time
from collections import defaultdict, deque
from typing import Any, Deque, Dict
from urllib.parse import quote, unquote, urlencode, urlparse, urlunparse
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from pydantic import BaseModel
from hermes_cli.dashboard_auth import (
get_provider,
list_providers,
list_session_providers,
native_flow,
)
from hermes_cli.dashboard_auth import prefix as _prefix_mod
from hermes_cli.dashboard_auth.audit import AuditEvent, audit_log
from hermes_cli.dashboard_auth.base import (
InvalidCodeError,
InvalidCredentialsError,
ProviderError,
RefreshExpiredError,
Session,
)
from hermes_cli.dashboard_auth.cookies import (
clear_pkce_cookie,
clear_session_cookies,
clear_sso_attempt_cookie,
detect_https,
parse_pkce_payload,
read_pkce_cookie,
read_session_cookies,
set_pkce_cookie,
set_session_cookies,
)
from hermes_cli.dashboard_auth.login_page import render_login_html
from hermes_cli.dashboard_auth.request_utils import (
access_token_max_age,
client_ip as _client_ip,
is_safe_next_path,
)
_log = logging.getLogger(__name__)
router = APIRouter()
_NO_STORE = {"Cache-Control": "no-store, no-cache, must-revalidate"}
_NATIVE_EXPIRED_DETAIL = "Native login expired or unknown; restart sign-in."
def _prefix(request: Request) -> str:
"""Normalised ``X-Forwarded-Prefix`` (cookie name/Path + redirect URLs)."""
return _prefix_mod.prefix_from_request(request)
def _redirect_uri(request: Request) -> str:
"""Absolute ``/auth/callback`` URL handed to the IDP.
Tiers: (1) operator-declared ``HERMES_DASHBOARD_PUBLIC_URL`` /
``dashboard.public_url`` is the complete authority — ``X-Forwarded-Prefix``
is ignored so an already-baked-in prefix is not doubled; (2) otherwise
``url_for`` (honours ``X-Forwarded-Host/Proto`` under uvicorn
``proxy_headers``) with ``X-Forwarded-Prefix`` prepended to the path,
which Starlette does not do natively.
"""
public_url = _prefix_mod.resolve_public_url()
if public_url:
return f"{public_url}/auth/callback"
base = str(request.url_for("auth_callback"))
prefix = _prefix(request)
if not prefix:
return base
parsed = urlparse(base)
return urlunparse(parsed._replace(path=f"{prefix}{parsed.path}"))
def _provider_pkce_segments(cookie_payload: dict[str, str]) -> dict[str, str]:
"""Parse a provider's flat ``state=…;verifier=…`` PKCE string into a dict.
The ONE place the provider's flat form is parsed; from here on the payload
is a dict down to :func:`set_pkce_cookie`'s base64url(JSON) encoding.
"""
flat = cookie_payload.get("hermes_session_pkce", "")
return dict(seg.split("=", 1) for seg in flat.split(";") if "=" in seg)
def _validate_post_login_target(raw: str) -> str:
"""Return ``raw`` (URL-decoded) if it is a safe same-origin path, else ``""``.
Re-validated at every hop (gate -> /login -> /auth/login -> cookie ->
callback) because a ``next=`` value can re-enter via a crafted URL.
"""
if not raw:
return ""
decoded = unquote(raw)
return decoded if is_safe_next_path(decoded) else ""
def _set_pkce(resp, request: Request, payload: dict[str, str]) -> None:
set_pkce_cookie(
resp, payload=payload, use_https=detect_https(request), prefix=_prefix(request),
)
def _set_session(resp, request: Request, session: Session) -> None:
set_session_cookies(
resp,
access_token=session.access_token,
refresh_token=session.refresh_token,
access_token_expires_in=access_token_max_age(session),
use_https=detect_https(request),
prefix=_prefix(request),
provider=session.provider,
)
def _bearer_payload(session: Session) -> dict[str, Any]:
"""JSON body for the native token/refresh endpoints (tokens in body, no cookie)."""
return {
"access_token": session.access_token,
"refresh_token": session.refresh_token,
"token_type": "Bearer",
"expires_at": session.expires_at,
"provider": session.provider,
"user_id": session.user_id,
}
def _finish_native_login(
request: Request, *, broker_state: str, session: Session, provider: str,
) -> str:
"""Mint the one-time loopback code for a pending native authorization.
Shared tail of ``/auth/callback`` and ``/auth/password-login``: returns the
desktop's ``redirect_uri?code=…&state=…``. No session cookies are set on
the native path — the desktop redeems the code at ``/auth/native/token``.
"""
ip = _client_ip(request)
try:
pending = native_flow.get_pending(broker_state)
gw_code = native_flow.complete_pending(broker_state, session=session)
except native_flow.NativeFlowError:
audit_log(
AuditEvent.NATIVE_TOKEN_FAILURE,
provider=provider, reason="pending_not_found", ip=ip,
)
raise HTTPException(status_code=400, detail=_NATIVE_EXPIRED_DETAIL)
sep = "&" if "?" in pending.redirect_uri else "?"
loopback = (
f"{pending.redirect_uri}{sep}"
f"{urlencode({'code': gw_code, 'state': pending.client_state})}"
)
audit_log(
AuditEvent.NATIVE_CODE_ISSUED,
provider=provider, user_id=session.user_id, ip=ip,
)
return loopback
def _login_failure(request: Request, provider: str, reason: str, **extra) -> None:
audit_log(
AuditEvent.LOGIN_FAILURE,
provider=provider, reason=reason, **extra, ip=_client_ip(request),
)
def _login_success(request: Request, session: Session, provider: str) -> None:
audit_log(
AuditEvent.LOGIN_SUCCESS,
provider=provider,
user_id=session.user_id,
email=session.email,
org_id=session.org_id,
ip=_client_ip(request),
)
# ---------------------------------------------------------------------------
# Public: login page + provider list
# ---------------------------------------------------------------------------
@router.get("/login", name="login_page")
async def login_page(request: Request) -> HTMLResponse:
# ``next=`` is set by the gate's redirect but /login is reachable directly,
# so re-validate here too.
next_path = _validate_post_login_target(request.query_params.get("next", ""))
return HTMLResponse(render_login_html(next_path=next_path), headers=_NO_STORE)
@router.get("/api/auth/providers", name="auth_providers")
async def api_auth_providers() -> Any:
# Only interactive providers are sign-in options; fail closed on zero.
providers = list_session_providers()
if not providers:
return JSONResponse({"detail": "no auth providers registered"}, status_code=503)
return {
"providers": [
{
"name": p.name,
"display_name": p.display_name,
"supports_password": bool(getattr(p, "supports_password", False)),
}
for p in providers
],
}
# ---------------------------------------------------------------------------
# Public: OAuth round trip
# ---------------------------------------------------------------------------
@router.get("/auth/login", name="auth_login")
async def auth_login(request: Request, provider: str, next: str = ""):
p = get_provider(provider)
if p is None:
raise HTTPException(status_code=404, detail=f"Unknown provider: {provider!r}")
if not getattr(p, "supports_session", True):
raise HTTPException(
status_code=404,
detail=f"Provider does not support interactive login: {provider!r}",
)
safe_next = _validate_post_login_target(next)
if getattr(p, "supports_password", False):
login_url = f"{_prefix(request)}/login"
if safe_next:
login_url = f"{login_url}?next={quote(safe_next, safe='')}"
return RedirectResponse(url=login_url, status_code=302)
try:
ls = p.start_login(redirect_uri=_redirect_uri(request))
except ProviderError as e:
_login_failure(request, provider, "provider_unreachable")
raise HTTPException(status_code=503, detail=f"Provider unreachable: {e}")
audit_log(AuditEvent.LOGIN_START, provider=provider, ip=_client_ip(request))
resp = RedirectResponse(url=ls.redirect_url, status_code=302)
# The PKCE cookie is the only server-controlled channel that survives the
# IDP round trip (IDPs echo back only code+state), so it carries the
# provider name and the pre-validated ``next`` path.
pkce = _provider_pkce_segments(ls.cookie_payload)
pkce.setdefault("provider", provider)
if safe_next:
pkce["next"] = safe_next
_set_pkce(resp, request, pkce)
return resp
# ---------------------------------------------------------------------------
# Public: RFC 8252 native-app authorization (system browser + loopback + PKCE)
# ---------------------------------------------------------------------------
def _validate_loopback_redirect_uri(raw: str) -> str:
"""Accept only ``http://127.0.0.1[:port]/…`` / ``http://[::1][:port]/…``.
Security boundary, not ergonomics: /auth/native/authorize is public, so a
non-loopback host would turn the callback into an open redirect leaking a
live authorization code. ``localhost`` is rejected per RFC 8252 §8.3 (may
resolve off-loopback); the desktop always sends a literal IP.
"""
if not raw:
raise HTTPException(status_code=400, detail="redirect_uri required")
parsed = urlparse(raw)
if parsed.scheme != "http":
raise HTTPException(
status_code=400,
detail="native redirect_uri must be http:// on the loopback interface",
)
if (parsed.hostname or "").lower() not in ("127.0.0.1", "::1"):
raise HTTPException(
status_code=400,
detail=(
"native redirect_uri host must be a loopback IP literal "
"(127.0.0.1 / ::1)"
),
)
return raw
def _select_native_provider(provider: str):
"""Resolve the provider for a native authorize request.
An empty ``provider`` auto-selects when exactly one brokerable (non-password)
session provider exists — password providers can never be the OAuth broker
target, so they must not count, otherwise a normal OIDC+basic deployment
would fail desktop login with a misleading "Unknown provider". With zero
brokerable providers a lone password provider is still selected so the
caller can emit an explanatory 400 rather than a 404.
"""
if provider:
return get_provider(provider)
sess_providers = list_session_providers()
native_eligible = [
pp for pp in sess_providers if not getattr(pp, "supports_password", False)
]
if len(native_eligible) == 1:
return native_eligible[0]
if not native_eligible and len(sess_providers) == 1:
return sess_providers[0]
return None
@router.get("/auth/native/authorize", name="auth_native_authorize")
async def auth_native_authorize(
request: Request,
provider: str = "",
code_challenge: str = "",
code_challenge_method: str = "",
redirect_uri: str = "",
state: str = "",
):
"""Begin an RFC 8252 native-app login for the desktop app.
Stashes a pending broker authorization keyed by an opaque ``broker_state``
that rides in the gateway's own PKCE cookie, then runs the existing
upstream round trip (``start_login`` -> IDP -> ``/auth/callback``). The
desktop's challenge/state never touch the cookie. Password providers are
sent to the interactive ``/login`` form instead of an IDP (system browser
=> OS password-manager autofill); ``/auth/password-login`` then completes
the pending authorization.
"""
if code_challenge_method.upper() != "S256":
raise HTTPException(status_code=400, detail="code_challenge_method must be S256")
if not code_challenge:
raise HTTPException(status_code=400, detail="code_challenge required")
_validate_loopback_redirect_uri(redirect_uri)
p = _select_native_provider(provider)
if p is None:
raise HTTPException(status_code=404, detail=f"Unknown provider: {provider!r}")
if not getattr(p, "supports_session", True):
raise HTTPException(
status_code=400,
detail=f"Provider does not support native login: {p.name!r}",
)
try:
broker_state = native_flow.register_pending(
code_challenge=code_challenge,
redirect_uri=redirect_uri,
client_state=state,
client_ip=_client_ip(request),
)
except native_flow.NativeFlowError as e:
raise HTTPException(status_code=503, detail=str(e))
if getattr(p, "supports_password", False):
audit_log(
AuditEvent.NATIVE_AUTHORIZE_START, provider=p.name, ip=_client_ip(request),
)
resp = RedirectResponse(url=f"{_prefix(request)}/login", status_code=302)
_set_pkce(resp, request, {"provider": p.name, "broker": broker_state})
return resp
try:
ls = p.start_login(redirect_uri=_redirect_uri(request))
except ProviderError as e:
raise HTTPException(status_code=503, detail=f"Provider unreachable: {e}")
audit_log(AuditEvent.NATIVE_AUTHORIZE_START, provider=p.name, ip=_client_ip(request))
resp = RedirectResponse(url=ls.redirect_url, status_code=302)
pkce = _provider_pkce_segments(ls.cookie_payload)
pkce.setdefault("provider", p.name)
pkce["broker"] = broker_state
_set_pkce(resp, request, pkce)
return resp
@router.get("/auth/callback", name="auth_callback")
async def auth_callback(
request: Request,
code: str = "",
state: str = "",
error: str = "",
error_description: str = "",
):
pkce_raw = read_pkce_cookie(request)
if not pkce_raw:
audit_log(
AuditEvent.LOGIN_FAILURE, reason="missing_pkce_cookie", ip=_client_ip(request),
)
raise HTTPException(status_code=400, detail="Missing PKCE state cookie")
# ``next`` and ``broker`` are read from the server-set cookie ONLY: the IDP
# echoes back just code+state, so any such query param here is attacker
# controlled.
parts = parse_pkce_payload(pkce_raw)
provider_name = parts.get("provider", "")
expected_state = parts.get("state", "")
verifier = parts.get("verifier", "")
next_from_cookie = parts.get("next", "")
broker_state = parts.get("broker", "")
p = get_provider(provider_name)
if p is None:
raise HTTPException(
status_code=400, detail=f"Unknown provider in cookie: {provider_name!r}",
)
if error:
_login_failure(request, provider_name, "idp_error", error=error)
raise HTTPException(
status_code=400,
detail=f"OAuth error from provider: {error} ({error_description})",
)
if not state or state != expected_state:
_login_failure(request, provider_name, "state_mismatch")
raise HTTPException(
status_code=400, detail="OAuth state mismatch (CSRF check failed)",
)
try:
session = p.complete_login(
code=code, state=state, code_verifier=verifier,
redirect_uri=_redirect_uri(request),
)
except InvalidCodeError as e:
_login_failure(request, provider_name, "invalid_code")
raise HTTPException(status_code=400, detail=f"Invalid code: {e}")
except ProviderError as e:
_login_failure(request, provider_name, "provider_unreachable")
raise HTTPException(status_code=503, detail=f"Provider unreachable: {e}")
_login_success(request, session, provider_name)
https = detect_https(request)
prefix = _prefix(request)
if broker_state:
loopback = _finish_native_login(
request, broker_state=broker_state, session=session, provider=provider_name,
)
resp = RedirectResponse(url=loopback, status_code=302)
clear_pkce_cookie(resp, use_https=https, prefix=prefix)
clear_sso_attempt_cookie(resp, prefix=prefix)
return resp
landing = _validate_post_login_target(next_from_cookie) or "/"
resp = RedirectResponse(url=landing, status_code=302)
_set_session(resp, request, session)
clear_pkce_cookie(resp, use_https=https, prefix=prefix)
# Clear the one-shot auto-SSO loop-guard so it never suppresses a future
# silent attempt after logout.
clear_sso_attempt_cookie(resp, prefix=prefix)
return resp
# ---------------------------------------------------------------------------
# Public: password (non-redirect) login
# ---------------------------------------------------------------------------
#
# Brute-force throttle: a process-local sliding window per client IP. Best
# effort defence-in-depth on top of the provider's constant-time verify (resets
# on restart; behind a proxy the IP is the proxy's unless X-Forwarded-For).
_PW_RATE_MAX_ATTEMPTS = 10
_PW_RATE_WINDOW_SEC = 60.0
_pw_attempts: Dict[str, Deque[float]] = defaultdict(deque)
_pw_attempts_lock = threading.Lock()
def _password_rate_limited(ip: str) -> bool:
"""True if ``ip`` exceeded the budget; records the attempt when allowed.
An empty IP shares one bucket — fail-safe toward throttling.
"""
now = time.monotonic()
cutoff = now - _PW_RATE_WINDOW_SEC
with _pw_attempts_lock:
bucket = _pw_attempts[ip or "_unknown_"]
while bucket and bucket[0] < cutoff:
bucket.popleft()
if len(bucket) >= _PW_RATE_MAX_ATTEMPTS:
return True
bucket.append(now)
return False
def _reset_password_rate_limit() -> None:
"""Test-only: clear all rate-limit buckets."""
with _pw_attempts_lock:
_pw_attempts.clear()
class _PasswordLoginBody(BaseModel):
provider: str
username: str
password: str
next: str = ""
@router.post("/auth/password-login", name="auth_password_login")
async def auth_password_login(request: Request, body: _PasswordLoginBody):
"""Authenticate a username/password against a password provider.
Returns JSON ``{"ok": true, "next": <path>}`` (the form POSTs via fetch,
which follows a 302 opaquely) and sets the session cookies. When the PKCE
cookie carries a native ``broker`` handle, ``next`` is instead the
desktop's loopback redirect and NO cookies are set.
Failure modes are deliberately generic (no username/provider oracle):
unknown or non-password provider -> 404; bad credentials -> 401; backing
store unreachable -> 503; too many attempts from this IP -> 429.
"""
ip = _client_ip(request)
if _password_rate_limited(ip):
_login_failure(request, body.provider, "rate_limited")
raise HTTPException(
status_code=429, detail="Too many login attempts. Try again shortly.",
)
p = get_provider(body.provider)
if p is None or not getattr(p, "supports_password", False):
_login_failure(request, body.provider, "unknown_password_provider")
raise HTTPException(status_code=404, detail="Unknown provider")
# The native broker handle also records WHICH provider the flow was started
# for. Enforce equality BEFORE verifying credentials so a flow started for
# provider A cannot be completed with provider B's credentials.
broker_state = ""
cookie_provider = ""
pkce_raw = read_pkce_cookie(request)
if pkce_raw:
pkce_parts = parse_pkce_payload(pkce_raw)
broker_state = pkce_parts.get("broker", "")
cookie_provider = pkce_parts.get("provider", "")
if broker_state and cookie_provider != body.provider:
audit_log(
AuditEvent.NATIVE_TOKEN_FAILURE,
provider=body.provider, reason="provider_mismatch", ip=ip,
)
raise HTTPException(
status_code=400,
detail=(
"This native sign-in was started for a different provider; "
"use that provider's form or restart sign-in."
),
)
try:
session = p.complete_password_login(
username=body.username, password=body.password
)
except InvalidCredentialsError:
_login_failure(request, body.provider, "invalid_credentials")
raise HTTPException(status_code=401, detail="Invalid credentials")
except NotImplementedError:
# supports_password True but method not implemented: a provider bug.
raise HTTPException(status_code=500, detail="Provider misconfigured")
except ProviderError as e:
_login_failure(request, body.provider, "provider_unreachable")
raise HTTPException(status_code=503, detail=f"Provider unreachable: {e}")
_login_success(request, session, body.provider)
if broker_state:
loopback = _finish_native_login(
request, broker_state=broker_state, session=session, provider=body.provider,
)
resp = JSONResponse({"ok": True, "next": loopback})
clear_pkce_cookie(resp, use_https=detect_https(request), prefix=_prefix(request))
return resp
landing = _validate_post_login_target(body.next) or "/"
resp = JSONResponse({"ok": True, "next": landing})
_set_session(resp, request, session)
return resp
@router.post("/auth/logout", name="auth_logout")
async def auth_logout(request: Request):
_at, rt = read_session_cookies(request)
if rt:
# Best-effort revoke on every provider; failures logged, never raised.
for provider in list_providers():
try:
provider.revoke_session(refresh_token=rt)
except Exception as e: # noqa: BLE001 — best-effort
_log.warning(
"dashboard-auth: revoke on %r failed: %s", provider.name, e,
)
sess = getattr(request.state, "session", None)
audit_log(
AuditEvent.LOGOUT,
provider=(sess.provider if sess else "unknown"),
user_id=(sess.user_id if sess else ""),
ip=_client_ip(request),
)
prefix = _prefix(request)
resp = RedirectResponse(url=f"{prefix}/login", status_code=302)
clear_session_cookies(resp, prefix=prefix)
clear_pkce_cookie(resp, use_https=detect_https(request), prefix=prefix)
return resp
# ---------------------------------------------------------------------------
# Auth-required: identity probe + WS ticket for the SPA
# ---------------------------------------------------------------------------
def _require_session(request: Request):
sess = getattr(request.state, "session", None)
if sess is None:
raise HTTPException(status_code=401, detail="Unauthorized")
return sess
@router.get("/api/auth/me", name="auth_me")
async def api_auth_me(request: Request):
"""Return the verified session as JSON. Auth-required (gate enforces)."""
sess = _require_session(request)
return {
"user_id": sess.user_id,
"email": sess.email,
"display_name": sess.display_name,
"org_id": sess.org_id,
"provider": sess.provider,
"expires_at": sess.expires_at,
}
@router.post("/api/auth/ws-ticket", name="auth_ws_ticket")
async def api_auth_ws_ticket(request: Request):
"""Mint a 30s single-use ticket for a WS upgrade (browsers cannot set
``Authorization`` on the upgrade). One ticket per WS is the expected pattern.
"""
sess = _require_session(request)
from hermes_cli.dashboard_auth.ws_tickets import TTL_SECONDS, mint_ticket
ticket = mint_ticket(user_id=sess.user_id, provider=sess.provider)
audit_log(
AuditEvent.WS_TICKET_MINTED,
provider=sess.provider, user_id=sess.user_id, ip=_client_ip(request),
)
return {"ticket": ticket, "ttl_seconds": TTL_SECONDS}
# ---------------------------------------------------------------------------
# Public: RFC 8252 native-app token exchange + refresh
# ---------------------------------------------------------------------------
class _NativeTokenBody(BaseModel):
code: str
code_verifier: str
@router.post("/auth/native/token", name="auth_native_token")
async def auth_native_token(request: Request, body: _NativeTokenBody):
"""Exchange a loopback gateway code + PKCE verifier for bearer tokens.
The code is consumed on every path (no verifier oracle, no replay); any
unknown/expired/redeemed code or PKCE mismatch is a generic 400. Tokens go
in the JSON body; no cookie is set.
"""
try:
session = native_flow.redeem_code(
code=body.code, code_verifier=body.code_verifier
)
except native_flow.CodeInvalid:
audit_log(
AuditEvent.NATIVE_TOKEN_FAILURE,
reason="invalid_code_or_pkce", ip=_client_ip(request),
)
raise HTTPException(
status_code=400, detail="Invalid or expired authorization code.",
)
audit_log(
AuditEvent.NATIVE_TOKEN_SUCCESS,
provider=session.provider, user_id=session.user_id, ip=_client_ip(request),
)
return _bearer_payload(session)
class _NativeRefreshBody(BaseModel):
refresh_token: str
provider: str = ""
@router.post("/auth/native/refresh", name="auth_native_refresh")
async def auth_native_refresh(request: Request, body: _NativeRefreshBody):
"""Rotate a desktop-held refresh token (mirrors the gate's ``_attempt_refresh``).
Tries each session provider (hinted one first) until one rotates the
token. Every provider rejecting the RT -> 401 ``session_expired`` (desktop
starts a fresh login); none rotated and one unreachable -> 503.
"""
if not body.refresh_token:
raise HTTPException(status_code=400, detail="refresh_token required")
providers = list_session_providers()
if body.provider:
providers.sort(key=lambda p: p.name != body.provider)
unreachable: str | None = None
for provider in providers:
try:
session = provider.refresh_session(refresh_token=body.refresh_token)
except RefreshExpiredError:
continue
except ProviderError as e:
if unreachable is None:
unreachable = provider.name
_log.warning(
"dashboard-auth: provider %r unreachable during native refresh: %s",
provider.name, e,
)
continue
audit_log(
AuditEvent.REFRESH_SUCCESS,
provider=session.provider, user_id=session.user_id, ip=_client_ip(request),
)
return _bearer_payload(session)
if unreachable is not None:
raise HTTPException(
status_code=503, detail=f"Auth provider {unreachable!r} unreachable",
)
audit_log(
AuditEvent.REFRESH_FAILURE,
reason="all_providers_rejected_rt", ip=_client_ip(request),
)
return JSONResponse(
{
"error": "session_expired",
"detail": "Refresh token expired or invalid; start a new sign-in.",
},
status_code=401,
)