Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions hermes_cli/dashboard_auth/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,11 @@ class InvalidCredentialsError(Exception):


class RefreshExpiredError(Exception):
"""The refresh token is dead.
"""This provider rejects the refresh token as dead or invalid.

Middleware clears cookies and forces re-login (302 → ``/login``).
In a multi-provider deployment this does not prove token ownership, so
middleware may try remaining providers. It clears cookies and forces
re-login only after every reachable provider rejects the token.
"""


Expand Down Expand Up @@ -125,9 +127,13 @@ class DashboardAuthProvider(ABC):
raises ``ProviderError`` if the IDP is unreachable. Middleware
treats expiry and unreachable differently (expiry → refresh;
unreachable → 503).
* ``refresh_session`` raises ``RefreshExpiredError`` when the
refresh token is also invalid; middleware then forces re-login.
Raises ``ProviderError`` on network failure.
* ``refresh_session`` raises ``RefreshExpiredError`` when the refresh
token is invalid for that provider. Middleware tries the remaining
providers because an opaque foreign token can be indistinguishable
from an expired one; it forces re-login only after every reachable
provider rejects the token. Raises ``ProviderError`` on network
failure; middleware still tries remaining providers, but returns 503
without clearing cookies if none succeeds and any was unavailable.
* ``revoke_session`` is best-effort and must not raise.

Subclasses MUST set ``name`` (lowercase identifier, stable forever)
Expand Down
38 changes: 38 additions & 0 deletions hermes_cli/dashboard_auth/cookies.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@
# request's HTTPS + prefix combination.
SESSION_AT_COOKIE = "hermes_session_at"
SESSION_RT_COOKIE = "hermes_session_rt"
# Provider that minted the session. This non-secret routing hint prevents a
# refresh token from being handed to the wrong provider when several dashboard
# auth plugins are enabled (for example Basic + Nous OAuth).
SESSION_PROVIDER_COOKIE = "hermes_session_provider"
PKCE_COOKIE = "hermes_session_pkce"
# One-shot loop-guard marker for the auto-SSO redirect (Phase 1,
# cloud-auto-discovery). Set when the gate auto-initiates the portal OAuth
Expand Down Expand Up @@ -141,6 +145,24 @@ def _common_attrs(*, use_https: bool, prefix: str) -> dict:
return attrs


def set_session_provider_cookie(
response: Response,
*,
provider: str,
use_https: bool,
prefix: str = "",
) -> None:
"""Persist the non-secret provider routing hint for token refresh."""
if not provider:
return
response.set_cookie(
_resolved_name(SESSION_PROVIDER_COOKIE, use_https=use_https, prefix=prefix),
provider,
max_age=_RT_MAX_AGE,
**_common_attrs(use_https=use_https, prefix=prefix),
)


def set_session_cookies(
response: Response,
*,
Expand All @@ -149,6 +171,7 @@ def set_session_cookies(
access_token_expires_in: int,
use_https: bool,
prefix: str = "",
provider: str = "",
) -> None:
"""Set the session cookies on the response.

Expand Down Expand Up @@ -181,6 +204,12 @@ def set_session_cookies(
max_age=_RT_MAX_AGE,
**_common_attrs(use_https=use_https, prefix=prefix),
)
set_session_provider_cookie(
response,
provider=provider,
use_https=use_https,
prefix=prefix,
)


def clear_session_cookies(response: Response, *, prefix: str = "") -> None:
Expand All @@ -202,6 +231,10 @@ def clear_session_cookies(response: Response, *, prefix: str = "") -> None:
f"{variant}{SESSION_RT_COOKIE}", "", max_age=0,
path=path, httponly=True, samesite="lax",
)
response.set_cookie(
f"{variant}{SESSION_PROVIDER_COOKIE}", "", max_age=0,
path=path, httponly=True, samesite="lax",
)


