From 7e1f12c8a41c8e05fe1fd0ad4b56c66933fe8d5f Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Sat, 10 Oct 2026 01:44:10 +0200 Subject: [PATCH] fix(oauth2): authorize token introspection callers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 25 ++ MIGRATION.md | 81 ++++ docs/architecture.md | 6 +- docs/observability.md | 6 +- docs/security-considerations.md | 28 +- oauth2/clientauth/none.go | 6 +- oauth2/doc.go | 9 +- oauth2/hook.go | 3 +- oauth2/introspect_endpoint.go | 142 ++++-- oauth2/introspect_endpoint_test.go | 681 +++++++++++++++++++++++++++++ oauth2/introspect_policy.go | 86 ++++ oauth2/server.go | 17 +- 12 files changed, 1053 insertions(+), 37 deletions(-) create mode 100644 oauth2/introspect_endpoint_test.go create mode 100644 oauth2/introspect_policy.go diff --git a/CHANGELOG.md b/CHANGELOG.md index edf887b..891ac4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,16 @@ legacy packages (`authentication/`, `authorization/`, the in-tree - **Documentation**: `docs/architecture.md`, `docs/observability.md`, `docs/security-considerations.md`, `docs/migration-from-v0.md`, and a refreshed `README.md`. +- **OAuth2 introspection policy**: `ServerConfig.IntrospectionPolicy` + (`oauth2.IntrospectionPolicy`) decides which active tokens each caller of + `/introspect` may see — the place to enforce resource and tenant + boundaries (RFC 7662 §4). It receives the request context, the + authenticated confidential caller and an `oauth2.IntrospectedToken` (its + `oauth2.TokenKind`, client, subject, scope, audience, issuance and expiry — + never the token value). A denial answers a bare `{"active":false}`, the + same bytes as an unknown token; a policy error fails closed the same way + and reaches `OnError` as a `server_error`. Optional: without a policy, any + authenticated confidential client may introspect any token. ### Changed @@ -147,3 +157,18 @@ legacy packages (`authentication/`, `authorization/`, the in-tree it fails with `server_error` before any token is minted or persisted. Non-rotating refresh tokens remain an explicit `Profile20` choice (`RotateRefreshTokens: false`). +- The OAuth2 `/introspect` endpoint now answers only clients whose + `Type()` is `oauth2.ClientConfidential` (RFC 7662 §2.1 / §4, RFC 6749 + §2.2, §2.3). It used to answer any client the shared `ClientAuth` chain + authenticated, so anyone knowing a public `client_id` — which is not a + secret — could read the validity and claims of any token they held, + 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. + Public clients, whatever the method, and untyped clients (`Type() == ""`) + authenticated through Basic or Post — `none` already refused them — now + get `401 invalid_client`. This breaks deployments whose applications, or + resource servers registered without a type, call `/introspect`. `/token` + and `/revoke` are unchanged. The `none` method's `error_description` for + a non-public client no longer contains a double quote, which RFC 6749 + §5.2 forbids. See [MIGRATION.md](MIGRATION.md). diff --git a/MIGRATION.md b/MIGRATION.md index 0fbc407..7bc3044 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -89,3 +89,84 @@ Every sub-module declares `replace github.com/hyperscale-stack/security => ../` (or `=> ../../` for the SQL/Redis sub-modules) so local development and CI work without published versions. Releases are cut per module with `module/vX.Y.Z` tags. + +## Breaking changes before the first v2 release + +The v2 stack is not tagged yet, but code tracking `master` may already rely +on its behavior. Each entry below states what changed and how to adapt. + +### `oauth2`: `/introspect` only answers confidential clients (#81) + +`/introspect` used to answer any caller the shared `ServerConfig.ClientAuth` +chain authenticated. RFC 7662 §2.1 / §4 require the authorization server to +authenticate the protected resources calling it, but a `client_id` alone is +not a credential (RFC 6749 §2.2), and the authorization server MUST NOT rely +on a public client's authentication to identify it (RFC 6749 §2.3). Yet a +public client's `client_id`, shipped inside every copy of a SPA or mobile +app, was enough for anyone to read the validity and claims of a token they +held, 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. +Untyped clients authenticating through Basic or Post were answered too. + +The endpoint now answers only callers whose `Client.Type()` is +`oauth2.ClientConfidential`: a caller whose credentials are valid but whose +type is anything else gets `401 invalid_client` +(`introspection requires confidential client authentication`): + +| Caller at `/introspect` | Before | After | +| -------------------------------------------------------------------------------------------- | ------------------- | -------------------- | +| Public client via `none` (`client_id` only) | `200`, token claims | `401 invalid_client` | +| Public client via `client_secret_basic` / `_post` with its secret (Basic: even an empty one) | `200`, token claims | `401 invalid_client` | +| Untyped client via `client_secret_basic` / `_post` | `200`, token claims | `401 invalid_client` | +| Confidential client via `client_secret_basic` / `_post` | `200`, token claims | unchanged (see 3.) | + +`/token` and `/revoke` are unchanged: a public client keeps running the +authorization-code + PKCE flow, refreshing and revoking its own tokens with +`none`. + +To migrate: + +1. Register every resource server that calls `/introspect` as a confidential + client with a secret, authenticated with `client_secret_basic` or + `client_secret_post`. An untyped client — such as an + `oauth2.DefaultClient` without `TypeValue` — must now set + `TypeValue: oauth2.ClientConfidential` to keep introspecting. +2. Stop calling `/introspect` from public applications: read `expires_in` + and `scope` from the `/token` response, or ask a confidential backend. +3. When several resource servers or tenants share the authorization server, + set `ServerConfig.IntrospectionPolicy`: without one, any authenticated + confidential client may introspect any token. RFC 7662 §4 says protected + resources SHOULD be specifically authorized, and when a token can be used + only at certain resource servers, the authorization server MUST determine + whether it can be used at the one making the call. A denial answers a bare + `{"active":false}`, the same bytes as an unknown token; a policy error + fails closed the same way and reaches `OnError`, so wire both. A typical + policy discloses access tokens to the resource server they are meant for, + and refresh tokens to none: + + ```go + srv, err := oauth2.NewServer(oauth2.ServerConfig{ + // ... + OnError: onError, // an oauth2.ErrorHook: policy failures are reported here + IntrospectionPolicy: func(_ context.Context, caller oauth2.Client, tok oauth2.IntrospectedToken) (bool, error) { + // A refresh token is meant for its client alone and carries no + // audience: never disclose it to a resource server. + if tok.Kind != oauth2.TokenKindAccess { + return false, nil + } + + // audienceOf is your registry: resource server -> its audience. + // An empty audience, on either side, fails closed. + return tok.Audience != "" && tok.Audience == audienceOf(caller.ID()), nil + }, + }) + ``` + + Do not reduce the check to `caller.ID() == tok.ClientID`: the resource + server introspecting a token is usually not the client it was issued to, + so that rule refuses the legitimate calls without enforcing any resource + or tenant boundary. The policy only sees active tokens, only after the + caller authenticated as a confidential client, and gets the request + context; see the `oauth2.IntrospectionPolicy` godoc for the full + contract. diff --git a/docs/architecture.md b/docs/architecture.md index fd3b265..d1041e2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -148,10 +148,12 @@ The `oauth2` module is an authorization server, not just a provider: `refresh_token` (rotation + reuse detection), plus the opt-in legacy `password` grant (`grant.NewLegacyPassword`, refused outside `Profile20`). - **Client authentication** — `client_secret_basic`, `client_secret_post`, - `none` (public clients, PKCE required). + `none` (public clients, PKCE required). `/introspect` answers + confidential clients only. - **Endpoints** — `/authorize` (RFC 6749 §3.1, `authorization_code` and the opt-in legacy `implicit` flow, with an application-supplied consent hook), - `/token`, `/revoke` (RFC 7009), `/introspect` (RFC 7662), + `/token`, `/revoke` (RFC 7009), `/introspect` (RFC 7662, with an optional + `IntrospectionPolicy` deciding which tokens each caller may see), `/.well-known/oauth-authorization-server` (RFC 8414). The endpoint path prefix used in the metadata document is configurable via `ServerConfig.RoutePrefix`. diff --git a/docs/observability.md b/docs/observability.md index 50f6d6f..baaf155 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -59,7 +59,11 @@ The hook fires for: `/introspect`, metadata) and every error redirected back to the client by `/authorize`, including the pre-redirect refusals answered with a bare 400 (unknown client, unregistered `redirect_uri`); -- the best-effort revocations `/revoke` swallows to honour RFC 7009 §2.2. +- the best-effort revocations `/revoke` swallows to honour RFC 7009 §2.2; +- an `IntrospectionPolicy` that failed: `/introspect` withholds the token + behind a bare `{"active":false}` and reports a `server_error` + (`introspection policy failed`, the policy's error as cause). A plain + refusal by the policy is an expected outcome and is not reported. `grant.Config.OnError` is the same hook for the errors a grant swallows before returning — today, a family revocation that failed during BCP §8.10.3 diff --git a/docs/security-considerations.md b/docs/security-considerations.md index a7e617b..eae5f64 100644 --- a/docs/security-considerations.md +++ b/docs/security-considerations.md @@ -82,7 +82,30 @@ configurable clock skew, and resolves keys by `kid` against a JWKS provider refused outright under the stricter profile. - **Client authentication** — `client_secret_basic` / `client_secret_post` compare secrets in constant time. Public clients use `none` and MUST use - PKCE. + PKCE. `/introspect` answers `ClientConfidential` callers only: a public + client (whatever the method, `none` included) or an untyped client gets + `401 invalid_client`. +- **Token introspection** — RFC 7662 §4 requires the authorization server + to authenticate the protected resources calling `/introspect`. A public + client cannot authenticate: its `client_id` ships inside every copy of the + app and RFC 6749 §2.2 forbids using it alone, and the authorization server + MUST NOT rely on a public client's authentication — even with a secret — + to identify it (RFC 6749 §2.3). Accepting one would let anyone read the + validity and claims of a token they held — through `none`, or through + Basic/Post with the client's registered secret (Basic: even an empty + one) — so `/introspect` refuses every caller that is not a confidential + client. By default, any authenticated confidential client may then + introspect any token. RFC 7662 §4 adds that protected resources SHOULD be + specifically authorized, and that when a token can be used only at certain + resource servers the authorization server MUST determine whether it can be + used at the caller: that decision is `ServerConfig.IntrospectionPolicy`, + which sees the authenticated caller and the token's metadata before + anything is disclosed. Refuse refresh tokens to resource servers — they are + meant for the client alone, and a resource server that ignores + `token_type` could accept one as a bearer token. A withheld token and a + failed policy answer the same bytes as an unknown token + (`{"active":false}`), so the response discloses nothing (its latency may + still differ); a policy failure is reported to `OnError`. ## Sessions @@ -126,6 +149,9 @@ span catalog. - [ ] Keep the JWT allowlist asymmetric unless you truly need HMAC. - [ ] Use `Profile20BCP` or stricter; do not enable the `implicit` / `password` legacy grants without a documented reason. +- [ ] Register the resource servers calling `/introspect` as confidential + clients, and set `ServerConfig.IntrospectionPolicy` when several + resource servers or tenants share the authorization server. - [ ] Serve over HTTPS so `Secure` cookies and bearer tokens are protected. - [ ] Supply at least two session keys so rotation is possible without invalidating live sessions. diff --git a/oauth2/clientauth/none.go b/oauth2/clientauth/none.go index a3701ee..64e70b9 100644 --- a/oauth2/clientauth/none.go +++ b/oauth2/clientauth/none.go @@ -18,6 +18,10 @@ import ( // // The server MUST reject confidential clients trying to use "none"; the // grant handler enforces PKCE separately. +// +// A client_id alone is not a credential (RFC 6749 §2.2): the server's +// /introspect endpoint, which answers confidential clients only, refuses +// every caller authenticated this way. func NewNone() ClientAuthenticator { return noneAuth{} } type noneAuth struct{} @@ -56,7 +60,7 @@ func (noneAuth) Authenticate(ctx context.Context, r *http.Request, store oauth2. } if client.Type() != oauth2.ClientPublic { - return nil, oauth2.ErrInvalidClient.WithDescription(`"none" reserved to public clients`) + return nil, oauth2.ErrInvalidClient.WithDescription("none method reserved to public clients") } if !allowsMethod(client, "none") { diff --git a/oauth2/doc.go b/oauth2/doc.go index 0231fc0..98a3e19 100644 --- a/oauth2/doc.go +++ b/oauth2/doc.go @@ -14,6 +14,11 @@ // endpoint (authorization_code, and the opt-in legacy implicit flow); // TokenHandler, RevokeHandler, IntrospectHandler and MetadataHandler // cover the remaining RFC endpoints. +// - IntrospectHandler answers confidential clients only (a public +// client's client_id is no credential), and the optional +// ServerConfig.IntrospectionPolicy decides which active tokens each +// caller may see, enforcing resource and tenant boundaries (RFC 7662 +// §4). // - Grants implement authorization_code (with PKCE), client_credentials // and refresh_token. The legacy password grant (grant.NewLegacyPassword) // is opt-in and refused outside Profile20. @@ -24,8 +29,8 @@ // to guarantee single-use semantics and reuse-detection. // - ServerConfig.OnError (and grant.Config.OnError) observe the errors the // protocol hides: the cause of a server_error, which never reaches the -// wire, and the revocations RFC 7009 §2.2 requires to answer 200 OK -// regardless. See ErrorHook. +// wire, the revocations RFC 7009 §2.2 requires to answer 200 OK +// regardless, and a failed IntrospectionPolicy. See ErrorHook. // // Allowed dependencies: // - github.com/hyperscale-stack/security (core) diff --git a/oauth2/hook.go b/oauth2/hook.go index a0ead70..4966edc 100644 --- a/oauth2/hook.go +++ b/oauth2/hook.go @@ -15,7 +15,8 @@ import "context" // wire response deliberately drops it. // - the errors the server swallows to stay protocol-compliant, such as a // best-effort revocation that failed (RFC 7009 §2.2 mandates 200 OK -// whatever happened). +// whatever happened) or an [IntrospectionPolicy] that failed (the token +// is withheld behind a bare {"active":false}, RFC 7662 §2.2). // // The hook is a pure observability sink: it MUST NOT influence the // response. It runs synchronously on the request goroutine, so a slow diff --git a/oauth2/introspect_endpoint.go b/oauth2/introspect_endpoint.go index f717572..e8d78c0 100644 --- a/oauth2/introspect_endpoint.go +++ b/oauth2/introspect_endpoint.go @@ -5,15 +5,32 @@ package oauth2 import ( + "context" "encoding/json" "net/http" ) +// introspectionCallerRefused is the error_description of the 401 answered +// to a caller that is not a confidential client. It stays within the +// RFC 6749 §5.2 charset. +const introspectionCallerRefused = "introspection requires confidential client authentication" + // IntrospectHandler returns the http.Handler for RFC 7662 token -// introspection. The caller MUST authenticate as an OAuth2 client (the -// same ClientAuthenticators are reused). A successful response carries -// "active":true plus the standard claims; a failed lookup returns -// "active":false with no other fields. +// introspection. +// +// Only confidential clients are answered. RFC 7662 §2.1 and §4 require the +// endpoint to authenticate the protected resources calling it, and a public +// client cannot authenticate: its client_id is no secret, and no credential +// it presents can be relied on to identify it (RFC 6749 §2.2, §2.3). The +// caller authenticates through the configured [ServerConfig.ClientAuth] +// methods and must be a [ClientConfidential] client; a public client — +// whatever the method, "none" included — or an untyped one gets 401 +// invalid_client. +// +// An active token is disclosed — "active":true plus its claims — when the +// [ServerConfig.IntrospectionPolicy] allows it, or when no policy is set. +// Anything else (an unknown, expired or consumed token, or one the policy +// withholds) answers a bare {"active":false} (RFC 7662 §2.2). func (s *Server) IntrospectHandler() http.Handler { return http.HandlerFunc(s.serveIntrospect) } @@ -31,7 +48,8 @@ func (s *Server) serveIntrospect(w http.ResponseWriter, r *http.Request) { return } - if _, err := s.authenticateClient(r.Context(), r); err != nil { + caller, err := s.authenticateIntrospectionCaller(r.Context(), r) + if err != nil { s.writeOAuthError(r.Context(), w, err) return @@ -44,7 +62,7 @@ func (s *Server) serveIntrospect(w http.ResponseWriter, r *http.Request) { return } - body := s.introspect(r, rawToken) + body := s.introspect(r.Context(), caller, rawToken) w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") @@ -55,6 +73,23 @@ func (s *Server) serveIntrospect(w http.ResponseWriter, r *http.Request) { } } +// authenticateIntrospectionCaller authenticates the caller through the +// shared client-authentication chain, then admits confidential clients +// only: a public client's credentials prove nothing (RFC 6749 §2.2, §2.3), +// and an untyped client cannot be told apart from one. +func (s *Server) authenticateIntrospectionCaller(ctx context.Context, r *http.Request) (Client, error) { + caller, err := s.authenticateClient(ctx, r) + if err != nil { + return nil, err + } + + if caller.Type() != ClientConfidential { + return nil, ErrInvalidClient.WithDescription(introspectionCallerRefused) + } + + return caller, nil +} + // introspectResponse is the RFC 7662 §2.2 JSON envelope. We populate the // most commonly consumed fields; deployments needing custom claims should // wrap this endpoint. @@ -69,42 +104,97 @@ type introspectResponse struct { Audience string `json:"aud,omitempty"` } -func (s *Server) introspect(r *http.Request, rawToken string) introspectResponse { +// introspect builds the response for rawToken as seen by caller: the +// claims of an active token the policy discloses, a bare inactive answer +// for anything else. +func (s *Server) introspect(ctx context.Context, caller Client, rawToken string) introspectResponse { + tok, ok := s.lookupActiveToken(ctx, rawToken) + if !ok || !s.introspectionAllowed(ctx, caller, tok) { + return introspectResponse{Active: false} + } + + return activeIntrospectResponse(tok) +} + +// lookupActiveToken resolves rawToken as an access token, then as a +// refresh token, and reports whether it is active: an expired token or a +// consumed refresh token is not. +func (s *Server) lookupActiveToken(ctx context.Context, rawToken string) (IntrospectedToken, bool) { hash := HashToken(nil, rawToken) now := s.cfg.Now() - if at, err := s.cfg.Storage.LookupAccessToken(r.Context(), hash); err == nil { + if at, err := s.cfg.Storage.LookupAccessToken(ctx, hash); err == nil { if at.IsExpired(now) { - return introspectResponse{Active: false} + return IntrospectedToken{}, false } - return introspectResponse{ - Active: true, - Scope: at.Scope, + return IntrospectedToken{ + Kind: TokenKindAccess, ClientID: at.ClientID, Subject: at.Subject, - ExpiresAt: at.ExpiresAt.Unix(), - IssuedAt: at.IssuedAt.Unix(), - TokenType: TokenTypeBearer, + Scope: at.Scope, Audience: at.Audience, - } + IssuedAt: at.IssuedAt, + ExpiresAt: at.ExpiresAt, + }, true } - if rt, err := s.cfg.Storage.LookupRefreshToken(r.Context(), hash); err == nil { + if rt, err := s.cfg.Storage.LookupRefreshToken(ctx, hash); err == nil { if rt.IsExpired(now) || rt.Consumed { - return introspectResponse{Active: false} + return IntrospectedToken{}, false } - return introspectResponse{ - Active: true, - Scope: rt.Scope, + return IntrospectedToken{ + Kind: TokenKindRefresh, ClientID: rt.ClientID, Subject: rt.Subject, - ExpiresAt: rt.ExpiresAt.Unix(), - IssuedAt: rt.IssuedAt.Unix(), - TokenType: "refresh_token", - } + Scope: rt.Scope, + IssuedAt: rt.IssuedAt, + ExpiresAt: rt.ExpiresAt, + }, true + } + + return IntrospectedToken{}, false +} + +// introspectionAllowed runs the configured [IntrospectionPolicy]; without +// one, every confidential caller may see every token. A failed policy +// withholds the token — fail closed — and is reported to the [ErrorHook], +// since the response must not tell it apart from an unknown token. +func (s *Server) introspectionAllowed(ctx context.Context, caller Client, tok IntrospectedToken) bool { + if s.cfg.IntrospectionPolicy == nil { + return true + } + + allowed, err := s.cfg.IntrospectionPolicy(ctx, caller, tok) + if err != nil { + s.notifyError(ctx, ErrServerError.WithDescription("introspection policy failed").WithCause(err)) + + return false } - return introspectResponse{Active: false} + return allowed +} + +// activeIntrospectResponse renders an active token as the RFC 7662 §2.2 +// body. +func activeIntrospectResponse(tok IntrospectedToken) introspectResponse { + tokenType := TokenTypeBearer + if tok.Kind == TokenKindRefresh { + // RFC 6749 §5.1 defines no "refresh_token" token type; the value is + // kept so a resource server can tell a refresh token apart and + // refuse it. + tokenType = string(TokenKindRefresh) + } + + return introspectResponse{ + Active: true, + Scope: tok.Scope, + ClientID: tok.ClientID, + Subject: tok.Subject, + ExpiresAt: tok.ExpiresAt.Unix(), + IssuedAt: tok.IssuedAt.Unix(), + TokenType: tokenType, + Audience: tok.Audience, + } } diff --git a/oauth2/introspect_endpoint_test.go b/oauth2/introspect_endpoint_test.go new file mode 100644 index 0000000..4530f08 --- /dev/null +++ b/oauth2/introspect_endpoint_test.go @@ -0,0 +1,681 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package oauth2_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "sync/atomic" + "testing" + "time" + + "github.com/hyperscale-stack/security/oauth2" + "github.com/hyperscale-stack/security/oauth2/clientauth" + "github.com/hyperscale-stack/security/oauth2/grant" + "github.com/hyperscale-stack/security/oauth2/storage/memory" + "github.com/hyperscale-stack/security/oauth2/token" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Clients registered on the introspection test server (see +// introspectClients). +const ( + spaClientID = "spa" + rsAPIClientID = "rs-api" + rsAPISecret = "rs-api-secret" + rsBillingClientID = "rs-billing" + rsBillingSecret = "rs-billing-secret" + publicSecretClientID = "public-with-secret" + publicSecret = "public-secret" + untypedClientID = "untyped" + untypedSecret = "untyped-secret" + untypedNoSecretID = "untyped-no-secret" +) + +const ( + // introspectionRefused is the error_description /introspect answers + // every caller that is not a confidential client with. + introspectionRefused = "introspection requires confidential client authentication" + // inactiveBody is the bare RFC 7662 §2.2 answer for a token the caller + // may not see. + inactiveBody = `{"active":false}` +) + +// The instant the introspection test server's clock is pinned to, and the +// timestamps of the active tokens seedTokens persists. +var ( + introspectNow = time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) + seededIssuedAt = introspectNow.Add(-time.Minute) + seededAccessExp = introspectNow.Add(time.Hour) + seededRefreshExp = introspectNow.Add(24 * time.Hour) +) + +// descriptionCharset is the RFC 6749 §5.2 error_description charset: +// %x20-21 / %x23-5B / %x5D-7E — no double quote, no backslash. +var descriptionCharset = regexp.MustCompile(`^[\x20-\x21\x23-\x5B\x5D-\x7E]*$`) + +// introspectClients registers the callers the introspection tests use. +// AuthMethodValues stays empty (any method) everywhere, so a refusal can +// only come from the endpoint's own caller check. +func introspectClients() *staticClientStore { + return &staticClientStore{clients: map[string]oauth2.Client{ + // A public SPA: "none" + PKCE at /token. + spaClientID: &oauth2.DefaultClient{ + IDValue: spaClientID, + TypeValue: oauth2.ClientPublic, + RedirectURIValues: []string{redirectURI}, + ScopeValues: []string{"read"}, + }, + // Two confidential resource servers. + rsAPIClientID: &oauth2.DefaultClient{ + IDValue: rsAPIClientID, Secret: rsAPISecret, TypeValue: oauth2.ClientConfidential, + }, + rsBillingClientID: &oauth2.DefaultClient{ + IDValue: rsBillingClientID, Secret: rsBillingSecret, TypeValue: oauth2.ClientConfidential, + }, + // A public client provisioned with a secret all the same. + publicSecretClientID: &oauth2.DefaultClient{ + IDValue: publicSecretClientID, Secret: publicSecret, TypeValue: oauth2.ClientPublic, + }, + // Clients with no declared type, with and without a secret. + untypedClientID: &oauth2.DefaultClient{IDValue: untypedClientID, Secret: untypedSecret}, + untypedNoSecretID: &oauth2.DefaultClient{IDValue: untypedNoSecretID}, + }} +} + +// newIntrospectServer builds a Profile20BCP server pinned to introspectNow. +// It serves /authorize, /token (authorization_code + rotating +// refresh_token), /revoke and /introspect, authenticates clients with +// client_secret_basic, client_secret_post and none, and holds the +// seedTokens tokens. policy and onError may be nil. +func newIntrospectServer(t *testing.T, policy oauth2.IntrospectionPolicy, onError oauth2.ErrorHook) *oauth2.Server { + t.Helper() + + store := memory.New() + gcfg := grant.Config{ + Storage: store, + AccessTokens: token.NewOpaque(32), + RefreshTokens: token.OpaqueRefreshAdapter{Opaque: token.NewOpaque(32)}, + AccessTTL: time.Hour, + RefreshTTL: 24 * time.Hour, + RotateRefreshTokens: true, + } + + srv, err := oauth2.NewServer(oauth2.ServerConfig{ + Profile: oauth2.Profile20BCP, + Storage: store, + ClientStore: introspectClients(), + IssuerResolver: oauth2.StaticIssuer("https://auth.example", "api"), + Grants: []oauth2.Grant{grant.NewAuthorizationCode(gcfg), grant.NewRefreshToken(gcfg)}, + ClientAuth: []oauth2.ClientAuthenticator{ + clientauth.NewBasic(), clientauth.NewPost(), clientauth.NewNone(), + }, + Now: func() time.Time { return introspectNow }, + OnError: onError, + IntrospectionPolicy: policy, + }) + require.NoError(t, err) + + seedTokens(t, store) + + return srv +} + +// seedTokens persists the tokens the introspection tests look up, all +// issued to the "app" client — which no test caller is. It is the only +// direct store write of this file, and each refresh token sits next to an +// access token of its own family, as a grant issues them: +// +// - at-app / rt-app (fam-app): active, for alice, scope read, audience api. +// - at-expired / rt-expired (fam-expired): past their expiry. +// - at-consumed / rt-consumed (fam-consumed): the refresh token was +// already rotated. +func seedTokens(t *testing.T, store *memory.Store) { + t.Helper() + + expiredAt := introspectNow.Add(-time.Hour) + pairs := []struct { + access oauth2.AccessToken + refresh oauth2.RefreshToken + }{ + { + access: oauth2.AccessToken{ + TokenHash: oauth2.HashToken(nil, "at-app"), ClientID: "app", Subject: "alice", Scope: "read", + Audience: "api", FamilyID: "fam-app", IssuedAt: seededIssuedAt, ExpiresAt: seededAccessExp, + }, + refresh: oauth2.RefreshToken{ + TokenHash: oauth2.HashToken(nil, "rt-app"), ClientID: "app", Subject: "alice", Scope: "read", + FamilyID: "fam-app", IssuedAt: seededIssuedAt, ExpiresAt: seededRefreshExp, + }, + }, + { + access: oauth2.AccessToken{ + TokenHash: oauth2.HashToken(nil, "at-expired"), ClientID: "app", Subject: "alice", Scope: "read", + Audience: "api", FamilyID: "fam-expired", IssuedAt: expiredAt.Add(-time.Hour), ExpiresAt: expiredAt, + }, + refresh: oauth2.RefreshToken{ + TokenHash: oauth2.HashToken(nil, "rt-expired"), ClientID: "app", Subject: "alice", Scope: "read", + FamilyID: "fam-expired", IssuedAt: expiredAt.Add(-time.Hour), ExpiresAt: expiredAt, + }, + }, + { + access: oauth2.AccessToken{ + TokenHash: oauth2.HashToken(nil, "at-consumed"), ClientID: "app", Subject: "alice", Scope: "read", + Audience: "api", FamilyID: "fam-consumed", IssuedAt: seededIssuedAt, ExpiresAt: seededAccessExp, + }, + refresh: oauth2.RefreshToken{ + TokenHash: oauth2.HashToken(nil, "rt-consumed"), ClientID: "app", Subject: "alice", Scope: "read", + FamilyID: "fam-consumed", IssuedAt: seededIssuedAt, ExpiresAt: seededRefreshExp, Consumed: true, + }, + }, + } + + for _, p := range pairs { + require.NoError(t, store.SaveAccessToken(t.Context(), &p.access)) + require.NoError(t, store.SaveRefreshToken(t.Context(), &p.refresh)) + } +} + +// basicForm builds a form POST authenticated with HTTP Basic as id:secret +// (formRequest can only authenticate as client-abc). +func basicForm(path string, form url.Values, id, secret string) *http.Request { + req := formRequest(path, form, false) + req.SetBasicAuth(id, secret) + + return req +} + +// introspectRequest asks /introspect about raw, authenticated with HTTP +// Basic as id:secret. +func introspectRequest(id, secret, raw string) *http.Request { + return basicForm("/oauth2/introspect", url.Values{"token": {raw}}, id, secret) +} + +// serve runs req through h and returns the recorded response. +func serve(h http.Handler, req *http.Request) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + return rec +} + +// decodeBody decodes a JSON response body. +func decodeBody(t *testing.T, rec *httptest.ResponseRecorder) map[string]any { + t.Helper() + + var body map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + + return body +} + +// assertAnswersLikeUnknown asserts that the /introspect answer rec, about +// the token raw, is exactly the one unknown got for a never-issued token — +// same status, headers and bytes (RFC 7662 §2.2) — so it discloses nothing. +func assertAnswersLikeUnknown(t *testing.T, unknown, rec *httptest.ResponseRecorder, raw string) { + t.Helper() + + assert.Equal(t, http.StatusOK, rec.Code, raw) + assert.JSONEq(t, inactiveBody, rec.Body.String(), raw) + assert.Equal(t, unknown.Body.Bytes(), rec.Body.Bytes(), raw) + assert.Equal(t, unknown.Header(), rec.Header(), raw) +} + +// policyProbe is an [oauth2.IntrospectionPolicy] double: it answers allow +// and err, and counts its calls. +type policyProbe struct { + allow bool + err error + calls atomic.Int32 +} + +func (p *policyProbe) decide(context.Context, oauth2.Client, oauth2.IntrospectedToken) (bool, error) { + p.calls.Add(1) + + return p.allow, p.err +} + +// TestIntrospectCallerAuthentication covers the endpoint authentication +// RFC 7662 §2.1 / §4 require: only a confidential client is answered. Any +// other caller — a public client whatever the method, an untyped client, a +// failed authentication — gets 401 invalid_client before the token is +// looked at. +func TestIntrospectCallerAuthentication(t *testing.T) { + t.Parallel() + + // postForm builds an /introspect request for the active at-app token, + // carrying credentials (if any) in the form only. + postForm := func(credentials url.Values) *http.Request { + form := url.Values{"token": {"at-app"}} + for k, v := range credentials { + form[k] = v + } + + return formRequest("/oauth2/introspect", form, false) + } + + cases := []struct { + name string + req *http.Request + accepted bool + wantDesc string // the pinned error_description, if any + }{ + { + name: "client_secret_basic confidential client", + req: introspectRequest(rsAPIClientID, rsAPISecret, "at-app"), + accepted: true, + }, + { + name: "client_secret_post confidential client", + req: postForm(url.Values{"client_id": {rsAPIClientID}, "client_secret": {rsAPISecret}}), + accepted: true, + }, + { + name: "none public client", + req: postForm(url.Values{"client_id": {spaClientID}}), + wantDesc: introspectionRefused, + }, + { + name: "none unknown client", + req: postForm(url.Values{"client_id": {"ghost"}}), + }, + { + name: "none confidential client", + req: postForm(url.Values{"client_id": {rsAPIClientID}}), + wantDesc: "none method reserved to public clients", + }, + { + name: "basic public client with an empty password", + req: introspectRequest(spaClientID, "", "at-app"), + wantDesc: introspectionRefused, + }, + { + name: "basic public client with a secret", + req: introspectRequest(publicSecretClientID, publicSecret, "at-app"), + wantDesc: introspectionRefused, + }, + { + name: "post public client with a secret", + req: postForm(url.Values{"client_id": {publicSecretClientID}, "client_secret": {publicSecret}}), + wantDesc: introspectionRefused, + }, + { + name: "basic untyped client", + req: introspectRequest(untypedClientID, untypedSecret, "at-app"), + wantDesc: introspectionRefused, + }, + { + name: "basic untyped client with an empty password", + req: introspectRequest(untypedNoSecretID, "", "at-app"), + wantDesc: introspectionRefused, + }, + { + name: "wrong secret", + req: introspectRequest(rsAPIClientID, "wrong", "at-app"), + }, + { + name: "no credentials", + req: postForm(nil), + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + probe := &policyProbe{allow: true} + sink := &errorSink{} + srv := newIntrospectServer(t, probe.decide, sink.hook) + + rec := serve(srv.IntrospectHandler(), tc.req) + body := decodeBody(t, rec) + + if tc.accepted { + require.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, true, body["active"]) + assert.Equal(t, int32(1), probe.calls.Load()) + assert.Empty(t, sink.codes()) + + return + } + + // RFC 7662 §2.3 / RFC 6749 §5.2: a failed endpoint + // authentication is a 401 invalid_client with a challenge... + require.Equal(t, http.StatusUnauthorized, rec.Code) + assert.NotEmpty(t, rec.Header().Get("WWW-Authenticate")) + assert.Equal(t, oauth2.CodeInvalidClient, body["error"]) + + desc, _ := body["error_description"].(string) + assert.Regexp(t, descriptionCharset, desc) + + if tc.wantDesc != "" { + assert.Equal(t, tc.wantDesc, desc) + } + + // ...that tells nothing about the token, never handed to the + // policy. + assert.NotContains(t, body, "active") + assert.NotContains(t, body, "sub") + assert.NotContains(t, body, "scope") + assert.Zero(t, probe.calls.Load(), "the policy must not run for a refused caller") + assert.Equal(t, []string{oauth2.CodeInvalidClient}, sink.codes()) + }) + } +} + +// TestIntrospectDefaultDisclosesToConfidentialResourceServer pins the +// default: with no policy, any authenticated confidential client sees any +// token — including one issued to another client, since the resource +// server asking is usually not the client the token was issued to. +func TestIntrospectDefaultDisclosesToConfidentialResourceServer(t *testing.T) { + t.Parallel() + + srv := newIntrospectServer(t, nil, nil) + + rec := serve(srv.IntrospectHandler(), introspectRequest(rsBillingClientID, rsBillingSecret, "at-app")) + require.Equal(t, http.StatusOK, rec.Code) + assert.JSONEq(t, fmt.Sprintf( + `{"active":true,"scope":"read","client_id":"app","sub":"alice","exp":%d,"iat":%d,"token_type":"Bearer","aud":"api"}`, + seededAccessExp.Unix(), seededIssuedAt.Unix()), rec.Body.String()) + + rec = serve(srv.IntrospectHandler(), introspectRequest(rsBillingClientID, rsBillingSecret, "rt-app")) + require.Equal(t, http.StatusOK, rec.Code) + assert.JSONEq(t, fmt.Sprintf( + `{"active":true,"scope":"read","client_id":"app","sub":"alice","exp":%d,"iat":%d,"token_type":"refresh_token"}`, + seededRefreshExp.Unix(), seededIssuedAt.Unix()), rec.Body.String()) +} + +// tenantKey carries a request-scoped tenant, as a middleware would set it. +type tenantKey struct{} + +// TestIntrospectionPolicyInputs checks what the policy is handed: the +// request context, the authenticated caller as the ClientStore returned +// it, and the token's metadata — never its value. +func TestIntrospectionPolicyInputs(t *testing.T) { + t.Parallel() + + type call struct { + tenant any + caller oauth2.Client + token oauth2.IntrospectedToken + } + + var calls []call + + srv := newIntrospectServer(t, + func(ctx context.Context, caller oauth2.Client, tok oauth2.IntrospectedToken) (bool, error) { + calls = append(calls, call{tenant: ctx.Value(tenantKey{}), caller: caller, token: tok}) + + return true, nil + }, nil) + + for _, raw := range []string{"at-app", "rt-app"} { + req := introspectRequest(rsAPIClientID, rsAPISecret, raw) + req = req.WithContext(context.WithValue(req.Context(), tenantKey{}, "tenant-a")) + + rec := serve(srv.IntrospectHandler(), req) + require.Equal(t, http.StatusOK, rec.Code, raw) + assert.Equal(t, true, decodeBody(t, rec)["active"], raw) + } + + require.Len(t, calls, 2) + + for _, c := range calls { + assert.Equal(t, "tenant-a", c.tenant, "the policy sees the request context") + assert.IsType(t, &oauth2.DefaultClient{}, c.caller, "the caller is the ClientStore record") + assert.Equal(t, rsAPIClientID, c.caller.ID()) + assert.Equal(t, oauth2.ClientConfidential, c.caller.Type()) + } + + assert.Equal(t, oauth2.IntrospectedToken{ + Kind: oauth2.TokenKindAccess, + ClientID: "app", + Subject: "alice", + Scope: "read", + Audience: "api", + IssuedAt: seededIssuedAt, + ExpiresAt: seededAccessExp, + }, calls[0].token) + + // A refresh token carries no audience. + assert.Equal(t, oauth2.IntrospectedToken{ + Kind: oauth2.TokenKindRefresh, + ClientID: "app", + Subject: "alice", + Scope: "read", + IssuedAt: seededIssuedAt, + ExpiresAt: seededRefreshExp, + }, calls[1].token) +} + +// TestIntrospectionPolicyNotConsultedForInactiveTokens: an unknown, +// expired or consumed token answers {"active":false} on its own — the +// policy only ever sees active tokens. +func TestIntrospectionPolicyNotConsultedForInactiveTokens(t *testing.T) { + t.Parallel() + + probe := &policyProbe{allow: true} + srv := newIntrospectServer(t, probe.decide, nil) + + for _, raw := range []string{"ghost", "at-expired", "rt-expired", "rt-consumed"} { + rec := serve(srv.IntrospectHandler(), introspectRequest(rsAPIClientID, rsAPISecret, raw)) + require.Equal(t, http.StatusOK, rec.Code, raw) + assert.JSONEq(t, inactiveBody, rec.Body.String(), raw) + } + + assert.Zero(t, probe.calls.Load()) +} + +// TestIntrospectionPolicyDenialDisclosesNothing: a token the policy +// withholds answers exactly like an unknown token (RFC 7662 §2.2) — same +// status, headers and bytes — and the denial is not reported as an error. +func TestIntrospectionPolicyDenialDisclosesNothing(t *testing.T) { + t.Parallel() + + probe := &policyProbe{allow: false} + sink := &errorSink{} + srv := newIntrospectServer(t, probe.decide, sink.hook) + + unknown := serve(srv.IntrospectHandler(), introspectRequest(rsAPIClientID, rsAPISecret, "ghost")) + require.Equal(t, http.StatusOK, unknown.Code) + assert.Equal(t, "application/json", unknown.Header().Get("Content-Type")) + assert.Equal(t, "no-store", unknown.Header().Get("Cache-Control")) + + for _, raw := range []string{"at-app", "rt-app"} { + rec := serve(srv.IntrospectHandler(), introspectRequest(rsAPIClientID, rsAPISecret, raw)) + assertAnswersLikeUnknown(t, unknown, rec, raw) + } + + assert.Equal(t, int32(2), probe.calls.Load(), "both tokens were withheld by the policy") + assert.Empty(t, sink.codes(), "a denial is an expected outcome, not an incident") +} + +// TestIntrospectionPolicyEnforcesBoundaries runs a tenant policy and a +// resource (audience) policy against two resource servers: each one sees +// what its boundary allows — refresh tokens included — and nothing else. +func TestIntrospectionPolicyEnforcesBoundaries(t *testing.T) { + t.Parallel() + + // Tenant boundary: a caller sees the tokens of its own tenant's + // clients. + tenantOf := map[string]string{"app": "tenant-a", rsAPIClientID: "tenant-a", rsBillingClientID: "tenant-b"} + tenantPolicy := func(_ context.Context, caller oauth2.Client, tok oauth2.IntrospectedToken) (bool, error) { + tenant := tenantOf[caller.ID()] + + return tenant != "" && tenant == tenantOf[tok.ClientID], nil + } + + // Resource boundary: a caller sees the access tokens issued for its own + // audience, and refresh tokens never — they carry no audience. + audienceOf := map[string]string{rsAPIClientID: "api", rsBillingClientID: "billing"} + resourcePolicy := func(_ context.Context, caller oauth2.Client, tok oauth2.IntrospectedToken) (bool, error) { + return tok.Kind == oauth2.TokenKindAccess && tok.Audience != "" && tok.Audience == audienceOf[caller.ID()], nil + } + + cases := []struct { + name string + policy oauth2.IntrospectionPolicy + callerID string + secret string + raw string + wantActive bool + }{ + {"tenant: own tenant sees the access token", tenantPolicy, rsAPIClientID, rsAPISecret, "at-app", true}, + {"tenant: own tenant sees the refresh token", tenantPolicy, rsAPIClientID, rsAPISecret, "rt-app", true}, + {"tenant: other tenant denied the access token", tenantPolicy, rsBillingClientID, rsBillingSecret, "at-app", false}, + {"tenant: other tenant denied the refresh token", tenantPolicy, rsBillingClientID, rsBillingSecret, "rt-app", false}, + {"resource: audience sees the access token", resourcePolicy, rsAPIClientID, rsAPISecret, "at-app", true}, + {"resource: audience denied the refresh token", resourcePolicy, rsAPIClientID, rsAPISecret, "rt-app", false}, + {"resource: other resource denied the access token", resourcePolicy, rsBillingClientID, rsBillingSecret, "at-app", false}, + {"resource: other resource denied the refresh token", resourcePolicy, rsBillingClientID, rsBillingSecret, "rt-app", false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + srv := newIntrospectServer(t, tc.policy, nil) + + rec := serve(srv.IntrospectHandler(), introspectRequest(tc.callerID, tc.secret, tc.raw)) + require.Equal(t, http.StatusOK, rec.Code) + + if !tc.wantActive { + assert.JSONEq(t, inactiveBody, rec.Body.String()) + + return + } + + body := decodeBody(t, rec) + assert.Equal(t, true, body["active"]) + assert.Equal(t, "app", body["client_id"]) + assert.Equal(t, "alice", body["sub"]) + }) + } +} + +// TestIntrospectionPolicyErrorFailsClosed: a policy that cannot decide +// withholds the token, whatever its bool says, and the failure reaches the +// error hook — the only place it can be seen. +func TestIntrospectionPolicyErrorFailsClosed(t *testing.T) { + t.Parallel() + + errDirDown := errors.New("tenant directory down") + probe := &policyProbe{allow: true, err: errDirDown} + sink := &errorSink{} + srv := newIntrospectServer(t, probe.decide, sink.hook) + + // An unknown token never reaches the policy, so it reports nothing. + unknown := serve(srv.IntrospectHandler(), introspectRequest(rsAPIClientID, rsAPISecret, "ghost")) + require.Equal(t, http.StatusOK, unknown.Code) + + // A 500 would tell the caller the token is active: answer exactly like + // an unknown token instead. + for _, raw := range []string{"at-app", "rt-app"} { + rec := serve(srv.IntrospectHandler(), introspectRequest(rsAPIClientID, rsAPISecret, raw)) + assertAnswersLikeUnknown(t, unknown, rec, raw) + } + + // Each failure reaches the hook, cause intact. + got := sink.collected() + require.Len(t, got, 2) + + for _, err := range got { + assert.Equal(t, oauth2.CodeServerError, oauth2.IsCode(err)) + assert.ErrorContains(t, err, "introspection policy failed") + assert.ErrorIs(t, err, errDirDown) + } +} + +// issuedTokens is the part of an RFC 6749 §5.1 body the flow test needs. +type issuedTokens struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} + +// decodeTokens decodes a successful /token response carrying both tokens. +func decodeTokens(t *testing.T, rec *httptest.ResponseRecorder) issuedTokens { + t.Helper() + + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + var got issuedTokens + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.NotEmpty(t, got.AccessToken) + require.NotEmpty(t, got.RefreshToken) + + return got +} + +// TestPublicClientAuthorizationCodeFlowUnaffected: refusing public clients +// at /introspect leaves their own flow intact — authorization_code + PKCE, +// refresh and revocation, all with "none" — while a resource server still +// introspects their tokens. +func TestPublicClientAuthorizationCodeFlowUnaffected(t *testing.T) { + t.Parallel() + + srv := newIntrospectServer(t, nil, nil) + + // asSPA builds a form POST authenticated with "none": client_id only. + asSPA := func(path string, form url.Values) *http.Request { + form.Set("client_id", spaClientID) + + return formRequest(path, form, false) + } + + // /authorize (S256 PKCE) mints a code for the SPA... + q := authorizeQuery() + q.Set("client_id", spaClientID) + + rec := runAuthorize(srv, q, approve) + require.Equal(t, http.StatusFound, rec.Code) + + code := mustLocation(t, rec).Query().Get("code") + require.NotEmpty(t, code) + + // ...which /token exchanges with "none" and the PKCE verifier. + issued := decodeTokens(t, serve(srv.TokenHandler(), asSPA("/oauth2/token", url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "redirect_uri": {redirectURI}, + "code_verifier": {pkceVerifier}, + }))) + + // The refresh token rotates with "none" too. + refreshed := decodeTokens(t, serve(srv.TokenHandler(), asSPA("/oauth2/token", url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {issued.RefreshToken}, + }))) + + // The SPA itself cannot introspect its token... + rec = serve(srv.IntrospectHandler(), asSPA("/oauth2/introspect", url.Values{"token": {refreshed.AccessToken}})) + require.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Equal(t, oauth2.CodeInvalidClient, decodeError(t, rec)) + + // ...but a confidential resource server can. + rec = serve(srv.IntrospectHandler(), introspectRequest(rsAPIClientID, rsAPISecret, refreshed.AccessToken)) + require.Equal(t, http.StatusOK, rec.Code) + + body := decodeBody(t, rec) + assert.Equal(t, true, body["active"]) + assert.Equal(t, spaClientID, body["client_id"]) + assert.Equal(t, "alice", body["sub"]) + assert.Equal(t, "api", body["aud"]) + + // Revoking the refresh token with "none" still works, and takes the + // family's access token down with it. + rec = serve(srv.RevokeHandler(), asSPA("/oauth2/revoke", url.Values{"token": {refreshed.RefreshToken}})) + require.Equal(t, http.StatusOK, rec.Code) + + rec = serve(srv.IntrospectHandler(), introspectRequest(rsAPIClientID, rsAPISecret, refreshed.AccessToken)) + require.Equal(t, http.StatusOK, rec.Code) + assert.JSONEq(t, inactiveBody, rec.Body.String()) +} diff --git a/oauth2/introspect_policy.go b/oauth2/introspect_policy.go new file mode 100644 index 0000000..d1df8f7 --- /dev/null +++ b/oauth2/introspect_policy.go @@ -0,0 +1,86 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package oauth2 + +import ( + "context" + "time" +) + +// TokenKind tells apart the two kinds of token /introspect resolves. The +// values are the RFC 7009 §4.1.2 token type hints. +type TokenKind string + +const ( + // TokenKindAccess is an access token. + TokenKindAccess TokenKind = "access_token" + // TokenKindRefresh is a refresh token. + TokenKindRefresh TokenKind = "refresh_token" +) + +// IntrospectedToken describes an active token to an [IntrospectionPolicy] +// before anything is disclosed: the claims the /introspect response would +// carry, never the token value or its hash. +type IntrospectedToken struct { + // Kind tells an access token from a refresh token. + Kind TokenKind + // ClientID is the client the token was issued to — usually not the + // caller, which is the resource server asking. + ClientID string + // Subject is the resource owner, or the client itself for a + // client_credentials token. + Subject string + // Scope is the space-delimited scope granted to the token. + Scope string + // Audience is the aud claim of an access token. It is always empty for + // a refresh token, and empty for an access token when the + // IssuerResolver returned no audience. + Audience string + // IssuedAt is the issuance time. + IssuedAt time.Time + // ExpiresAt is the expiry time. + ExpiresAt time.Time +} + +// IntrospectionPolicy decides whether the caller of /introspect may learn +// the metadata of an active token: the RFC 7662 §2.2 "not allowed to +// introspect this particular token" check, and the place to enforce +// resource and tenant boundaries. RFC 7662 §4 says protected resources +// SHOULD be specifically authorized, and makes the check mandatory for a +// token that can be used only at certain resource servers: the +// authorization server MUST then determine whether it can be used at the +// resource server making the call. +// +// The result decides the response: +// +// - (true, nil) discloses the token: "active":true plus its claims. +// - (false, nil) withholds it: a bare {"active":false}, the very bytes an +// unknown token gets. A denial is an expected outcome, not reported to +// the [ErrorHook]. +// - (_, err) fails closed: the token is withheld the same way, whatever +// the bool, and err reaches the [ErrorHook] as a server_error +// ("introspection policy failed"). Set [ServerConfig.OnError] along with +// a policy, or its failures go unseen. +// +// The policy only runs for an active token — an unknown, expired or +// consumed one answers {"active":false} without consulting it — and only +// once the caller has authenticated, so caller.Type() is always +// [ClientConfidential]. caller is the client the authentication returned — +// the [ClientStore] record for the built-in methods — so it can be +// type-asserted to your own [Client] implementation to read tenant +// metadata. ctx is the request context; request-derived data, such as a +// tenant resolved from the Host header, must be put there by middleware. +// +// Do not reduce the check to caller.ID() == token.ClientID: the resource +// server introspecting a token is usually not the client it was issued to. +// Refresh tokens carry no audience and are meant for their client alone: +// refuse them to resource servers. +// +// The policy runs synchronously on the request goroutine, before any byte +// of the response is written, and may be called concurrently. It MUST NOT +// panic: return an error to fail closed. A withheld token is +// indistinguishable from an unknown one on the wire only — the response +// time may differ. +type IntrospectionPolicy func(ctx context.Context, caller Client, token IntrospectedToken) (bool, error) diff --git a/oauth2/server.go b/oauth2/server.go index a41e216..4fab0ba 100644 --- a/oauth2/server.go +++ b/oauth2/server.go @@ -42,8 +42,11 @@ type ServerConfig struct { // [ProfileValidator] are checked against Profile at construction. Grants []Grant // ClientAuth lists the client-authentication methods active on /token - // (and /revoke, /introspect). The Server consults them in order and - // uses the first one whose Match returns true. + // and /revoke. The Server consults them in order and uses the first one + // whose Match returns true. /introspect authenticates its caller through + // the same list but answers [ClientConfidential] clients only: a public + // client (whatever the method, "none" included) or an untyped one gets + // 401 invalid_client. ClientAuth []ClientAuthenticator // RoutePrefix is the path prefix under which the token / revoke / // introspect / authorize endpoints are mounted. It is used solely to @@ -63,8 +66,16 @@ type ServerConfig struct { // OnError, when set, observes every error the server turns into an // RFC 6749 §5.2 response — including the cause of a server_error, which // never reaches the wire — plus the errors it swallows on purpose - // (best-effort revocation). Optional; see [ErrorHook]. + // (best-effort revocation, a failed [IntrospectionPolicy]). Optional; + // see [ErrorHook]. OnError ErrorHook + // IntrospectionPolicy, when set, decides which active tokens each + // confidential caller of /introspect may see — the place to enforce + // resource and tenant boundaries (RFC 7662 §4). A withheld token + // answers {"active":false}, exactly like an unknown one. Optional; nil + // lets any authenticated confidential client introspect any token. See + // [IntrospectionPolicy]. + IntrospectionPolicy IntrospectionPolicy } // Server is the OAuth2 authorization server. It exposes one