From a4984856319bea8464520236ad60be210d6749f0 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 23 May 2026 15:06:03 +1000 Subject: [PATCH] feat(dashboard-auth-nous): surface token iss/aud in verification-failure error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When jwt.decode raises InvalidTokenError, decode the token a second time without signature verification (safe — we never trust the values, just display them) and append the actual iss/aud claims plus our configured expected values to the error message. Lets operators see config drift between HERMES_DASHBOARD_PORTAL_URL / HERMES_DASHBOARD_OAUTH_CLIENT_ID and what Portal is actually emitting without having to hand-decode the JWT from the browser cookie. --- plugins/dashboard_auth/nous/__init__.py | 22 ++++++++++++++++++- .../dashboard_auth/test_nous_provider.py | 13 +++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) 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.