def set_pkce_cookie(
Expand Down Expand Up @@ -248,6 +281,11 @@ def read_session_cookies(request: Request) -> Tuple[Optional[str], Optional[str]
return at, rt


def read_session_provider(request: Request) -> Optional[str]:
"""Return the provider routing hint associated with the session cookies."""
return _read_with_fallback(request, SESSION_PROVIDER_COOKIE)


def read_pkce_cookie(request: Request) -> Optional[str]:
return _read_with_fallback(request, PKCE_COOKIE)

Expand Down
91 changes: 71 additions & 20 deletions hermes_cli/dashboard_auth/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,17 @@

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 ProviderError, RefreshExpiredError
from hermes_cli.dashboard_auth.base import (
DashboardAuthProvider,
ProviderError,
RefreshExpiredError,
)
from hermes_cli.dashboard_auth.cookies import (
clear_sso_attempt_cookie,
read_session_cookies,
read_session_provider,
read_sso_attempt_cookie,
set_session_provider_cookie,
set_sso_attempt_cookie,
)
from hermes_cli.dashboard_auth.public_paths import PUBLIC_API_PATHS
Expand Down Expand Up @@ -83,6 +89,22 @@ def _client_ip(request: Request) -> str:
return request.client.host if request.client else ""


def _ordered_session_providers(
provider_hint: str | None,
) -> list[DashboardAuthProvider]:
"""Prefer the hinted provider without making the hint authoritative.

The cookie can outlive a provider rename/removal or become stale after a
deployment change. A stable sort moves a matching provider to the front
while preserving registration order for every remaining candidate; an
unknown hint therefore leaves the normal scan unchanged.
"""
providers = list_session_providers()
if provider_hint:
providers.sort(key=lambda provider: provider.name != provider_hint)
return providers


def _unauth_response(request: Request, *, reason: str) -> Response:
"""API routes → 401 JSON with ``login_url``; HTML routes → 302 → /login.

Expand Down Expand Up @@ -276,6 +298,7 @@ async def gated_auth_middleware(
return await call_next(request)

at, _rt = read_session_cookies(request)
provider_hint = read_session_provider(request)
if not at and not _rt:
# Neither token present — no session at all. Nothing to verify or
# refresh. Before falling back to the /login interstitial, try to
Expand Down Expand Up @@ -321,7 +344,7 @@ async def gated_auth_middleware(
# 503 — distinguishing "transient IDP outage" (don't force re-login)
# from "token genuinely invalid" (fall through to refresh/relogin).
unreachable_provider: str | None = None
for provider in list_session_providers():
for provider in _ordered_session_providers(provider_hint):
try:
session = provider.verify_session(access_token=at)
except ProviderError as e:
Expand Down Expand Up @@ -353,9 +376,22 @@ async def gated_auth_middleware(
# Access token is expired/invalid. Before forcing re-login, try to
# rotate it using the refresh token (if the session cookie carries
# one). On success we re-set the rotated cookies on the response and
# serve the request transparently; on RefreshExpiredError (RT dead /
# revoked / reuse-detected) we fall through to clear-and-relogin.
refreshed = _attempt_refresh(request, refresh_token=_rt)
# serve the request transparently; only after every provider rejects
# the RT do we fall through to clear-and-relogin.
try:
refreshed = _attempt_refresh(
request,
refresh_token=_rt,
provider_hint=provider_hint,
)
except ProviderError as e:
# At least one provider could not confirm or reject the RT, and no
# other provider refreshed it. Preserve the cookies and surface a
# transient outage instead of turning uncertainty into a logout.
return JSONResponse(
{"detail": f"Auth provider {str(e)!r} unreachable"},
status_code=503,
)
if refreshed is not None:
new_session, refreshing_provider = refreshed
request.state.session = new_session
Expand All @@ -378,6 +414,7 @@ async def gated_auth_middleware(
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,
Expand Down Expand Up @@ -405,7 +442,18 @@ async def gated_auth_middleware(
return response

request.state.session = session
return await call_next(request)
response = await call_next(request)
if not provider_hint and session.provider:
from hermes_cli.dashboard_auth.cookies import detect_https
from hermes_cli.dashboard_auth.prefix import prefix_from_request

set_session_provider_cookie(
response,
provider=session.provider,
use_https=detect_https(request),
prefix=prefix_from_request(request),
)
return response


def _expires_in_seconds(session) -> int:
Expand All @@ -421,33 +469,32 @@ def _expires_in_seconds(session) -> int:
return max(60, int(session.expires_at) - int(time.time()))


def _attempt_refresh(request: Request, *, refresh_token):
def _attempt_refresh(request: Request, *, refresh_token, provider_hint: str | None = None):
"""Try to rotate an expired session via the refresh token.

Returns ``(new_session, provider_name)`` on success, or ``None`` if
there's no RT or every provider's ``refresh_session`` failed with
``RefreshExpiredError`` (dead/revoked/reuse-detected RT → force re-login).

A ``ProviderError`` (Portal unreachable) is NOT swallowed into a re-login
here — re-raising would 500 the request; instead we log and return None so
the caller forces a clean re-login, which is the safer UX than a hard
error on a transient network blip during the narrow refresh window.
The provider hint only changes candidate order. ``RefreshExpiredError``
rejects the token for that candidate, but cannot prove ownership because
providers such as Basic raise it for foreign opaque tokens too. Likewise,
``ProviderError`` only makes that candidate unavailable. Both are audited
and the remaining providers are tried. Returns ``None`` only when there is
no RT or every reachable provider rejects it. If no provider succeeds and
at least one raised ``ProviderError``, re-raises with that provider's name
so the caller can return 503 without clearing potentially valid cookies.
"""
if not refresh_token:
return None
for provider in list_session_providers():
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:
# This provider owns the RT but it's dead — stop trying others
# (an RT belongs to exactly one provider) and force re-login.
audit_log(
AuditEvent.REFRESH_FAILURE,
provider=provider.name,
reason="refresh_expired",
ip=_client_ip(request),
)
return None
continue
except ProviderError as e:
_log.warning(
"dashboard-auth: provider %r unreachable during refresh: %s",
Expand All @@ -459,7 +506,11 @@ def _attempt_refresh(request: Request, *, refresh_token):
reason="provider_unreachable",
ip=_client_ip(request),
)
return None
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
2 changes: 2 additions & 0 deletions hermes_cli/dashboard_auth/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,7 @@ async def auth_callback(
access_token_expires_in=expires_in,
use_https=detect_https(request),
prefix=_prefix(request),
provider=session.provider,
)
clear_pkce_cookie(resp, prefix=_prefix(request))
# Clear the one-shot auto-SSO loop-guard marker now that login succeeded,
Expand Down Expand Up @@ -549,6 +550,7 @@ async def auth_password_login(request: Request, body: _PasswordLoginBody):
access_token_expires_in=expires_in,
use_https=detect_https(request),
prefix=_prefix(request),
provider=session.provider,
)
return resp

Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,7 @@
"290859878+synapsesx@users.noreply.github.com": "synapsesx",
"157689911+itsflownium@users.noreply.github.com": "itsflownium",
"dirtyren@users.noreply.github.com": "dirtyren",
"theoldwizard123@pm.me": "unsupportedpastels",
"johnmlussier@gmail.com": "John-Lussier",
"chenkun_lws@126.com": "bytesnail", # PR #60360 salvage (--yolo startup ordering; #60328)
"iamgexin@qq.com": "nullptr0807", # PR #60956 salvage (gateway hygiene in-place compaction; #60947)
Expand Down
Loading
Loading