refactor(auth): Disable Nous legacy session key fallback

This commit is contained in:
Robin Fernandes
2026-05-29 02:24:48 -07:00
committed by kshitij
parent a87f0a82a5
commit 41ff6e5937
17 changed files with 484 additions and 860 deletions
+114 -456
View File
@@ -9,14 +9,11 @@ Architecture:
- ProviderConfig registry defines known OAuth providers
- Auth store (auth.json) holds per-provider credential state
- resolve_provider() picks the active provider via priority chain
- resolve_*_runtime_credentials() handles token refresh and key minting
- resolve_*_runtime_credentials() handles token refresh and runtime keys
- logout_command() is the CLI entry point for clearing auth
Nous authentication paths:
- Invoke JWT (preferred): use a scoped access_token directly for inference.
- Legacy session key (fallback): mint an opaque 24h key when JWT auth is
unavailable, or when HERMES_AGENT_USE_LEGACY_SESSION_KEYS is set for
debugging or rollback.
"""
from __future__ import annotations
@@ -73,22 +70,16 @@ AUTH_LOCK_TIMEOUT_SECONDS = 15.0
DEFAULT_NOUS_PORTAL_URL = "https://portal.nousresearch.com"
DEFAULT_NOUS_INFERENCE_URL = "https://inference-api.nousresearch.com/v1"
DEFAULT_NOUS_CLIENT_ID = "hermes-cli"
NOUS_LEGACY_AGENT_KEY_SCOPE = "inference:mint_agent_key"
NOUS_INFERENCE_INVOKE_SCOPE = "inference:invoke"
DEFAULT_NOUS_SCOPE = f"{NOUS_INFERENCE_INVOKE_SCOPE} {NOUS_LEGACY_AGENT_KEY_SCOPE}"
NOUS_LEGACY_SESSION_KEYS_ENV = "HERMES_AGENT_USE_LEGACY_SESSION_KEYS"
DEFAULT_NOUS_SCOPE = NOUS_INFERENCE_INVOKE_SCOPE
NOUS_DEVICE_CODE_SOURCE = "device_code"
NOUS_INFERENCE_AUTH_MODE_AUTO = "auto"
NOUS_INFERENCE_AUTH_MODE_FRESH = "fresh"
NOUS_INFERENCE_AUTH_MODE_LEGACY = "legacy"
NOUS_INFERENCE_AUTH_MODES = frozenset({
NOUS_INFERENCE_AUTH_MODE_AUTO,
NOUS_INFERENCE_AUTH_MODE_FRESH,
NOUS_INFERENCE_AUTH_MODE_LEGACY,
})
NOUS_AUTH_PATH_INVOKE_JWT = "invoke_jwt"
NOUS_AUTH_PATH_LEGACY_SESSION_KEY_CACHE = "legacy_session_key_cache"
NOUS_AUTH_PATH_LEGACY_SESSION_KEY_MINT = "legacy_session_key_mint"
DEFAULT_AGENT_KEY_MIN_TTL_SECONDS = 30 * 60 # 30 minutes
ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 # refresh 2 min before expiry
NOUS_INVOKE_JWT_MIN_TTL_SECONDS = ACCESS_TOKEN_REFRESH_SKEW_SECONDS
@@ -1653,12 +1644,11 @@ def _optional_base_url(value: Any) -> Optional[str]:
return cleaned if cleaned else None
# Allowlist of hosts the Nous Portal proxy is willing to forward minted
# bearer tokens to. The bearer is a long-lived agent_key minted by
# portal.nousresearch.com — sending it anywhere else would leak it.
# Allowlist of hosts the Nous Portal proxy is willing to forward inference
# JWTs to. Sending a bearer anywhere else would leak it.
#
# This is consulted only for URLs coming from the NETWORK side (Portal
# refresh / agent-key-mint responses). User-controlled env-var overrides
# refresh responses). User-controlled env-var overrides
# (NOUS_INFERENCE_BASE_URL) bypass validation — that's the documented
# dev/staging escape hatch and the env source is already trusted (the
# user set it themselves).
@@ -1676,10 +1666,10 @@ def _validate_nous_inference_url_from_network(url: Optional[str]) -> Optional[st
unexpected host letting the caller fall back to the configured
default rather than persist or forward a poisoned value.
Defense-in-depth: a compromised refresh / mint response from the
Portal API (MITM, malicious response injection) could otherwise
redirect every subsequent proxy request bearing the user's
legitimately-minted agent_key to an attacker-controlled endpoint.
Defense-in-depth: a compromised refresh response from the Portal API
(MITM, malicious response injection) could otherwise redirect every
subsequent proxy request bearing the user's inference JWT — to an
attacker-controlled endpoint.
Validating scheme + host at the source closes that loop before the
poisoned URL ever lands in ``auth.json``.
@@ -1743,14 +1733,6 @@ def _scope_values(raw_scope: Any) -> set[str]:
return scopes
def _nous_legacy_session_keys_forced() -> bool:
return is_truthy_value(os.getenv(NOUS_LEGACY_SESSION_KEYS_ENV), default=False)
def _nous_scope_has_invoke(raw_scope: Any) -> bool:
return NOUS_INFERENCE_INVOKE_SCOPE in _scope_values(raw_scope)
def _normalize_nous_inference_auth_mode(inference_auth_mode: Optional[str]) -> str:
mode = str(inference_auth_mode or NOUS_INFERENCE_AUTH_MODE_AUTO).strip().lower()
if mode not in NOUS_INFERENCE_AUTH_MODES:
@@ -1809,23 +1791,6 @@ def _nous_invoke_jwt_is_usable(
)
def _nous_legacy_session_key_reason(
token: Any,
*,
scope: Any = None,
expires_at: Any = None,
inference_auth_mode: str = NOUS_INFERENCE_AUTH_MODE_AUTO,
) -> str:
if inference_auth_mode == NOUS_INFERENCE_AUTH_MODE_LEGACY:
return "forced_legacy_session_key"
if _nous_legacy_session_keys_forced():
return "forced_legacy_session_keys"
return (
_nous_invoke_jwt_status(token, scope=scope, expires_at=expires_at)
or "invoke_jwt_unavailable"
)
def _choose_nous_inference_auth_path(
state: Dict[str, Any],
*,
@@ -1833,34 +1798,29 @@ def _choose_nous_inference_auth_path(
min_key_ttl_seconds: int = DEFAULT_AGENT_KEY_MIN_TTL_SECONDS,
inference_auth_mode: str = NOUS_INFERENCE_AUTH_MODE_AUTO,
) -> Tuple[str, Optional[str]]:
inference_auth_mode = _normalize_nous_inference_auth_mode(inference_auth_mode)
del min_key_ttl_seconds
_normalize_nous_inference_auth_mode(inference_auth_mode)
token = state.get("access_token") if access_token is None else access_token
if (
not _nous_legacy_session_keys_forced()
and inference_auth_mode != NOUS_INFERENCE_AUTH_MODE_LEGACY
and _nous_invoke_jwt_is_usable(
token,
scope=state.get("scope"),
expires_at=state.get("expires_at"),
)
if _nous_invoke_jwt_is_usable(
token,
scope=state.get("scope"),
expires_at=state.get("expires_at"),
):
return NOUS_AUTH_PATH_INVOKE_JWT, None
if (
inference_auth_mode == NOUS_INFERENCE_AUTH_MODE_AUTO
and _agent_key_is_usable(
state,
max(60, int(min_key_ttl_seconds)),
)
):
return NOUS_AUTH_PATH_LEGACY_SESSION_KEY_CACHE, None
return (
NOUS_AUTH_PATH_LEGACY_SESSION_KEY_MINT,
_nous_legacy_session_key_reason(
reason = (
_nous_invoke_jwt_status(
token,
scope=state.get("scope"),
expires_at=state.get("expires_at"),
inference_auth_mode=inference_auth_mode,
),
)
or "invoke_jwt_unavailable"
)
raise AuthError(
"Nous Portal access token is not a usable inference JWT "
f"({reason}). Re-authenticate with: hermes auth add nous",
provider="nous",
code=reason,
relogin_required=True,
)
@@ -1877,24 +1837,6 @@ def _log_nous_invoke_jwt_selected(
)
def _log_nous_legacy_session_key_selected(
reason: str,
*,
access_token: Any,
sequence_id: Optional[str] = None,
) -> None:
logger.info(
"Nous inference auth: using legacy session key path (%s)",
reason,
)
_oauth_trace(
"nous_legacy_session_key_selected",
sequence_id=sequence_id,
reason=reason,
access_token_fp=_token_fingerprint(access_token),
)
def _nous_jwt_expires_at(token: Any, fallback_expires_at: Any = None) -> Optional[str]:
claims = _decode_jwt_claims(token)
exp = claims.get("exp")
@@ -4304,85 +4246,6 @@ def _request_device_code(
return data
def _is_nous_invoke_scope_refusal(exc: Exception) -> bool:
if not isinstance(exc, httpx.HTTPStatusError):
return False
response = exc.response
if response.status_code not in {400, 401, 403}:
return False
try:
payload = response.json()
except Exception:
payload = {}
text = " ".join(
str(value)
for value in (
payload.get("error") if isinstance(payload, dict) else None,
payload.get("error_description") if isinstance(payload, dict) else None,
response.text,
)
if value
).lower()
if not text:
return False
return (
"invalid_scope" in text
or "unsupported_scope" in text
or "scope" in text and NOUS_INFERENCE_INVOKE_SCOPE in text
)
def _nous_device_scope_with_env_override(
requested_scope: Optional[str],
*,
default_scope: str = DEFAULT_NOUS_SCOPE,
) -> Tuple[str, bool]:
explicit_scope = requested_scope is not None
scope = requested_scope or default_scope
if _nous_legacy_session_keys_forced():
scope = NOUS_LEGACY_AGENT_KEY_SCOPE
return scope, explicit_scope
def _request_nous_device_code_with_scope_fallback(
*,
client: httpx.Client,
portal_base_url: str,
client_id: str,
scope: str,
allow_legacy_fallback: bool,
) -> Tuple[Dict[str, Any], str]:
try:
return (
_request_device_code(
client=client,
portal_base_url=portal_base_url,
client_id=client_id,
scope=scope,
),
scope,
)
except Exception as exc:
if (
allow_legacy_fallback
and _nous_scope_has_invoke(scope)
and _is_nous_invoke_scope_refusal(exc)
):
logger.info("Nous inference auth: NAS refused invoke scope, retrying legacy scope")
_oauth_trace("nous_device_code_invoke_scope_refused")
retry_scope = NOUS_LEGACY_AGENT_KEY_SCOPE
return (
_request_device_code(
client=client,
portal_base_url=portal_base_url,
client_id=client_id,
scope=retry_scope,
),
retry_scope,
)
raise
def _poll_for_token(
client: httpx.Client,
portal_base_url: str,
@@ -4433,7 +4296,7 @@ def _poll_for_token(
# =============================================================================
# Nous Portal — token refresh, agent key minting, model discovery
# Nous Portal — token refresh and model discovery
# =============================================================================
# -----------------------------------------------------------------------------
@@ -4512,9 +4375,9 @@ def _nous_shared_store_lock(timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS):
to be held, acquire ``_auth_store_lock`` FIRST. All runtime refresh
paths follow this order. The one exception is
``_try_import_shared_nous_state``, which holds this lock alone for
the entire refresh+mint cycle so concurrent imports on sibling
profiles can't race on the single-use shared refresh token; that
helper must NOT be called with ``_auth_store_lock`` already held.
the entire refresh cycle so concurrent imports on sibling profiles
can't race on the single-use shared refresh token; that helper must
NOT be called with ``_auth_store_lock`` already held.
"""
try:
lock_path = _nous_shared_store_path().with_suffix(".lock")
@@ -4574,9 +4437,8 @@ def _write_shared_nous_state(state: Dict[str, Any]) -> None:
is a convenience layer; the per-profile auth.json remains the source
of truth.
We deliberately omit the runtime ``agent_key`` compatibility field
(either an invoke JWT or legacy opaque session key) only OAuth tokens
are cross-profile useful.
We deliberately omit the runtime ``agent_key`` compatibility field;
the OAuth tokens are the cross-profile source of truth.
"""
refresh_token = state.get("refresh_token")
access_token = state.get("access_token")
@@ -4802,9 +4664,9 @@ def _try_import_shared_nous_state(
) -> Optional[Dict[str, Any]]:
"""Attempt to rehydrate Nous OAuth state from the shared store.
Reads the shared file (if present), runs a forced refresh+mint using
the stored refresh_token to produce a fresh access_token + agent_key
scoped to this profile, and returns the full auth_state dict ready
Reads the shared file (if present), runs a forced refresh using the
stored refresh_token to produce a fresh inference JWT scoped to this
profile, and returns the full auth_state dict ready
for ``persist_nous_credentials()``.
Returns ``None`` when no shared state is available or the rehydrate
@@ -4820,7 +4682,7 @@ def _try_import_shared_nous_state(
# Build a full state dict so refresh_nous_oauth_from_state has every
# field it needs. force_refresh=True gets us a fresh access_token
# for this profile; fresh auth mode avoids stale cached legacy keys.
# for this profile.
state: Dict[str, Any] = {
"access_token": shared.get("access_token"),
"refresh_token": shared.get("refresh_token"),
@@ -4927,39 +4789,6 @@ def _refresh_access_token(
raise AuthError(description, provider="nous", code=code, relogin_required=relogin)
def _mint_agent_key(
*,
client: httpx.Client,
portal_base_url: str,
access_token: str,
min_ttl_seconds: int,
) -> Dict[str, Any]:
"""Mint (or reuse) a short-lived inference API key."""
response = client.post(
f"{portal_base_url}/api/oauth/agent-key",
headers={"Authorization": f"Bearer {access_token}"},
json={"min_ttl_seconds": max(60, int(min_ttl_seconds))},
)
if response.status_code == 200:
payload = response.json()
if "api_key" not in payload:
raise AuthError("Mint response missing api_key",
provider="nous", code="server_error")
return payload
try:
error_payload = response.json()
except Exception as exc:
raise AuthError("Agent key mint request failed",
provider="nous", code="server_error") from exc
code = str(error_payload.get("error", "server_error"))
description = str(error_payload.get("error_description") or "Agent key mint request failed")
relogin = code in {"invalid_token", "invalid_grant"}
raise AuthError(description, provider="nous", code=code, relogin_required=relogin)
def fetch_nous_models(
*,
inference_base_url: str,
@@ -5021,15 +4850,12 @@ def _agent_key_is_usable(state: Dict[str, Any], min_ttl_seconds: int) -> bool:
key = state.get("agent_key")
if not isinstance(key, str) or not key.strip():
return False
if _decode_jwt_claims(key):
if _nous_legacy_session_keys_forced():
return False
return _nous_invoke_jwt_is_usable(
key,
scope=state.get("scope"),
expires_at=state.get("agent_key_expires_at"),
)
return not _is_expiring(state.get("agent_key_expires_at"), min_ttl_seconds)
return _nous_invoke_jwt_is_usable(
key,
scope=state.get("scope"),
expires_at=state.get("agent_key_expires_at"),
min_ttl_seconds=max(0, int(min_ttl_seconds)),
)
def resolve_nous_access_token(
@@ -5160,11 +4986,11 @@ def refresh_nous_oauth_pure(
) -> Dict[str, Any]:
"""Refresh Nous OAuth state without mutating auth.json directly.
``on_state_update`` is called after a successful access-token refresh and
before any subsequent agent-key mint. Callers that own persistent state can
use it to save the newly rotated refresh token before later work can fail.
``on_state_update`` is called after a successful access-token refresh.
Callers that own persistent state can use it to save the newly rotated
refresh token before later validation can fail.
"""
inference_auth_mode = _normalize_nous_inference_auth_mode(inference_auth_mode)
_normalize_nous_inference_auth_mode(inference_auth_mode)
state: Dict[str, Any] = {
"access_token": access_token,
"refresh_token": refresh_token,
@@ -5186,33 +5012,39 @@ def refresh_nous_oauth_pure(
timeout = httpx.Timeout(timeout_seconds if timeout_seconds else 15.0)
with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}, verify=verify) as client:
min_agent_key_ttl = max(60, int(min_key_ttl_seconds))
legacy_session_keys = _nous_legacy_session_keys_forced()
current_invoke_jwt_usable = (
not legacy_session_keys
and _nous_invoke_jwt_is_usable(
state.get("access_token"),
scope=state.get("scope"),
expires_at=state.get("expires_at"),
)
del min_key_ttl_seconds
current_invoke_jwt_status = _nous_invoke_jwt_status(
state.get("access_token"),
scope=state.get("scope"),
expires_at=state.get("expires_at"),
)
if (
force_refresh
or (
_is_expiring(state.get("expires_at"), ACCESS_TOKEN_REFRESH_SKEW_SECONDS)
and not current_invoke_jwt_usable
)
):
if force_refresh or current_invoke_jwt_status is not None:
refresh_token_value = state.get("refresh_token")
if not isinstance(refresh_token_value, str) or not refresh_token_value:
if current_invoke_jwt_status is not None:
raise AuthError(
"Nous Portal access token is not a usable inference JWT "
f"({current_invoke_jwt_status}) and no refresh token is available. "
"Re-authenticate with: hermes auth add nous",
provider="nous",
code=current_invoke_jwt_status,
relogin_required=True,
)
raise AuthError(
"No refresh token is available for Nous Portal.",
provider="nous",
relogin_required=True,
)
refreshed = _refresh_access_token(
client=client,
portal_base_url=state["portal_base_url"],
client_id=state["client_id"],
refresh_token=state["refresh_token"],
refresh_token=refresh_token_value,
)
now = datetime.now(timezone.utc)
access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in"))
state["access_token"] = refreshed["access_token"]
state["refresh_token"] = refreshed.get("refresh_token") or state["refresh_token"]
state["refresh_token"] = refreshed.get("refresh_token") or refresh_token_value
state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer"
state["scope"] = refreshed.get("scope") or state.get("scope")
refreshed_url = _validate_nous_inference_url_from_network(refreshed.get("inference_base_url"))
@@ -5226,34 +5058,12 @@ def refresh_nous_oauth_pure(
if on_state_update is not None:
on_state_update(dict(state), "post_refresh_access_token")
selected_auth_path, fallback_reason = _choose_nous_inference_auth_path(
selected_auth_path, _ = _choose_nous_inference_auth_path(
state,
min_key_ttl_seconds=min_agent_key_ttl,
inference_auth_mode=inference_auth_mode,
)
if selected_auth_path == NOUS_AUTH_PATH_INVOKE_JWT:
_select_nous_invoke_jwt(state)
elif selected_auth_path == NOUS_AUTH_PATH_LEGACY_SESSION_KEY_MINT:
_log_nous_legacy_session_key_selected(
fallback_reason or "legacy_session_key_required",
access_token=state.get("access_token"),
)
mint_payload = _mint_agent_key(
client=client,
portal_base_url=state["portal_base_url"],
access_token=state["access_token"],
min_ttl_seconds=min_key_ttl_seconds,
)
now = datetime.now(timezone.utc)
state["agent_key"] = mint_payload.get("api_key")
state["agent_key_id"] = mint_payload.get("key_id")
state["agent_key_expires_at"] = mint_payload.get("expires_at")
state["agent_key_expires_in"] = mint_payload.get("expires_in")
state["agent_key_reused"] = bool(mint_payload.get("reused", False))
state["agent_key_obtained_at"] = now.isoformat()
minted_url = _validate_nous_inference_url_from_network(mint_payload.get("inference_base_url"))
if minted_url:
state["inference_base_url"] = minted_url
return state
@@ -5296,7 +5106,7 @@ def persist_nous_credentials(
*,
label: Optional[str] = None,
):
"""Persist minted Nous OAuth credentials as the singleton provider state
"""Persist Nous OAuth credentials as the singleton provider state
and ensure the credential pool is in sync.
Nous credentials are read at runtime from two independent locations:
@@ -5307,7 +5117,7 @@ def persist_nous_credentials(
- ``credential_pool.nous``: used by the runtime ``pool.select()`` path.
Historically ``hermes auth add nous`` wrote a ``manual:device_code`` pool
entry only, skipping ``providers.nous``. When the 24h agent_key TTL
entry only, skipping ``providers.nous``. When the runtime credential
expired, the recovery path read the empty singleton state and raised
``AuthError`` silently (``logger.debug`` at INFO level).
@@ -5367,16 +5177,16 @@ def resolve_nous_runtime_credentials(
insecure: Optional[bool] = None,
ca_bundle: Optional[str] = None,
inference_auth_mode: str = NOUS_INFERENCE_AUTH_MODE_AUTO,
force_refresh: bool = False,
) -> Dict[str, Any]:
"""
Resolve Nous inference credentials for runtime use.
Ensures access_token is valid (refreshes if needed) and a short-lived
inference key is present with minimum TTL (mints/reuses as needed).
Concurrent processes coordinate through the auth store file lock.
Ensures access_token is a valid inference-scoped JWT, refreshing it when
needed. Concurrent processes coordinate through the auth store file lock.
Returns dict with: provider, base_url, api_key, key_id, expires_at,
expires_in, source ("invoke_jwt", "cache", or "portal"), and auth_path.
expires_in, source ("invoke_jwt"), and auth_path.
"""
inference_auth_mode = _normalize_nous_inference_auth_mode(inference_auth_mode)
min_key_ttl_seconds = max(60, int(min_key_ttl_seconds))
@@ -5456,6 +5266,7 @@ def resolve_nous_runtime_credentials(
refresh_token_fp=_token_fingerprint(state.get("refresh_token")),
)
selected_auth_path = NOUS_AUTH_PATH_INVOKE_JWT
with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}, verify=verify) as client:
access_token = state.get("access_token")
refresh_token = state.get("refresh_token")
@@ -5464,43 +5275,40 @@ def resolve_nous_runtime_credentials(
raise AuthError("No access token found for Nous Portal login.",
provider="nous", relogin_required=True)
# Step 1: refresh access token if expiring. If the access token
# is already a valid invoke JWT, trust its own exp claim even when
# older auth.json metadata has a stale/missing expires_at.
current_invoke_jwt_usable = (
not _nous_legacy_session_keys_forced()
and _nous_invoke_jwt_is_usable(
access_token,
scope=state.get("scope"),
expires_at=state.get("expires_at"),
)
invoke_jwt_status = _nous_invoke_jwt_status(
access_token,
scope=state.get("scope"),
expires_at=state.get("expires_at"),
)
if (
_is_expiring(state.get("expires_at"), ACCESS_TOKEN_REFRESH_SKEW_SECONDS)
and not current_invoke_jwt_usable
):
if force_refresh or invoke_jwt_status is not None:
with _nous_shared_store_lock(timeout_seconds=max(timeout_seconds + 5.0, AUTH_LOCK_TIMEOUT_SECONDS)):
if _merge_shared_nous_oauth_state(state):
access_token = state.get("access_token")
refresh_token = state.get("refresh_token")
_persist_state("post_shared_merge_access_expiring")
if (
_is_expiring(state.get("expires_at"), ACCESS_TOKEN_REFRESH_SKEW_SECONDS)
and not _nous_invoke_jwt_is_usable(
invoke_jwt_status = _nous_invoke_jwt_status(
access_token,
scope=state.get("scope"),
expires_at=state.get("expires_at"),
)
):
if not isinstance(refresh_token, str) or not refresh_token:
raise AuthError("Session expired and no refresh token is available.",
provider="nous", relogin_required=True)
_persist_state("post_shared_merge_access_unusable")
if force_refresh or invoke_jwt_status is not None:
if not isinstance(refresh_token, str) or not refresh_token:
reason = invoke_jwt_status or "force_refresh"
raise AuthError(
"Nous Portal access token is not a usable inference JWT "
f"({reason}) and no refresh token is available. "
"Re-authenticate with: hermes auth add nous",
provider="nous",
code=reason,
relogin_required=True,
)
refresh_reason = "force_refresh" if force_refresh else (invoke_jwt_status or "access_unusable")
_oauth_trace(
"refresh_start",
sequence_id=sequence_id,
reason="access_expiring",
reason=refresh_reason,
refresh_token_fp=_token_fingerprint(refresh_token),
)
try:
@@ -5542,166 +5350,25 @@ def resolve_nous_runtime_credentials(
_oauth_trace(
"refresh_success",
sequence_id=sequence_id,
reason="access_expiring",
reason=refresh_reason,
previous_refresh_token_fp=_token_fingerprint(previous_refresh_token),
new_refresh_token_fp=_token_fingerprint(refresh_token),
)
# Persist immediately so downstream mint failures cannot drop rotated refresh tokens.
_persist_state("post_refresh_access_expiring")
# Persist immediately so validation failures cannot drop rotated refresh tokens.
_persist_state("post_refresh_access_token")
# Step 2: resolve the compatibility ``agent_key`` field. Preferred
# path stores the NAS invoke JWT there; legacy path mints/reuses
# the opaque session key.
used_cached_key = False
mint_payload: Optional[Dict[str, Any]] = None
selected_auth_path, fallback_reason = _choose_nous_inference_auth_path(
selected_auth_path, _ = _choose_nous_inference_auth_path(
state,
access_token=access_token,
min_key_ttl_seconds=min_key_ttl_seconds,
inference_auth_mode=inference_auth_mode,
)
_select_nous_invoke_jwt(
state,
access_token=access_token,
sequence_id=sequence_id,
)
if selected_auth_path == NOUS_AUTH_PATH_INVOKE_JWT:
_select_nous_invoke_jwt(
state,
access_token=access_token,
sequence_id=sequence_id,
)
elif selected_auth_path == NOUS_AUTH_PATH_LEGACY_SESSION_KEY_CACHE:
used_cached_key = True
logger.info("Nous inference auth: using cached agent_key")
_oauth_trace("agent_key_reuse", sequence_id=sequence_id)
else:
_log_nous_legacy_session_key_selected(
fallback_reason or "legacy_session_key_required",
access_token=access_token,
sequence_id=sequence_id,
)
try:
_oauth_trace(
"mint_start",
sequence_id=sequence_id,
access_token_fp=_token_fingerprint(access_token),
)
mint_payload = _mint_agent_key(
client=client, portal_base_url=portal_base_url,
access_token=access_token, min_ttl_seconds=min_key_ttl_seconds,
)
except AuthError as exc:
_oauth_trace(
"mint_error",
sequence_id=sequence_id,
code=exc.code,
)
# Retry path: access token may be stale server-side despite local checks
latest_refresh_token = state.get("refresh_token")
if (
exc.code in {"invalid_token", "invalid_grant"}
and isinstance(latest_refresh_token, str)
and latest_refresh_token
):
with _nous_shared_store_lock(timeout_seconds=max(timeout_seconds + 5.0, AUTH_LOCK_TIMEOUT_SECONDS)):
if _merge_shared_nous_oauth_state(state):
access_token = state.get("access_token")
latest_refresh_token = state.get("refresh_token")
_persist_state("post_shared_merge_mint_retry")
else:
_oauth_trace(
"refresh_start",
sequence_id=sequence_id,
reason="mint_retry_after_invalid_token",
refresh_token_fp=_token_fingerprint(latest_refresh_token),
)
try:
refreshed = _refresh_access_token(
client=client, portal_base_url=portal_base_url,
client_id=client_id, refresh_token=latest_refresh_token,
)
except AuthError as exc:
if _is_terminal_nous_refresh_error(exc):
_quarantine_nous_oauth_state(
state,
exc,
reason="runtime_mint_retry_refresh_failure",
)
_quarantine_nous_pool_entries(
auth_store,
exc,
reason="runtime_mint_retry_refresh_failure",
)
_persist_state("terminal_runtime_mint_retry_refresh_failure")
raise
now = datetime.now(timezone.utc)
access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in"))
state["access_token"] = refreshed["access_token"]
state["refresh_token"] = refreshed.get("refresh_token") or latest_refresh_token
state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer"
state["scope"] = refreshed.get("scope") or state.get("scope")
refreshed_url = _validate_nous_inference_url_from_network(refreshed.get("inference_base_url"))
if refreshed_url:
inference_base_url = refreshed_url
state["obtained_at"] = now.isoformat()
state["expires_in"] = access_ttl
state["expires_at"] = datetime.fromtimestamp(
now.timestamp() + access_ttl, tz=timezone.utc
).isoformat()
access_token = state["access_token"]
refresh_token = state["refresh_token"]
_oauth_trace(
"refresh_success",
sequence_id=sequence_id,
reason="mint_retry_after_invalid_token",
previous_refresh_token_fp=_token_fingerprint(latest_refresh_token),
new_refresh_token_fp=_token_fingerprint(refresh_token),
)
# Persist retry refresh immediately for crash safety and cross-process visibility.
_persist_state("post_refresh_mint_retry")
retry_inference_auth_mode = (
NOUS_INFERENCE_AUTH_MODE_LEGACY
if inference_auth_mode == NOUS_INFERENCE_AUTH_MODE_LEGACY
else NOUS_INFERENCE_AUTH_MODE_FRESH
)
retry_auth_path, _ = _choose_nous_inference_auth_path(
state,
access_token=access_token,
min_key_ttl_seconds=min_key_ttl_seconds,
inference_auth_mode=retry_inference_auth_mode,
)
if retry_auth_path == NOUS_AUTH_PATH_INVOKE_JWT:
mint_payload = None
selected_auth_path = NOUS_AUTH_PATH_INVOKE_JWT
_select_nous_invoke_jwt(
state,
access_token=access_token,
sequence_id=sequence_id,
)
else:
mint_payload = _mint_agent_key(
client=client, portal_base_url=portal_base_url,
access_token=access_token, min_ttl_seconds=min_key_ttl_seconds,
)
else:
raise
if mint_payload is not None:
now = datetime.now(timezone.utc)
state["agent_key"] = mint_payload.get("api_key")
state["agent_key_id"] = mint_payload.get("key_id")
state["agent_key_expires_at"] = mint_payload.get("expires_at")
state["agent_key_expires_in"] = mint_payload.get("expires_in")
state["agent_key_reused"] = bool(mint_payload.get("reused", False))
state["agent_key_obtained_at"] = now.isoformat()
minted_url = _validate_nous_inference_url_from_network(mint_payload.get("inference_base_url"))
if minted_url:
inference_base_url = minted_url
_oauth_trace(
"mint_success",
sequence_id=sequence_id,
reused=bool(mint_payload.get("reused", False)),
)
# Persist routing and TLS metadata for non-interactive refresh/mint
# Persist routing and TLS metadata for non-interactive refresh.
state["portal_base_url"] = portal_base_url
state["inference_base_url"] = inference_base_url
state["client_id"] = client_id
@@ -5735,11 +5402,7 @@ def resolve_nous_runtime_credentials(
"key_id": state.get("agent_key_id"),
"expires_at": expires_at,
"expires_in": expires_in,
"source": (
NOUS_AUTH_PATH_INVOKE_JWT
if selected_auth_path == NOUS_AUTH_PATH_INVOKE_JWT
else ("cache" if used_cached_key else "portal")
),
"source": NOUS_AUTH_PATH_INVOKE_JWT,
"auth_path": selected_auth_path,
}
@@ -5765,8 +5428,7 @@ def _snapshot_nous_pool_status() -> Dict[str, Any]:
"""Best-effort status from the credential pool.
This is a fallback only. The auth-store provider state is the runtime source
of truth because it is what ``resolve_nous_runtime_credentials()`` refreshes
and mints against.
of truth because it is what ``resolve_nous_runtime_credentials()`` refreshes.
"""
try:
from agent.credential_pool import load_pool
@@ -5858,7 +5520,7 @@ def get_nous_auth_status() -> Dict[str, Any]:
"""Status snapshot for Nous auth.
Prefer the auth-store provider state, because that is the live source of
truth for refresh + mint operations. When provider state exists, validate it
truth for refresh operations. When provider state exists, validate it
by resolving runtime credentials so revoked refresh sessions do not show up
as a healthy login. If provider state is absent, fall back to the credential
pool for the just-logged-in / not-yet-promoted case.
@@ -7719,10 +7381,7 @@ def _nous_device_code_login(
or pconfig.inference_base_url
).rstrip("/")
client_id = client_id or pconfig.client_id
scope, explicit_scope = _nous_device_scope_with_env_override(
scope,
default_scope=pconfig.scope,
)
scope = scope or pconfig.scope
timeout = httpx.Timeout(timeout_seconds)
verify: bool | str = False if insecure else (ca_bundle if ca_bundle else True)
@@ -7737,12 +7396,11 @@ def _nous_device_code_login(
print(f"TLS verification: custom CA bundle ({ca_bundle})")
with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}, verify=verify) as client:
device_data, scope = _request_nous_device_code_with_scope_fallback(
device_data = _request_device_code(
client=client,
portal_base_url=portal_base_url,
client_id=client_id,
scope=scope,
allow_legacy_fallback=not explicit_scope,
)
verification_url = str(device_data["verification_uri_complete"])