diff --git a/plugins/dashboard_auth/nous/__init__.py b/plugins/dashboard_auth/nous/__init__.py index e82aae8a595..e434fbfb054 100644 --- a/plugins/dashboard_auth/nous/__init__.py +++ b/plugins/dashboard_auth/nous/__init__.py @@ -356,8 +356,28 @@ class NousDashboardAuthProvider(DashboardAuthProvider): # verify_session() catches this and returns None per protocol. raise InvalidCodeError(f"access token expired: {exc}") from exc except jwt.InvalidTokenError as exc: + # Surface the actual claim values that failed verification so + # operators don't have to dig into the JWT to debug config drift + # between HERMES_DASHBOARD_PORTAL_URL / HERMES_DASHBOARD_OAUTH_CLIENT_ID + # and what Portal is actually emitting. Decoding without verification + # is safe here: we've already failed to verify, and we never trust + # these values — they're surfaced for diagnostics only. + details = "" + try: + unverified = jwt.decode( + access_token, + options={"verify_signature": False, "verify_exp": False}, + ) + details = ( + f" [token iss={unverified.get('iss')!r} " + f"aud={unverified.get('aud')!r}; " + f"expected iss={self._portal_url!r} " + f"aud={self._client_id!r}]" + ) + except Exception: + pass raise ProviderError( - f"access token verification failed: {exc}" + f"access token verification failed: {exc}{details}" ) from exc self._check_agent_instance_id(claims) diff --git a/tests/plugins/dashboard_auth/test_nous_provider.py b/tests/plugins/dashboard_auth/test_nous_provider.py index a022784af05..92806f15fb8 100644 --- a/tests/plugins/dashboard_auth/test_nous_provider.py +++ b/tests/plugins/dashboard_auth/test_nous_provider.py @@ -504,6 +504,19 @@ class TestVerifySession: with pytest.raises(ProviderError, match="verification failed"): provider.verify_session(access_token=token) + def test_verification_failure_message_surfaces_token_claims( + self, provider, rsa_keypair + ): + """Operators need to see the actual iss/aud the token carries to debug + config drift between HERMES_DASHBOARD_PORTAL_URL/CLIENT_ID and Portal.""" + token = _mint_token(rsa_keypair, iss="https://evil.example") + with pytest.raises(ProviderError) as excinfo: + provider.verify_session(access_token=token) + msg = str(excinfo.value) + # Both the observed (token) and expected (configured) values appear. + assert "'https://evil.example'" in msg + assert "'https://portal.example.com'" in msg # configured portal URL + def test_missing_sub_raises(self, provider, rsa_keypair): # PyJWT's "require" set includes sub, so this surfaces as # InvalidTokenError → ProviderError before we ever touch _session_from_claims.