Skip to content

fix(oauth2): authorize token introspection callers - #90

Merged
euskadi31 merged 1 commit into
masterfrom
feature/81-oauth2-introspection-authz
Oct 10, 2026
Merged

euskadi31 merged 1 commit into
masterfrom
feature/81-oauth2-introspection-authz

Conversation

@euskadi31

Copy link
Copy Markdown
Contributor

Closes #81.

Problem

serveIntrospect ran the shared client-authentication chain and threw the
authenticated client away:

if _, err := s.authenticateClient(r.Context(), r); err != nil {

It then looked the token up globally and returned its claims. Any caller the
ClientAuth chain accepted could therefore introspect any token it held,
refresh tokens included (client_id, sub, scope, exp, iat):

  • through none — a public client_id is not a secret: it ships inside
    every copy of a SPA or mobile app, and RFC 6749 §2.2 says it "MUST NOT be
    used alone for client authentication"
    ;
  • through client_secret_basic / client_secret_post — neither method
    checks the client type, so a public client provisioned with a secret got in
    too, whether or not none was registered; through Basic even with an empty
    one (DefaultClient.SecretMatches("") is true when no secret is
    registered). RFC 6749 §2.3: the authorization server "MUST NOT rely on
    public client authentication for the purpose of identifying the client"
    ;
  • untyped clients (Type() == "") authenticating through Basic or Post were
    answered 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. /introspect keeps the shared chain
(authenticateClient is unchanged) and then admits confidential clients
only:

caller, err := s.authenticateClient(ctx, r)
if err != nil {
	return nil, err
}

if caller.Type() != ClientConfidential {
	return nil, ErrInvalidClient.WithDescription(introspectionCallerRefused)
}

It is an allowlist, not a none/public denylist: a public client — whatever
the method — and an untyped client get 401 invalid_client with
WWW-Authenticate (RFC 7662 §2.3 → RFC 6749 §5.2), before the token is
looked up. /token and /revoke are unchanged: a public client keeps
running authorization_code + PKCE and refreshing and revoking its own tokens
with none (RFC 7009 §2.1 validates credentials only "in case of a
confidential client").

Token-specific authorization. A new optional
ServerConfig.IntrospectionPolicy:

type IntrospectionPolicy func(ctx context.Context, caller Client, token IntrospectedToken) (bool, error)
  • IntrospectedToken holds exactly the claims the response would disclose —
    Kind (a typed TokenKind: access or refresh), ClientID, Subject,
    Scope, Audience, IssuedAt, ExpiresAt — never the token value or its
    hash.
  • It runs only for active tokens (an unknown, expired or consumed token
    answers {"active":false} without it, so a caller cannot make the server
    run policy I/O on random strings), and only once the caller has
    authenticated: caller.Type() is always ClientConfidential.
Caller / policy outcome Response OnError
public client (any method), untyped client 401 invalid_client invalid_client
confidential, no policy (default) 200, claims — unchanged —
confidential, policy (true, nil) 200, claims —
confidential, policy (false, nil) 200 {"active":false}, the bytes of an unknown token (RFC 7662 §2.2) — (expected outcome)
confidential, policy (_, err) 200 {"active":false} — fails closed server_error "introspection policy failed", cause wrapped

Design notes:

  • The default stays permissive for confidential callers (maintainer
    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.ClientID as a rule — a resource
    server 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_type could accept a refresh token as a bearer token).
  • A policy error answers {"active":false}, not 500: only active tokens
    reach 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:false
    answer, not an error; the failure is reported through OnError.
  • A withheld token is indistinguishable from an unknown one on the wire only:
    the policy runs for active tokens, so latency may differ (documented).
  • clientauth/none.go answered a non-public client with
    "none" reserved to public clients: the " (%x22) is outside the
    RFC 6749 §5.2 error_description charset. It now reads
    none 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

/introspect now answers only callers whose Client.Type() is
oauth2.ClientConfidential
:

  • resource servers calling it must be registered as confidential clients with
    a secret (client_secret_basic / client_secret_post);
  • untyped clients — e.g. an oauth2.DefaultClient without TypeValue —
    must now set TypeValue: oauth2.ClientConfidential to keep introspecting;
  • public applications must stop calling /introspect (use expires_in /
    scope from the /token response, or a confidential backend);
  • deployments where several resource servers or tenants share the
    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 ServerConfig field, new types). See
MIGRATION.md for the before/after table and a policy
snippet.

Tests

New oauth2/introspect_endpoint_test.go (package oauth2_test, no change
to existing tests). The fixture: a Profile20BCP server with a pinned clock,
[Basic, Post, None] client authentication, a public SPA, two confidential
resource servers (rs-api, rs-billing), a public client provisioned with a
secret, 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 Post
    confidential callers → 200. none public, Basic public with an empty
    password, Basic and Post public with its secret, Basic untyped with and
    without a secret → 401 introspection requires confidential client authentication. none unknown, none confidential (pins the charset-safe
    description), wrong secret, no credentials → 401. Every refusal: a
    WWW-Authenticate challenge, no active/sub/scope, an
    error_description within the RFC 6749 §5.2 charset, the policy never
    called, invalid_client on OnError.
  • TestIntrospectDefaultDisclosesToConfidentialResourceServer (pin) — with
    no policy, a resource server that is not the token's client gets the exact
    access and refresh bodies.
  • TestIntrospectionPolicyInputs — the request context value, the
    authenticated caller (the ClientStore record), the exact
    IntrospectedToken for an access and a refresh token (no audience).
  • TestIntrospectionPolicyNotConsultedForInactiveTokens — unknown, expired
    access, expired refresh, consumed refresh: {"active":false}, zero policy
    calls.
  • TestIntrospectionPolicyDenialDisclosesNothing — access and refresh: same
    status, headers and bytes as an unknown token; nothing on OnError.
  • TestIntrospectionPolicyEnforcesBoundaries — a tenant policy and an
    audience policy against both resource servers, access and refresh tokens
    (8 rows).
  • TestIntrospectionPolicyErrorFailsClosed — access and refresh: same
    status, headers and bytes as an unknown token; two server_errors on
    OnError, each wrapping the policy's error.
  • TestPublicClientAuthorizationCodeFlowUnaffected — the SPA runs
    /authorize (S256) → /token with none + verifier → refresh with none;
    /introspect as the SPA → 401; rs-api sees the token (client_id
    spa); /revoke with none → the token introspects inactive.

Red phase:

  • The whole file first fails to compile (undefined: oauth2.IntrospectionPolicy,
    unknown field IntrospectionPolicy in struct literal, ...).
  • With only the new API stubbed in (types + config field, endpoint
    unchanged): the six public/untyped caller rows got 200 with the token's
    claims
    instead of 401; the none + confidential row failed the §5.2
    charset on "none" reserved to public clients; the policy tests showed the
    policy never consulted — denied tokens, both tenant and audience
    boundaries and the failing policy all disclosed active:true with
    sub/scope/client_id
    , with nothing on OnError; the public-client
    flow passed /authorize, /token and the refresh, and failed exactly at
    the SPA's /introspect call (200 instead of 401). The default-disclosure
    pin passed, as intended.

Mutation testing on a scratch copy — every mutant fails at least one test:

Mutant Caught by
endpoint reverted to master (pre-fix) caller authentication (8 rows), policy inputs, denial, boundaries (5 rows), error, public-client flow
allowlist → denylist (refuse ClientPublic only) caller authentication: both untyped rows
caller type check removed caller authentication (6 rows), public-client flow
policy consulted before the active check not-consulted-for-inactive, denial, error
policy error fails open (returns its bool) error
policy error not reported to OnError error
policy denial reported to OnError denial
denial leaks client_id denial, boundaries (5 rows), error
refresh token handed to the policy as an access token policy inputs, default-disclosure pin, existing TestIntrospectEndpoint
refresh token_type rendered as Bearer default-disclosure pin, existing TestIntrospectEndpoint
consumed refresh token treated as active not-consulted-for-inactive, existing TestIntrospectEndpoint
policy handed context.Background() instead of the request context policy inputs
none description reverted (" in error_description) caller authentication: none + confidential row

An 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; oauth2 coverage
    91.1% → 92.3%, oauth2/clientauth stays at 100%. New code is 100% covered
    (authenticateIntrospectionCaller, introspect, lookupActiveToken,
    introspectionAllowed, activeIntrospectResponse); the two lines of
    serveIntrospect still uncovered are the pre-existing ParseForm and
    JSON-encode error branches.
  • make lint — 0 issues.

Follow-ups (out of scope)

  • An empty registered client secret is still accepted by
    client_secret_basic at /token (DefaultClient.SecretMatches("") is
    true when no secret is registered).
  • Storage errors at /introspect are read as "not found" (err == nil
    checks on the lookups) and never reach OnError; lookupActiveToken is
    now the single place to change.
  • Remaining RFC 6749 §5.2 error_description charset breaches elsewhere
    (e.g. grant/authorization_code.go: PKCE method "plain" is refused by the active profile).
  • The wire 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.md is not edited here. Proposed addition to the "OAuth2 server"
    section, after the endpoint list:

    /introspect answers ClientConfidential callers only (public and
    untyped clients get 401 invalid_client); the optional
    ServerConfig.IntrospectionPolicy decides which active tokens each
    caller may see (resource / tenant boundaries, RFC 7662 §4).

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.
@euskadi31
euskadi31 merged commit c3b2963 into master Oct 10, 2026
2 checks passed
@euskadi31
euskadi31 deleted the feature/81-oauth2-introspection-authz branch October 10, 2026 00:21
@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 38008572901

Coverage increased (+0.2%) to 92.032%

Details

  • Coverage increased (+0.2%) from the base build.
  • Patch coverage: 65 of 65 lines across 2 files are fully covered (100%).
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 4330
Covered Lines: 3985
Line Coverage: 92.03%
Coverage Strength: 14.32 hits per line

💛 - Coveralls

1 similar comment
@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 38008572901

Coverage increased (+0.2%) to 92.032%

Details

  • Coverage increased (+0.2%) from the base build.
  • Patch coverage: 65 of 65 lines across 2 files are fully covered (100%).
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 4330
Covered Lines: 3985
Line Coverage: 92.03%
Coverage Strength: 14.32 hits per line

💛 - Coveralls

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

oauth2: add explicit caller authorization for token introspection

2 participants