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