fix(auth): document introspection-error fall-through, drop misleading userinfo metric

Address claude-review round 4 on #919:
- Functional concern: document that _introspect_token returns None for both an
  active=false response (the cross-client case we must handle) AND a network
  error, so both fall through to userinfo. This is safe — userinfo is itself an
  authoritative live check, so a flapping introspection endpoint can't cause an
  invalid token to be accepted.
- Observability nit: only record a ("userinfo", ...) metric when userinfo was
  actually attempted (userinfo_uri configured); a no-validators-configured
  opaque token now returns None without a misleading userinfo-failure metric.
  Added test_opaque_rejected_when_no_validators_configured.
- Added a comment on the post-validation cache re-read explaining why the entry
  is always present (write-then-read with no await between).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-17 20:18:21 +02:00
co-authored by Claude Opus 4.8
parent bafe82c897
commit ed32519563
2 changed files with 33 additions and 4 deletions
+19 -4
View File
@@ -231,6 +231,10 @@ class UnifiedTokenVerifier(TokenVerifier):
# tokens are stamped with ``_auth_via_userinfo`` in the cache; for them
# we rely on the per-user authorization every management endpoint
# enforces (token sub == requested resource owner).
# Recover the via-userinfo flag from the cache entry. On a cache miss
# this is the entry _verify_without_audience_check just wrote (no await
# between that write and this read, so it is always present); on a cache
# hit it was written by an earlier call.
cached_entry = self._token_cache.get(cache_key)
via_userinfo = bool(cached_entry and cached_entry[0].get("_auth_via_userinfo"))
if via_userinfo:
@@ -393,10 +397,16 @@ class UnifiedTokenVerifier(TokenVerifier):
else:
record_oauth_token_validation("introspect", "invalid")
if payload is None:
# Introspection is unconfigured, or it reported the token
# inactive. Set validation_method first so a userinfo
# exception caught by the outer handler is attributed right.
# Fall through to userinfo when introspection is unconfigured or
# returned None. NOTE: _introspect_token returns None for BOTH an
# active=false response (the nx101294 cross-client case we must
# handle) AND a network/timeout error — both reach userinfo here.
# That is safe: userinfo is itself an authoritative live check (a
# revoked/invalid token gets a 401), so a flapping introspection
# endpoint cannot cause an invalid token to be accepted.
if payload is None and self.userinfo_uri:
# Set validation_method first so a userinfo exception caught
# by the outer handler is attributed correctly.
validation_method = "userinfo"
payload = await self._validate_via_userinfo(token)
if payload:
@@ -405,6 +415,11 @@ class UnifiedTokenVerifier(TokenVerifier):
record_oauth_token_validation("userinfo", "invalid")
return None
if payload is None:
# No validator was configured, or none succeeded. Don't record
# a userinfo failure metric when userinfo was never attempted.
return None
# Check payload is valid
if not payload:
return None
+14
View File
@@ -741,6 +741,20 @@ class TestUserinfoFallback:
assert result.resource == "testuser"
introspect_mock.assert_not_called() # skipped when unconfigured
async def test_opaque_rejected_when_no_validators_configured(self, base_settings):
"""With neither introspection nor userinfo configured, an opaque token is
rejected without recording a misleading userinfo-failure metric."""
base_settings.introspection_uri = None
base_settings.userinfo_uri = None
verifier = UnifiedTokenVerifier(base_settings)
assert verifier.introspection_uri is None
assert verifier.userinfo_uri is None
result = await verifier._verify_without_audience_check(
"opaque-no-validator", "mgmt:none"
)
assert result is None
async def test_mgmt_userinfo_not_called_when_introspection_succeeds(
self, monkeypatch, userinfo_settings
):