refactor(auth): Disable Nous legacy session key fallback
This commit is contained in:
+114
-456
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user