fix(auth): address Nous JWT fallback review

This commit is contained in:
Robin Fernandes 2026-05-28 19:26:30 +10:00 committed by kshitij
parent 41ff6e5937
commit 7e958dafc2
6 changed files with 90 additions and 47 deletions

View file

@ -204,7 +204,21 @@ class PooledCredential:
if self.provider == "nous":
# Nous stores the runtime inference credential in agent_key for
# compatibility. It must be a NAS invoke JWT.
return str(self.agent_key or self.access_token or "")
for token, expires_at in (
(self.agent_key, self.agent_key_expires_at),
(self.access_token, self.expires_at),
):
if (
isinstance(token, str)
and token.strip()
and auth_mod._nous_invoke_jwt_is_usable(
token,
scope=getattr(self, "scope", None),
expires_at=expires_at,
)
):
return token.strip()
return ""
return str(self.access_token or "")
@property

View file

@ -1791,30 +1791,19 @@ def _nous_invoke_jwt_is_usable(
)
def _choose_nous_inference_auth_path(
def _assert_nous_inference_jwt_usable(
state: Dict[str, Any],
*,
access_token: Any = None,
min_key_ttl_seconds: int = DEFAULT_AGENT_KEY_MIN_TTL_SECONDS,
inference_auth_mode: str = NOUS_INFERENCE_AUTH_MODE_AUTO,
) -> Tuple[str, Optional[str]]:
del min_key_ttl_seconds
_normalize_nous_inference_auth_mode(inference_auth_mode)
) -> None:
token = state.get("access_token") if access_token is None else access_token
if _nous_invoke_jwt_is_usable(
reason = _nous_invoke_jwt_status(
token,
scope=state.get("scope"),
expires_at=state.get("expires_at"),
):
return NOUS_AUTH_PATH_INVOKE_JWT, None
reason = (
_nous_invoke_jwt_status(
token,
scope=state.get("scope"),
expires_at=state.get("expires_at"),
)
or "invoke_jwt_unavailable"
)
if reason is None:
return
raise AuthError(
"Nous Portal access token is not a usable inference JWT "
f"({reason}). Re-authenticate with: hermes auth add nous",
@ -5058,12 +5047,8 @@ def refresh_nous_oauth_pure(
if on_state_update is not None:
on_state_update(dict(state), "post_refresh_access_token")
selected_auth_path, _ = _choose_nous_inference_auth_path(
state,
inference_auth_mode=inference_auth_mode,
)
if selected_auth_path == NOUS_AUTH_PATH_INVOKE_JWT:
_select_nous_invoke_jwt(state)
_assert_nous_inference_jwt_usable(state)
_select_nous_invoke_jwt(state)
return state
@ -5266,7 +5251,6 @@ 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")
@ -5357,10 +5341,9 @@ def resolve_nous_runtime_credentials(
# Persist immediately so validation failures cannot drop rotated refresh tokens.
_persist_state("post_refresh_access_token")
selected_auth_path, _ = _choose_nous_inference_auth_path(
_assert_nous_inference_jwt_usable(
state,
access_token=access_token,
inference_auth_mode=inference_auth_mode,
)
_select_nous_invoke_jwt(
state,
@ -5403,7 +5386,7 @@ def resolve_nous_runtime_credentials(
"expires_at": expires_at,
"expires_in": expires_in,
"source": NOUS_AUTH_PATH_INVOKE_JWT,
"auth_path": selected_auth_path,
"auth_path": NOUS_AUTH_PATH_INVOKE_JWT,
}
@ -5448,7 +5431,7 @@ def _snapshot_nous_pool_status() -> Dict[str, Any]:
return (agent_exp, access_exp, -priority)
entry = max(entries, key=_entry_sort_key)
runtime_key = getattr(entry, "runtime_api_key", None) or getattr(entry, "access_token", "")
runtime_key = getattr(entry, "runtime_api_key", None)
if not runtime_key:
return _empty_nous_auth_status()
access_token = getattr(entry, "access_token", None)

View file

@ -84,10 +84,21 @@ class NousPortalAdapter(UpstreamAdapter):
failed_credential: UpstreamCredential,
status_code: int,
) -> Optional[UpstreamCredential]:
_ = failed_credential, status_code
return None
_ = failed_credential
if status_code != 401:
return None
logger.info("proxy: Nous upstream rejected bearer; force-refreshing invoke JWT")
return self._get_credential(
inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_AUTO,
force_refresh=True,
)
def _get_credential(self, *, inference_auth_mode: str) -> UpstreamCredential:
def _get_credential(
self,
*,
inference_auth_mode: str,
force_refresh: bool = False,
) -> UpstreamCredential:
with self._lock:
state = self._read_state()
if state is None:
@ -98,6 +109,7 @@ class NousPortalAdapter(UpstreamAdapter):
try:
refreshed = resolve_nous_runtime_credentials(
inference_auth_mode=inference_auth_mode,
force_refresh=force_refresh,
)
except AuthError as exc:
if _is_terminal_nous_refresh_error(exc):

View file

@ -1293,6 +1293,29 @@ def test_load_pool_mirrors_nous_invoke_jwt_agent_key_runtime_api_key(tmp_path, m
assert pool_entry["agent_key_expires_at"] == expires_at
def test_nous_runtime_api_key_rejects_opaque_agent_key():
from agent.credential_pool import PooledCredential
entry = PooledCredential(
provider="nous",
id="nous-opaque",
label="opaque",
auth_type="oauth",
priority=0,
source="device_code",
access_token="opaque-access-token",
refresh_token="refresh-token",
agent_key="opaque-agent-key",
agent_key_expires_at=datetime.fromtimestamp(
time.time() + 3600,
tz=timezone.utc,
).isoformat(),
extra={"scope": "inference:invoke"},
)
assert entry.runtime_api_key == ""
def test_nous_pool_terminal_refresh_removes_device_code_entry(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared"))

View file

@ -485,7 +485,7 @@ def test_nous_device_code_login_does_not_retry_legacy_scope_when_invoke_refused(
assert scopes == [auth_mod.DEFAULT_NOUS_SCOPE]
def test_legacy_session_env_is_ignored_for_invoke_scope_and_jwt_storage(tmp_path, monkeypatch):
def test_removed_legacy_session_env_var_does_not_change_jwt_auth(tmp_path, monkeypatch):
import hermes_cli.auth as auth_mod
hermes_home = tmp_path / "hermes"
@ -609,13 +609,16 @@ def test_get_nous_auth_status_checks_credential_pool(tmp_path, monkeypatch):
# Seed the credential pool with a Nous entry
from agent.credential_pool import PooledCredential, load_pool
pool = load_pool("nous")
token = _invoke_jwt(seconds=3600)
expires_at = _future_iso(3600)
entry = PooledCredential.from_dict("nous", {
"access_token": "test-access-token",
"access_token": token,
"refresh_token": "test-refresh-token",
"portal_base_url": "https://portal.example.com",
"inference_base_url": "https://inference.example.com/v1",
"agent_key": "test-agent-key",
"agent_key_expires_at": "2099-01-01T00:00:00+00:00",
"agent_key": token,
"agent_key_expires_at": expires_at,
"scope": "inference:invoke",
"label": "dashboard device_code",
"auth_type": "oauth",
"source": "manual:dashboard_device_code",
@ -628,7 +631,7 @@ def test_get_nous_auth_status_checks_credential_pool(tmp_path, monkeypatch):
assert "example.com" in str(status.get("portal_base_url", ""))
def test_get_nous_auth_status_pool_opaque_key_is_not_portal_login(tmp_path, monkeypatch):
def test_get_nous_auth_status_pool_opaque_key_is_not_inference_credential(tmp_path, monkeypatch):
from hermes_cli.auth import get_nous_auth_status, invalidate_nous_auth_status_cache
hermes_home = tmp_path / "hermes"
@ -656,11 +659,11 @@ def test_get_nous_auth_status_pool_opaque_key_is_not_portal_login(tmp_path, monk
status = get_nous_auth_status()
assert status["logged_in"] is False
assert status["inference_credential_present"] is True
assert status["credential_source"] == "pool:manual opaque key"
assert status["inference_credential_present"] is False
assert status["credential_source"] is None
assert status.get("access_token") is None
assert status.get("portal_base_url") is None
assert status.get("inference_base_url") == "https://inference.example.com/v1"
assert status.get("inference_base_url") is None
invalidate_nous_auth_status_cache()

View file

@ -105,7 +105,7 @@ def test_nous_adapter_authenticated_with_agent_key(tmp_path, monkeypatch):
def test_nous_adapter_authenticated_with_refresh_token_only(tmp_path, monkeypatch):
"""If access_token+refresh_token exist but no agent_key yet, we can still mint."""
"""If access_token+refresh_token exist but no agent_key yet, we can still refresh."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
_write_auth_store(tmp_path, {
"access_token": "access-tok",
@ -125,7 +125,7 @@ def test_nous_adapter_get_credential_uses_runtime_resolver(tmp_path, monkeypatch
})
refreshed_state = {
"api_key": "minted-bearer",
"api_key": "jwt-bearer",
"base_url": "https://inference-api.nousresearch.com/v1",
"expires_at": "2099-01-01T00:00:00Z",
}
@ -138,13 +138,13 @@ def test_nous_adapter_get_credential_uses_runtime_resolver(tmp_path, monkeypatch
cred = adapter.get_credential()
mock_resolve.assert_called_once()
assert cred.bearer == "minted-bearer"
assert cred.bearer == "jwt-bearer"
assert cred.base_url == "https://inference-api.nousresearch.com/v1"
assert cred.expires_at == "2099-01-01T00:00:00Z"
assert cred.token_type == "Bearer"
def test_nous_adapter_retry_credential_does_not_fallback_on_jwt_401(tmp_path, monkeypatch):
def test_nous_adapter_retry_credential_force_refreshes_on_jwt_401(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
_write_auth_store(tmp_path, {
"access_token": "jwt-access",
@ -154,9 +154,15 @@ def test_nous_adapter_retry_credential_does_not_fallback_on_jwt_401(tmp_path, mo
"inference_base_url": "https://inference-api.nousresearch.com/v1",
"agent_key": "jwt-access",
})
refreshed_state = {
"api_key": "fresh-jwt-bearer",
"base_url": "https://inference-api.nousresearch.com/v1",
"expires_at": "2099-01-01T00:00:00Z",
}
with patch(
"hermes_cli.proxy.adapters.nous_portal.resolve_nous_runtime_credentials",
return_value=refreshed_state,
) as mock_resolve:
adapter = NousPortalAdapter()
cred = adapter.get_retry_credential(
@ -167,11 +173,13 @@ def test_nous_adapter_retry_credential_does_not_fallback_on_jwt_401(tmp_path, mo
status_code=401,
)
assert cred is None
mock_resolve.assert_not_called()
assert cred is not None
assert cred.bearer == "fresh-jwt-bearer"
assert mock_resolve.call_args.kwargs["force_refresh"] is True
assert mock_resolve.call_args.kwargs["inference_auth_mode"] == "auto"
def test_nous_adapter_retry_credential_skips_opaque_bearer(tmp_path, monkeypatch):
def test_nous_adapter_retry_credential_skips_non_401(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
_write_auth_store(tmp_path, {
"access_token": "jwt-access",
@ -188,7 +196,7 @@ def test_nous_adapter_retry_credential_skips_opaque_bearer(tmp_path, monkeypatch
bearer="opaque-bearer",
base_url="https://inference-api.nousresearch.com/v1",
),
status_code=401,
status_code=403,
)
assert cred is None