Repository navigation
fix(oauth2): authorize token introspection callers - #90
Merged
Merged
Conversation
serveIntrospect authenticated its caller through the shared ClientAuth
chain, then threw the client away: any client the chain accepted could
introspect any token it held. A public client's client_id is no secret
— it ships inside every SPA and mobile app, and RFC 6749 §2.2 forbids
using it alone — yet it was enough to read the validity and claims of a
token, refresh tokens included: through the none method, or through
client_secret_basic / client_secret_post with the client's registered
secret (Basic: even an empty one), whether or not none was registered.
RFC 7662 §2.1 and §4 require the endpoint to authenticate the protected
resources calling it, and RFC 6749 §2.3 forbids relying on a public
client's authentication to identify it.
/introspect now answers only callers whose Type() is
ClientConfidential: a public client, whatever the method, and an
untyped client authenticated through Basic or Post (none already
refused it) get 401 invalid_client before the token is looked up.
/token and /revoke are unchanged, so a public client keeps running
authorization_code + PKCE, refreshing and revoking its own tokens with
none. The none method's description for a non-public client loses its
double quote, which the RFC 6749 §5.2 error_description charset
forbids.
The new ServerConfig.IntrospectionPolicy decides which active tokens
each caller may see, from the caller and an IntrospectedToken (kind,
client, subject, scope, audience, issuance and expiry — never the token
value): the specific authorization RFC 7662 §4 recommends, where
resource and tenant boundaries are enforced, refresh tokens included.
It only runs for active tokens. A denial answers a bare
{"active":false}, the same bytes as an unknown token (RFC 7662 §2.2); a
policy error fails closed the same way and reaches OnError as a
server_error, since a 500 would tell the caller the token is active.
Without a policy, any confidential client may introspect any token, as
before.
This breaks deployments whose applications, or resource servers
registered without a type, call /introspect: CHANGELOG and MIGRATION.md
describe the migration and a typical audience-bound policy. Refs #81.
Coverage Report for CI Build 38008572901Coverage increased (+0.2%) to 92.032%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
1 similar comment
Coverage Report for CI Build 38008572901Coverage increased (+0.2%) to 92.032%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
This was referenced Oct 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #81.
Problem
serveIntrospectran the shared client-authentication chain and threw theauthenticated client away:
It then looked the token up globally and returned its claims. Any caller the
ClientAuthchain accepted could therefore introspect any token it held,refresh tokens included (
client_id,sub,scope,exp,iat):none— a publicclient_idis not a secret: it ships insideevery copy of a SPA or mobile app, and RFC 6749 §2.2 says it "MUST NOT be
used alone for client authentication";
client_secret_basic/client_secret_post— neither methodchecks the client type, so a public client provisioned with a secret got in
too, whether or not
nonewas registered; through Basic even with an emptyone (
DefaultClient.SecretMatches("")is true when no secret isregistered). RFC 6749 §2.3: the authorization server "MUST NOT rely on
public client authentication for the purpose of identifying the client";
Type() == "") authenticating through Basic or Post wereanswered as well.
RFC 7662 §4 says the authorization server "MUST require authentication of
protected resources" calling the endpoint and "SHOULD require protected
resources to be specifically authorized", and "if the token can be used
only at certain resource servers, the authorization server MUST determine
whether or not the token can be used at the resource server making the
introspection call". The endpoint could express neither. The caller still
needs to hold the token (the lookup is by hash of a 256-bit opaque value), so
this is a validity-and-claims oracle for leaked tokens, not an enumeration.
Fix
Endpoint authentication.
/introspectkeeps the shared chain(
authenticateClientis unchanged) and then admits confidential clientsonly:
It is an allowlist, not a
none/public denylist: a public client — whateverthe method — and an untyped client get
401 invalid_clientwithWWW-Authenticate(RFC 7662 §2.3 → RFC 6749 §5.2), before the token islooked up.
/tokenand/revokeare unchanged: a public client keepsrunning authorization_code + PKCE and refreshing and revoking its own tokens
with
none(RFC 7009 §2.1 validates credentials only "in case of aconfidential client").
Token-specific authorization. A new optional
ServerConfig.IntrospectionPolicy:IntrospectedTokenholds exactly the claims the response would disclose —Kind(a typedTokenKind: access or refresh),ClientID,Subject,Scope,Audience,IssuedAt,ExpiresAt— never the token value or itshash.
answers
{"active":false}without it, so a caller cannot make the serverrun policy I/O on random strings), and only once the caller has
authenticated:
caller.Type()is alwaysClientConfidential.OnError401 invalid_clientinvalid_client200, claims — unchanged(true, nil)200, claims(false, nil)200 {"active":false}, the bytes of an unknown token (RFC 7662 §2.2)(_, err)200 {"active":false}— fails closedserver_error"introspection policy failed", cause wrappedDesign notes:
decision): RFC 7662 §4's "specifically authorized" SHOULD and its
conditional MUST are delegated to the policy, and the docs say so. The
issue rules out
caller.ID() == token.ClientIDas a rule — a resourceserver is usually not the client the token was issued to — so the docs show
a tenant and an audience policy instead, and recommend refusing refresh
tokens to resource servers (they carry no audience, and one that ignores
token_typecould accept a refresh token as a bearer token).{"active":false}, not 500: only active tokensreach the policy, so a 500 would tell the caller the token is active.
RFC 7662 §2.2 / §2.3 make "not allowed to introspect" an
active:falseanswer, not an error; the failure is reported through
OnError.the policy runs for active tokens, so latency may differ (documented).
clientauth/none.goanswered a non-public client with"none" reserved to public clients: the"(%x22) is outside theRFC 6749 §5.2
error_descriptioncharset. It now readsnone method reserved to public clients.Docs: godoc (
IntrospectHandler,ServerConfig.ClientAuth/OnError/IntrospectionPolicy, the new types,ErrorHook, the package doc,clientauth.NewNone),CHANGELOG.md(Added, Security),MIGRATION.md(new "Breaking changes before the first v2 release" section),
docs/security-considerations.md,docs/observability.md,docs/architecture.md.Breaking change / migration
/introspectnow answers only callers whoseClient.Type()isoauth2.ClientConfidential:a secret (
client_secret_basic/client_secret_post);oauth2.DefaultClientwithoutTypeValue—must now set
TypeValue: oauth2.ClientConfidentialto keep introspecting;/introspect(useexpires_in/scopefrom the/tokenresponse, or a confidential backend);authorization server should set
ServerConfig.IntrospectionPolicy.Wire output is unchanged for confidential callers when no policy is set. The
API change is additive (one new
ServerConfigfield, new types). SeeMIGRATION.mdfor the before/after table and a policysnippet.
Tests
New
oauth2/introspect_endpoint_test.go(packageoauth2_test, no changeto existing tests). The fixture: a
Profile20BCPserver with a pinned clock,[Basic, Post, None]client authentication, a public SPA, two confidentialresource servers (
rs-api,rs-billing), a public client provisioned with asecret, untyped clients with and without a secret, and access/refresh pairs —
one family each: active, expired, consumed — issued to a third client.
TestIntrospectCallerAuthentication(12 rows) — Basic and Postconfidential callers → 200.
nonepublic, Basic public with an emptypassword, Basic and Post public with its secret, Basic untyped with and
without a secret → 401
introspection requires confidential client authentication.noneunknown,noneconfidential (pins the charset-safedescription), wrong secret, no credentials → 401. Every refusal: a
WWW-Authenticatechallenge, noactive/sub/scope, anerror_descriptionwithin the RFC 6749 §5.2 charset, the policy nevercalled,
invalid_clientonOnError.TestIntrospectDefaultDisclosesToConfidentialResourceServer(pin) — withno policy, a resource server that is not the token's client gets the exact
access and refresh bodies.
TestIntrospectionPolicyInputs— the request context value, theauthenticated caller (the
ClientStorerecord), the exactIntrospectedTokenfor an access and a refresh token (no audience).TestIntrospectionPolicyNotConsultedForInactiveTokens— unknown, expiredaccess, expired refresh, consumed refresh:
{"active":false}, zero policycalls.
TestIntrospectionPolicyDenialDisclosesNothing— access and refresh: samestatus, headers and bytes as an unknown token; nothing on
OnError.TestIntrospectionPolicyEnforcesBoundaries— a tenant policy and anaudience policy against both resource servers, access and refresh tokens
(8 rows).
TestIntrospectionPolicyErrorFailsClosed— access and refresh: samestatus, headers and bytes as an unknown token; two
server_errors onOnError, each wrapping the policy's error.TestPublicClientAuthorizationCodeFlowUnaffected— the SPA runs/authorize(S256) →/tokenwithnone+ verifier → refresh withnone;/introspectas the SPA → 401;rs-apisees the token (client_idspa);/revokewithnone→ the token introspects inactive.Red phase:
undefined: oauth2.IntrospectionPolicy,unknown field IntrospectionPolicy in struct literal, ...).unchanged): the six public/untyped caller rows got 200 with the token's
claims instead of 401; the
none+ confidential row failed the §5.2charset on
"none" reserved to public clients; the policy tests showed thepolicy never consulted — denied tokens, both tenant and audience
boundaries and the failing policy all disclosed
active:truewithsub/scope/client_id, with nothing onOnError; the public-clientflow passed
/authorize,/tokenand the refresh, and failed exactly atthe SPA's
/introspectcall (200 instead of 401). The default-disclosurepin passed, as intended.
Mutation testing on a scratch copy — every mutant fails at least one test:
master(pre-fix)ClientPubliconly)OnErrorOnErrorclient_idTestIntrospectEndpointtoken_typerendered asBearerTestIntrospectEndpointTestIntrospectEndpointcontext.Background()instead of the request contextnonedescription reverted ("inerror_description)none+ confidential rowAn independent adversarial review also re-ran the key tests against the
pre-fix production code and confirmed they fail there.
Checks
make build— OK.make test— every package passes with-race;oauth2coverage91.1% → 92.3%,
oauth2/clientauthstays at 100%. New code is 100% covered(
authenticateIntrospectionCaller,introspect,lookupActiveToken,introspectionAllowed,activeIntrospectResponse); the two lines ofserveIntrospectstill uncovered are the pre-existingParseFormandJSON-encode error branches.
make lint— 0 issues.Follow-ups (out of scope)
client_secret_basicat/token(DefaultClient.SecretMatches("")istrue when no secret is registered).
/introspectare read as "not found" (err == nilchecks on the lookups) and never reach
OnError;lookupActiveTokenisnow the single place to change.
error_descriptioncharset breaches elsewhere(e.g.
grant/authorization_code.go:PKCE method "plain" is refused by the active profile).token_type: "refresh_token"is not an RFC 6749 §5.1 token type(RFC 7662 §2.2); it is kept so a resource server can tell a refresh token
apart and refuse it.
Notes for the maintainer
CLAUDE.mdis not edited here. Proposed addition to the "OAuth2 server"section, after the endpoint list: