oidcx

package
v0.24.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 28, 2026 License: Apache-2.0 Imports: 29 Imported by: 0

Documentation

Overview

Package oidcx provides OAuth 2.0 / OIDC resource-server helpers: an auto-refreshing JWK Set cache, the RFC 9728 protected-resource metadata document (ProtectedResourceMetadata, MetadataURL), RFC 6750 / RFC 9470 WWW-Authenticate challenges (Challenge), and the resource verifier that accepts only tokens for this resource from its configured authorization servers and maps them to a caller (ResourceVerifier).

The JWK Set cache is a thin, import-light wrapper around jwkfetch.Cache that:

  • registers a single JWKS URL and re-fetches it in the background, no sooner than the minimum refresh interval (15 minutes by default, WithMinRefreshInterval) and no later than the maximum (24 hours by default, WithMaxRefreshInterval), within which the IdP's Cache-Control or Expires hint picks the moment; a failed fetch keeps the last-good set;
  • exposes a Set(ctx) accessor that callers use on every JWT verification path: it returns the cached set and never fetches, so a key the IdP starts signing with is seen only after the next background refresh, or after a Refresh the caller triggers (the ResourceVerifier does so, rate-limited, when a token names a kid the set lacks);
  • emits structured failure callbacks via an optional Logger hook so that obsx (or any other observability package) can audit refresh errors without oidcx pulling obsx in as a dependency.

The cache is safe for concurrent use; jwkfetch.Cache serializes refresh requests internally. Code generated by apic; DO NOT EDIT.

Code generated by apic; DO NOT EDIT.

Code generated by apic; DO NOT EDIT.

Code generated by apic; DO NOT EDIT.

Code generated by apic; DO NOT EDIT.

Code generated by apic; DO NOT EDIT.

Code generated by apic; DO NOT EDIT.

Code generated by apic; DO NOT EDIT.

Index

Constants

View Source
const (
	// ErrorCodeInvalidRequest: the request is malformed. Answered with 400.
	ErrorCodeInvalidRequest = "invalid_request"
	// ErrorCodeInvalidToken: the access token is expired, revoked, malformed
	// or invalid for another reason. Answered with 401.
	ErrorCodeInvalidToken = "invalid_token"
	// ErrorCodeInsufficientScope: the token is valid but does not grant the
	// scope the request needs. Answered with 403.
	ErrorCodeInsufficientScope = "insufficient_scope"
	// ErrorCodeInsufficientUserAuthentication: RFC 9470 step-up; the
	// authentication event behind the token does not meet the resource's
	// requirements. Answered with 401.
	ErrorCodeInsufficientUserAuthentication = "insufficient_user_authentication"
)

The error codes a Challenge renders (RFC 6750 §3.1, RFC 9470 §3). Any other Challenge.Error value is never rendered.

View Source
const (
	// DescriptionInvalidRequest describes ErrorCodeInvalidRequest.
	DescriptionInvalidRequest = "The request is malformed"
	// DescriptionInvalidToken describes ErrorCodeInvalidToken in general.
	DescriptionInvalidToken = "The access token is invalid"
	// DescriptionTokenExpired describes ErrorCodeInvalidToken for an expired
	// token, which a client can refresh (the RFC 6750 §3 example text).
	DescriptionTokenExpired = "The access token expired"
	// DescriptionWrongResource describes ErrorCodeInvalidToken for a token
	// that is not for this resource: an untrusted issuer, or an audience or
	// RFC 8707 resource claim that does not name it.
	DescriptionWrongResource = "The access token is not valid for this resource"
	// DescriptionInsufficientScope describes ErrorCodeInsufficientScope (the
	// RFC 6750 §3.1 text).
	DescriptionInsufficientScope = "The request requires higher privileges than provided by the access token"
	// DescriptionStepUpRequired describes
	// ErrorCodeInsufficientUserAuthentication (the RFC 9470 §3 example text).
	DescriptionStepUpRequired = "A different authentication level is required"
)

The error_description texts a Challenge renders, each only beside the error code it describes. They are fixed so that no token content, claim or internal error can reach the wire: a verifier maps its internal errors onto these texts and never passes its own. Each is in the RFC 6750 §3 charset (%x20-21 / %x23-5B / %x5D-7E), so it needs no escaping.

View Source
const (
	AuditReasonNoToken           = "no_token"
	AuditReasonKeySetUnavailable = "key_set_unavailable"
	AuditReasonExpired           = "expired"
	AuditReasonUntrustedIssuer   = "untrusted_issuer"
	AuditReasonWrongAudience     = "wrong_audience"
	AuditReasonWrongResource     = "wrong_resource"
	AuditReasonTokenType         = "token_type"
	AuditReasonUnknownKid        = "unknown_kid"
	// AuditReasonCertificateBinding: ErrCertificateBinding (RFC 8705 §3).
	AuditReasonCertificateBinding = "certificate_binding"
	AuditReasonStepUp             = "step_up"
	AuditReasonInsufficientScope  = "insufficient_scope"
	AuditReasonIdentityConflict   = "identity_conflict"
	AuditReasonInvalidToken       = "invalid_token"
)

The reasons AuditAttrs reports. Each is a fixed text: a gate's audit event never carries token content, a claim or an internal error message.

View Source
const AccessTokenType = "application/at+jwt"

AccessTokenType is the RFC 9068 §4 media type of a JWT access token, the one typ a ResourceVerifier accepts besides none at all (with "application/" implied: "at+jwt" names it too).

View Source
const ChallengeRealm = "api"

ChallengeRealm is the realm of every challenge Header renders; nothing can set another. It is a fixed, framework-neutral label: RFC 6750 §3 makes realm optional and MCP clients ignore it, resource_metadata already identifies the resource, and a constant never varies with configuration, never needs escaping and never names the framework (F-CR-004 / A-005). security.resource_server.resource_name is not used: it is Unicode display text for end users, and a header value is ASCII, so it would reach the wire mangled. A realm that could be set would also be free text, which could carry "resource_metadata=https://evil.example/" for a first-match client parser to read (see Header). The realm keeps at least one auth-param after the Bearer scheme, which RFC 6750 §3 requires, when there is no metadata URL to carry: Challenge{}.Header() is `Bearer realm="api"`, which pkg/securex sends as MinimalBearerChallenge when it has no other.

View Source
const DefaultAcceptableSkew = 30 * time.Second

DefaultAcceptableSkew is the clock difference a ResourceVerifier tolerates between itself and an authorization server when it checks exp, nbf and iat: a token issued up to 30 seconds "in the future", or expired up to 30 seconds ago, is accepted. WithAcceptableSkew changes it.

View Source
const DefaultFetchTimeout = 30 * time.Second

DefaultFetchTimeout is the default per-request timeout applied to the HTTP client used to fetch the JWKS document. APPSEC-7: prior versions inherited http.DefaultClient (no timeout), so a hung IdP could stall cold-start, refresh goroutines, and (once wired) every sync JWT verification. 30 seconds is conservative enough to cover slow-but-healthy IdPs while still bounding the worst case.

View Source
const DefaultMaxRefreshInterval = 24 * time.Hour

DefaultMaxRefreshInterval is the ceiling for re-fetching the JWKS: the background refresher fetches at least this often, however long a lifetime the IdP's Cache-Control or Expires hint claims (httprc's own default is 30 days). A day bounds how long a key the IdP has retired or added goes unnoticed by a cache nothing else refreshes.

View Source
const DefaultMaxStaleness = 0

DefaultMaxStaleness is 0, which disables the staleness cap. Callers that want a hard upper bound on how stale the cache may become on extended IdP outages must opt in via WithMaxStaleness. APPSEC-3.

View Source
const DefaultMinRefreshInterval = 15 * time.Minute

DefaultMinRefreshInterval is the floor for re-fetching the JWKS, regardless of any Cache-Control / Expires hint supplied by the IdP. 15 minutes balances rotation responsiveness against IdP load.

View Source
const KeyMissRefreshInterval = time.Minute

KeyMissRefreshInterval is how often, at most, a ResourceVerifier re-fetches one issuer's key set because a token named a kid that set lacks: the first such token after a rotation triggers one refresh, and every other token in the next minute, whatever kid it names, is judged against the set as it then stands. A minute keeps a flood of tokens with invented kids from turning into a flood of fetches against the authorization server, while a genuinely new key is picked up on first sight.

View Source
const KeySetRetryAfter = 30 * time.Second

KeySetRetryAfter is the Retry-After a resource-server gate sends with the 503 it answers ErrKeySetUnavailable with: the token could not be checked, for a reason that is not the client's (an authorization server's key set is unavailable, or the verifier's lifetime has ended at shutdown), so the client should retry, against this instance or another, rather than get a new token.

View Source
const MaxAcceptableSkew = 5 * time.Minute

MaxAcceptableSkew caps WithAcceptableSkew: a larger leeway would extend every token's lifetime by more than RFC 7519 §4.1.4's "a few minutes".

View Source
const MetadataCacheControl = "public, max-age=3600"

MetadataCacheControl is the Cache-Control value the metadata document is served with. The document changes only with the configuration, so a client may cache it for an hour rather than refetch it on every 401. The cost is that a configuration change (a new authorization server, say) can take up to that hour to reach a client holding a cached copy; the strong ETag lets it revalidate cheaply.

View Source
const MetadataPreflightHeaders = "MCP-Protocol-Version"

MetadataPreflightHeaders is the Access-Control-Allow-Headers value the metadata document's CORS preflight answers with: the MCP TypeScript SDK sends MCP-Protocol-Version when it fetches the document, and falls back to a plain GET only after a failed preflight.

Variables

View Source
var (
	// ErrNoToken: the request carried no Bearer credentials in its
	// Authorization header (none at all, or another scheme). RFC 6750 §3.1
	// answers it with a challenge that has no error code.
	ErrNoToken = errors.New("oidcx: no bearer token in the Authorization header")
	// ErrInvalidToken: the request presented a token that is not acceptable
	// here. Every more specific rejection below wraps it.
	ErrInvalidToken = errors.New("oidcx: invalid access token")
	// ErrTokenExpired: the token's signature verified but its "exp" has
	// passed, so the client can refresh it.
	ErrTokenExpired = fmt.Errorf("%w: the access token expired", ErrInvalidToken)
	// ErrIssuerNotTrusted: the token's "iss" is not exactly one of
	// security.resource_server.authorization_servers. Nothing is fetched for
	// such an issuer.
	ErrIssuerNotTrusted = fmt.Errorf("%w: the issuer is not a configured authorization server", ErrInvalidToken)
	// ErrAudienceMismatch: the token's "aud" does not name this resource.
	ErrAudienceMismatch = fmt.Errorf("%w: the audience does not name this resource", ErrInvalidToken)
	// ErrResourceMismatch: the token's RFC 8707 "resource" claim does not
	// name this resource, so a token one issuer minted for another resource
	// is refused even when the audiences overlap. With require_resource_claim
	// the claim must be present; without it, a claim that is present must
	// still name this resource.
	ErrResourceMismatch = fmt.Errorf("%w: the resource claim does not name this resource", ErrInvalidToken)
	// ErrKeySetUnavailable: the trusted issuer's key set cannot be served (it
	// exceeded WithMaxStaleness during an outage, the context the verifier
	// was built with is done, ErrJWKCacheStopped, or a refresh left it with
	// no RSA, EC or OKP key, ErrEmptyJWKSet), so no token from that
	// issuer can be verified. The client is not at fault; a gate may answer
	// it as a server error instead.
	ErrKeySetUnavailable = fmt.Errorf("%w: the issuer's key set is unavailable", ErrInvalidToken)
	// ErrCertificateBinding: the token is bound to a TLS client certificate
	// (RFC 8705 §3, its cnf claim's x5t#S256 thumbprint) that the request did
	// not present, or its cnf claim is malformed or names a confirmation
	// method apic does not verify (DPoP's jkt, a jwk, ...), or
	// security.resource_server.tls_client_certificate_bound_access_tokens
	// demands a binding the token lacks. A client that presents the right
	// certificate, or a correctly bound token, can succeed, so it is
	// invalid_token (401), never a 403.
	ErrCertificateBinding = fmt.Errorf("%w: the access token is not bound to the presented client certificate", ErrInvalidToken)
	// ErrStepUpRequired is matched by *StepUpError: the token is valid but
	// the authentication behind it does not meet
	// security.resource_server.step_up_acr_values or step_up_amr_values (RFC
	// 9470).
	ErrStepUpRequired = errors.New("oidcx: step-up authentication required")
	// ErrInsufficientScope is matched by *InsufficientScopeError: the token
	// is valid but does not grant a scope the request needs.
	ErrInsufficientScope = errors.New("oidcx: insufficient scope")
	// ErrNilResourceVerifier is returned by a nil *ResourceVerifier's Verify.
	ErrNilResourceVerifier = errors.New("oidcx: nil ResourceVerifier")
)

The errors Verify returns. Every rejection of a token the request did present wraps ErrInvalidToken, so errors.Is(err, ErrInvalidToken) tells a bad token from a missing one (ErrNoToken) or an insufficient one (ErrStepUpRequired, ErrInsufficientScope). None of them carries token content: a gate hands the error to securex.WriteChallenge, which sends it to the audit sink only, and the client sees the fixed challenge Challenge maps it to.

View Source
var ErrDiscovery = errors.New("oidcx: authorization-server metadata discovery failed")

ErrDiscovery is wrapped by the error NewResourceVerifier returns when no authorization-server metadata document of an issuer could be fetched, or none names that issuer exactly and an https jwks_uri.

View Source
var ErrEmptyJWKSURL = errors.New("oidcx: empty JWKS URL")

ErrEmptyJWKSURL is returned by NewJWKCache when the supplied JWKS URL is empty. Distinct from ErrEmptyJWKSet which signals that a fetch succeeded but returned no keys.

View Source
var ErrEmptyJWKSet = errors.New("oidcx: jwks endpoint returned no keys")

ErrEmptyJWKSet is returned by NewJWKCache when the IdP responds with a parseable but empty JWK set on cold start. Callers should treat this as a misconfiguration of the JWKS endpoint.

View Source
var ErrJWKCacheStopped = errors.New("oidcx: jwks cache stopped (its lifetime context is done)")

ErrJWKCacheStopped is returned by JWKCache.Set and JWKCache.Refresh once the context NewJWKCache was given is done: the refresh workers have exited, so no key set can be served or fetched. The cache does not recover; build a new one.

View Source
var ErrJWKSStaleExceeded = errors.New("oidcx: jwks cache exceeded max staleness")

ErrJWKSStaleExceeded is returned by JWKCache.Set when the most recent successful fetch is older than the configured WithMaxStaleness window. Callers should treat this as a hard authentication failure rather than fall back to the cached set: jwk.AutoRefresh would otherwise serve the stale set indefinitely during an extended IdP outage. APPSEC-3.

View Source
var ErrNilJWKCache = errors.New("oidcx: nil JWKCache")

ErrNilJWKCache is returned by methods on a nil receiver. This is strictly a defense against caller error -- production code should always inject a non-nil cache.

View Source
var ErrResourceServerDisabled = errors.New("oidcx: the resource server is not enabled")

ErrResourceServerDisabled is returned by NewProtectedResourceMetadata for an absent or disabled security.resource_server block: there is no protected resource to describe.

Functions

func AuditAttrs added in v0.24.0

func AuditAttrs(err error) []slog.Attr

AuditAttrs returns the audit attributes a resource-server gate logs for err: "reason", one of the AuditReason constants (AuditReasonTokenType for a typ the verifier refused, RFC 9068 §4; AuditReasonIdentityConflict for a composite AND-group whose credentials named two identities, securex.ErrIdentityConflict), and, for a step-up failure, only the *StepUpError flags ("acr_unmet", "amr_unmet"), never the token's own acr or amr. Nil for a nil err.

func MetadataRoutePath added in v0.24.0

func MetadataRoutePath(resource, metadataPath string) string

MetadataRoutePath returns the path at which the RFC 9728 metadata for resource is served (RFC 9728 §3.1): metadataPath, or configx.DefaultResourceMetadataPath when it is empty, followed by the resource identifier's own path. A resource with no path adds nothing (and configx refuses "/" alone, the only slash RFC 9728 §3.1 would drop); any other path is appended unchanged, escaping and trailing "/" included: https://api.example.com/v1/ maps to /.well-known/oauth-protected-resource/v1/. The server mounts the document at this path, and challenges point clients at MetadataURL, which carries the same path.

resource must be a valid resource identifier: anything configx.ParseResourceIdentifier refuses (a non-https or mixed-case URI, a character outside RFC 3986, an "=" or ",", userinfo, a query or fragment, "/" alone after the host, an encoded "/", or an empty, "." or ".." segment) yields "". So does a non-empty metadataPath that configx.ValidateMetadataPath refuses (not a canonical RFC 8615 well-known path of A-Za-z0-9._~/-), so the route, the URL and every challenge built from them only ever carry a path a block rs.Validate() accepts.

func MetadataURL added in v0.24.0

func MetadataURL(resource, metadataPath string) string

MetadataURL returns the URL of the RFC 9728 metadata document for resource (RFC 9728 §3.1): the resource's scheme and host, then MetadataRoutePath. https://api.example.com/v1 with the default path maps to https://api.example.com/.well-known/oauth-protected-resource/v1. It returns "" for anything configx.ParseResourceIdentifier refuses, and for a non-empty metadataPath configx.ValidateMetadataPath refuses (see MetadataRoutePath), so a challenge never points a client at a made-up URL.

func RequireScopes added in v0.24.0

func RequireScopes(c *callerctx.Caller, required ...string) error

RequireScopes returns nil when c carries every scope in required, and an *InsufficientScopeError naming all of required otherwise, so a client that follows the challenge asks for the whole set at once. Every scope is required, as on a generated route. required must come from configuration (see InsufficientScopeError.Required).

func ResourceMetadataURL added in v0.24.0

func ResourceMetadataURL(rs *configx.ResourceServerConfig) string

ResourceMetadataURL returns MetadataURL for rs's resource and effective metadata path (configx.ResourceServerConfig.EffectiveMetadataPath), or "" for a nil or disabled rs (no document is served, so there is nothing to point a client at), an invalid resource or an invalid metadata path. It is the only source of a challenge's resource_metadata (see NewChallenge).

Types

type Challenge added in v0.24.0

type Challenge struct {
	// Error is one of the ErrorCode constants, or empty for a request that
	// carried no credentials (RFC 6750 §3.1 gives that no error code). Any
	// other value is not rendered, nor is anything that needs an error.
	Error string
	// ErrorDescription is one of the Description constants that describes
	// Error. Any other text is not rendered.
	ErrorDescription string
	// Scope lists the scopes that would satisfy the request (RFC 6750 §3),
	// with any error or none (the MCP scope-selection strategy reads it from
	// the first 401). It is rendered sorted and once each, since the order
	// of scopes carries no meaning (RFC 6749 §3.3). A value that is not a
	// challenge token (configx.IsChallengeToken: an RFC 6749 scope-token
	// with no "=" or ",") is dropped.
	//
	// Take the scopes only from configuration (security.resource_server
	// .scopes_supported, a route's declared scopes), never from the token,
	// its claims or the request. The filter keeps a value from breaking the
	// header, not from saying something false: a client requests whatever
	// scope a challenge names, so a scope an attacker could choose would
	// steer the client's next token request.
	Scope []string
	// ACRValues lists the authentication context class references the
	// resource accepts, in order of preference (RFC 9470 §3), so they are
	// rendered in the given order, once each, and only beside
	// ErrorCodeInsufficientUserAuthentication. A value that is not a
	// challenge token (configx.IsChallengeToken) is dropped. Like Scope, take
	// them only from configuration (security.resource_server
	// .step_up_acr_values), never from the token, its claims or the request.
	ACRValues []string
	// ResourceMetadata is the RFC 9728 §5.1 URL of the resource's metadata
	// document. Take it only from ResourceMetadataURL, as NewChallenge does;
	// never build it by hand. It is rendered only when it is a challenge
	// token (configx.IsChallengeToken: printable ASCII with no space, '"',
	// '\', "=" or ","), which ResourceMetadataURL always is for a block
	// rs.Validate() accepts (it returns "" for a resource or metadata path
	// configx refuses), and dropped whole otherwise.
	ResourceMetadata string
}

Challenge is a Bearer WWW-Authenticate challenge (RFC 6750 §3) with the RFC 9470 step-up and RFC 9728 §5.1 resource_metadata parameters.

A bare 401 tells a client only "no". A challenge tells it what went wrong (Error), what would satisfy the request (Scope, ACRValues) and where to learn how to get a suitable token (ResourceMetadata), so an agent can recover instead of retrying a rejected token. Start from NewChallenge, which takes resource_metadata from configuration, then set the error and the requirements; Header renders the challenge (always with ChallengeRealm) and Status gives the HTTP status that carries it. Send the two together with pkg/securex's WriteChallenge, which picks the writer from the status.

Only the Bearer scheme is rendered. DPoP (RFC 9449) is out of scope: it needs proof-of-possession checks nothing performs. So is max_age (RFC 9470 §3): nothing configures or enforces how recently the user authenticated, and asking for it would be a false requirement. RFC 9470 defines no challenge parameter for authentication methods, so a token that misses security.resource_server.step_up_amr_values is answered with ErrorCodeInsufficientUserAuthentication alone, or beside the acr_values when step_up_acr_values is configured too.

func NewChallenge added in v0.24.0

func NewChallenge(rs *configx.ResourceServerConfig) Challenge

NewChallenge returns the base challenge for rs: resource_metadata from ResourceMetadataURL(rs), which is "" (so the parameter is left out) when rs is nil or disabled, or has an invalid resource or metadata path. Its path is the one the server mounts the document at (MetadataRoutePath). Callers then set Error, ErrorDescription, Scope and ACRValues.

func (Challenge) Header added in v0.24.0

func (c Challenge) Header() string

Header renders the challenge as a WWW-Authenticate field value, such as

Bearer realm="api", error="insufficient_scope", scope="traces:read", resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"

The parameters come in a fixed order (realm, error, error_description, scope, acr_values, resource_metadata), each at most once and each a quoted-string with '"' and '\' escaped (RFC 9110 §5.6.4). realm is always there, and always ChallengeRealm, since RFC 6750 §3 requires at least one auth-param after the scheme. Any other parameter with nothing valid to say is left out rather than rendered empty: some clients, the MCP Go SDK among them, refuse an empty value.

No field can inject a parameter or a header line (CWE-113), for a strict RFC 9110 parser or a naive one. The result is one line of printable ASCII: no CR, LF or other control character can reach it. And no rendered value holds '"', '\', "=" or ",": the realm, error codes and descriptions are fixed texts without them, and scope, acr_values and resource_metadata keep only configx.IsChallengeToken values. That matters because the MCP TypeScript and Python SDKs read a parameter with an unanchored first-match regex, name=(?:"([^"]+)"|([^\s,]+)): a value holding "resource_metadata=https://evil.example/" would be read as the resource_metadata parameter itself. With no "=" in any value, the first "name=" such a parser finds is the parameter, and it reads the same value a strict parser does.

func (Challenge) Status added in v0.24.0

func (c Challenge) Status() int

Status returns the HTTP status that carries the challenge: 403 for insufficient_scope and 400 for invalid_request (RFC 6750 §3.1), and 401 for everything else, which is a request with no credentials, an invalid token, or an RFC 9470 insufficient_user_authentication. pkg/securex's WriteChallenge(w, err, c.Status(), c.Header()) writes the 401 and the 403 with the matching writer (WriteUnauthorizedChallenge, WriteForbiddenChallenge). securex has no 400 writer, and WriteChallenge fails closed to a 401 with MinimalBearerChallenge for any other status, so a caller that answers invalid_request with its 400 writes that response itself.

type HTTPClient

type HTTPClient interface {
	Do(*http.Request) (*http.Response, error)
}

HTTPClient is the minimal interface oidcx needs to fetch JWKS over HTTP. Tests and callers using a non-default transport (mTLS, proxy, custom timeout) inject their own client via WithHTTPClient.

The signature deliberately mirrors *http.Client.Do so callers can pass *http.Client or any wrapper (e.g. an mTLS or instrumented client) that already satisfies that contract. It is also exactly the shape httprc/v3 expects, so the cache passes it through without an adapter.

type InsufficientScopeError added in v0.24.0

type InsufficientScopeError struct {
	// Required lists every scope the request needs. It must come from
	// configuration (the route's declared scopes, or
	// security.auth.default_required_scopes), never from the token or the
	// request: a client asks for whatever scope a challenge names.
	Required []string
}

InsufficientScopeError reports that a verified caller lacks a scope the request needs. A gate returns it (RequireScopes builds it) and Challenge answers it with RFC 6750 §3.1 insufficient_scope, status 403, naming Required in the challenge's scope parameter.

func (*InsufficientScopeError) Error added in v0.24.0

func (e *InsufficientScopeError) Error() string

Error implements error without naming any scope.

func (*InsufficientScopeError) Is added in v0.24.0

func (e *InsufficientScopeError) Is(target error) bool

Is reports whether target is ErrInsufficientScope.

type JWKCache

type JWKCache struct {
	// contains filtered or unexported fields
}

JWKCache is an auto-refreshing JWK Set cache. The zero value is not usable; construct one with NewJWKCache.

func NewJWKCache

func NewJWKCache(ctx context.Context, jwksURL string, opts ...Option) (*JWKCache, error)

NewJWKCache creates a cache for the given JWKS URL and primes it by performing an initial Refresh against the IdP. It returns an error if the cold-start fetch fails or the response is empty.

ctx is the cache's lifetime, not a construction deadline: the background refresh workers run until it is done, and from then on Set and Refresh return ErrJWKCacheStopped. Pass the host process's (or server's) lifetime context. The cold-start fetch is bounded by WithFetchTimeout; cancelling ctx also aborts it, but leaves no usable cache.

func (*JWKCache) Healthy

func (c *JWKCache) Healthy() bool

Healthy reports whether the cache is within the configured max-staleness window. Always true when WithMaxStaleness was not supplied. APPSEC-3.

func (*JWKCache) Refresh

func (c *JWKCache) Refresh(ctx context.Context) (jwk.Set, error)

Refresh forces an immediate refresh of the cached JWK set, whatever the minimum refresh interval says, so a caller must rate-limit it. Intended for tests, key-rotation event handlers, admin-triggered refresh endpoints, and the ResourceVerifier's rate-limited refresh when a token names a kid the set lacks. Like Set, it returns ErrJWKCacheStopped once the cache's lifetime context is done.

Errors from the underlying refresh are also reported to any logger supplied via WithLogger so the caller's observability pipeline sees the same failure surface that the background refresher emits via the error sink. The synchronous Refresh returns the error directly rather than forwarding it to that sink, so without this hop the logger would miss manual refresh failures and admin-triggered retries.

func (*JWKCache) Set

func (c *JWKCache) Set(ctx context.Context) (jwk.Set, error)

Set returns the cached JWK set. It never fetches: the background refresher replaces the set on its own schedule (see WithMinRefreshInterval and WithMaxRefreshInterval), and callers receive the cached set while a refresh is in progress. If a refresh fails, the cache continues to serve the last-good set so authentication does not flap during transient IdP outages -- subject to the WithMaxStaleness cap, which when set causes Set to return ErrJWKSStaleExceeded rather than indefinitely extend the trust window. APPSEC-3.

Once the cache's lifetime context is done, Set returns ErrJWKCacheStopped at once rather than waiting on refresh workers that have exited; ctx still bounds the lookup otherwise.

func (*JWKCache) URL

func (c *JWKCache) URL() string

URL reports the JWKS URL the cache is bound to.

type Logger

type Logger interface {
	Warn(msg string, args ...any)
	Error(msg string, args ...any)
}

Logger is the structured logging contract the cache uses to surface refresh failures and stale-fallback events. Callers wire their own logger (slog, obsx audit, log.Logger) by passing WithLogger. Keeping this an interface rather than a concrete type lets oidcx stay free of any logging dependency.

type Option

type Option func(*config)

Option configures a JWKCache or a ResourceVerifier at construction time. A ResourceVerifier builds one JWKCache per authorization server with the options it was given, and fetches authorization-server metadata with the same HTTP client policy; WithCallerKindRules applies to a ResourceVerifier only, and NewJWKCache ignores it.

func WithAcceptableSkew added in v0.24.0

func WithAcceptableSkew(d time.Duration) Option

WithAcceptableSkew sets the clock difference a ResourceVerifier tolerates when it checks exp, nbf and iat (DefaultAcceptableSkew without this option). A negative value is zero, and a value over MaxAcceptableSkew is that cap. NewJWKCache ignores this option.

func WithAllowedTokenTypes added in v0.24.0

func WithAllowedTokenTypes(types ...string) Option

WithAllowedTokenTypes widens the JOSE typ header values a ResourceVerifier accepts beyond AccessTokenType (RFC 9068 §4). Without it a token with no typ, or with "at+jwt" / "application/at+jwt", is accepted and any other typ is refused, so an ID token, a logout token or a security event token (which carry id_token+jwt, logout+jwt, secevent+jwt, or the generic JWT) cannot pass for an access token. An authorization server that labels its access tokens with the generic "JWT" (golang-jwt's default, and ThreadID's, Auth0's classic profile, Keycloak and Azure AD) needs WithAllowedTokenTypes("JWT"); its access tokens are then told from its other tokens by aud and, with require_resource_claim, resource alone. Each value is compared as a media type (RFC 7515 §4.1.9): without regard to case, and with "application/" implied when it holds no "/" (configx.TokenMediaType). An empty value is ignored. A generated server passes the accepted_token_types member of security.resource_server, which configx validates. NewJWKCache ignores this option.

func WithCallerKindRules added in v0.24.0

func WithCallerKindRules(rules []configx.CallerKindRule) Option

WithCallerKindRules sets the security.auth.caller_kind_rules a ResourceVerifier classifies callers with (configx.ClassifyCaller). With no rules, or without this option, it applies configx.DefaultCallerKindRules, which never yields a human caller. NewResourceVerifier validates the rules after the resource server, under that server's pins (configx.SecurityConfig.ValidateCallerKindRules), and refuses a list that fails. NewJWKCache ignores this option.

func WithFetchTimeout

func WithFetchTimeout(d time.Duration) Option

WithFetchTimeout overrides the per-request HTTP timeout applied when fetching the JWKS document (and, for a ResourceVerifier, each authorization-server metadata document). Defaults to DefaultFetchTimeout. It is what bounds construction: NewJWKCache and NewResourceVerifier take their context as the cache's lifetime, not as a construction deadline, so a caller that wants a bounded boot sets this rather than cancelling that context. Values <= 0 fall back to the default rather than disabling the timeout: a permanently-disabled timeout was the APPSEC-7 root cause and is not user-recoverable from the cache contract. Callers that need an unusual timeout policy should inject their own http.Client via WithHTTPClient; that client's Timeout is preserved verbatim. APPSEC-7.

func WithHTTPClient

func WithHTTPClient(c HTTPClient) Option

WithHTTPClient configures the underlying HTTP client used to fetch the JWKS document. Defaults to http.DefaultClient via jwx.

SEC-0057 (GitLab #311): NewJWKCache always enforces "never follow a JWKS redirect, never fetch without a deadline". When c is a *http.Client, NewJWKCache rebuilds a client that keeps c's Transport but always forces CheckRedirect and a finite Timeout, so an injected client's own (possibly permissive) redirect/timeout policy no longer reopens this gap. When c is any other HTTPClient implementation, NewJWKCache cannot see or rewrite its internal policy and uses it as-is -- see NewJWKCache's doc for that narrow, documented exception.

func WithLogger

func WithLogger(l Logger) Option

WithLogger registers a structured logger that receives a Warn entry on every refresh failure followed by an Error entry if the cache cannot serve any prior key set (cold-start failure).

func WithMaxRefreshInterval added in v0.24.0

func WithMaxRefreshInterval(d time.Duration) Option

WithMaxRefreshInterval overrides DefaultMaxRefreshInterval, the longest the background refresher waits between two fetches of the JWKS whatever the IdP's caching headers say. A value <= 0 falls back to the default, and one below the minimum refresh interval is raised to it.

func WithMaxStaleness

func WithMaxStaleness(d time.Duration) Option

WithMaxStaleness configures the upper bound on how stale the cached JWK set may become before JWKCache.Set starts returning ErrJWKSStaleExceeded. Counted from the last successful fetch. Zero (the default) disables the cap, preserving the legacy "serve last-good indefinitely" behaviour. Operators running security-sensitive workloads should set this to a value larger than the JWKS rotation cadence but smaller than the access-token lifetime so an extended IdP outage cannot indefinitely extend the trust window. APPSEC-3.

func WithMinRefreshInterval

func WithMinRefreshInterval(d time.Duration) Option

WithMinRefreshInterval overrides DefaultMinRefreshInterval. Values below 1 second are clamped to 1 second to avoid pathological refresh storms; values above 24 hours are accepted as-is so operators can extend the floor for tightly cost-controlled environments.

type ProtectedResourceMetadata added in v0.24.0

type ProtectedResourceMetadata struct {
	// Resource is the protected resource's identifier (RFC 8707).
	Resource string `json:"resource"`
	// AuthorizationServers lists the issuer identifiers of the authorization
	// servers whose tokens the resource accepts, in configured order. The
	// order matters: MCP clients (the TypeScript and Go SDKs) use the first
	// entry, so the operator's primary authorization server goes first.
	AuthorizationServers []string `json:"authorization_servers,omitempty"`
	// ScopesSupported lists the scopes the resource understands, sorted.
	ScopesSupported []string `json:"scopes_supported,omitempty"`
	// BearerMethodsSupported lists how a client may present a bearer token:
	// always ["header"], the only method apic reads.
	BearerMethodsSupported []string `json:"bearer_methods_supported,omitempty"`
	// ResourceName is the human-readable name for display to end users. It
	// is left out of the document when the configuration sets none.
	ResourceName string `json:"resource_name,omitempty"`
	// TLSClientCertificateBoundAccessTokens advertises RFC 8705
	// certificate-bound access tokens (RFC 9728 §2). It is true only when
	// security.resource_server.tls_client_certificate_bound_access_tokens
	// is set, and so the verifier refuses an unbound token; otherwise it is
	// left out.
	TLSClientCertificateBoundAccessTokens bool `json:"tls_client_certificate_bound_access_tokens,omitzero"`
	// contains filtered or unexported fields
}

ProtectedResourceMetadata is the RFC 9728 OAuth 2.0 Protected Resource Metadata document. A client that receives a 401 with a resource_metadata challenge parameter fetches it to learn which authorization server to get a token from and which resource to request it for, which turns an opaque rejection into an actionable one.

It carries no jwks_uri member. RFC 9728 §2 defines jwks_uri as the protected resource's OWN signing keys, which apic does not support, and the key set that verifies access tokens (security.resource_server.token_jwks_uri) is never published in its place. It carries tls_client_certificate_bound_access_tokens only when security.resource_server.tls_client_certificate_bound_access_tokens is set, which the resource verifier enforces (every token must be bound to the presented client certificate, RFC 8705 §3): certificate-bound tokens are never advertised without enforcement.

func NewProtectedResourceMetadata added in v0.24.0

func NewProtectedResourceMetadata(rs *configx.ResourceServerConfig) (*ProtectedResourceMetadata, error)

NewProtectedResourceMetadata builds the document from an enabled, valid security.resource_server block. It returns ErrResourceServerDisabled for an absent or disabled block, and the block's validation error (wrapping configx.ErrInvalidResourceServer) for an invalid one.

The document is marshaled once, here, with the fields in a fixed order and the scopes sorted, so the same configuration serves the same bytes across restarts: a moving document defeats client caching and makes diffs meaningless. The authorization servers keep their configured order, since clients use the first one (configx already refuses a repeated issuer).

func (*ProtectedResourceMetadata) Handler added in v0.24.0

func (m *ProtectedResourceMetadata) Handler() http.Handler

Handler serves the document. It is deliberately unauthenticated: RFC 9728 §3 requires the metadata to be publicly readable, and it holds only what a client needs before it can hold a token. GET returns the document as application/json; HEAD returns the same headers without the body (RFC 9110 §9.1 has a server support HEAD wherever it supports GET). Both carry MetadataCacheControl, a strong ETag, X-Content-Type-Options: nosniff and Access-Control-Allow-Origin: * (the document is public and credential-free, and a browser MCP client falls back to a plain GET that needs it). An If-None-Match that lists the ETag (weak comparison, RFC 9110 §13.1.2) or is "*" gets 304 with no body. OPTIONS answers a CORS preflight from any origin with 204, Access-Control-Allow-Origin: *, Access-Control-Allow-Methods: GET, HEAD and Access-Control-Allow-Headers: MetadataPreflightHeaders (no credentials are ever allowed). Any other method gets 405 with Allow: GET, HEAD, OPTIONS. A handler for a nil or hand-built value (not from NewProtectedResourceMetadata) serves 404: it has no document to publish.

type ResourceVerifier added in v0.24.0

type ResourceVerifier struct {
	// contains filtered or unexported fields
}

ResourceVerifier authenticates requests to an OAuth 2.0 protected resource (security.resource_server): it accepts only an access token that one of the configured authorization servers signed, that names this resource in its audience and, with require_resource_claim, in its RFC 8707 resource claim, and that meets the RFC 9470 step-up requirements. It maps the token to a callerctx.Caller, and Challenge maps its errors to the WWW-Authenticate challenge a gate sends with securex.WriteChallenge.

It is safe for concurrent use. Everything it reads from configuration is copied at construction, so a later change to the config block has no effect, and nothing it returns shares memory with it.

func NewResourceVerifier added in v0.24.0

func NewResourceVerifier(ctx context.Context, rs *configx.ResourceServerConfig, opts ...Option) (*ResourceVerifier, error)

NewResourceVerifier builds the verifier for rs. It refuses an absent or disabled block (ErrResourceServerDisabled), then validates rs and only then the caller-kind rules (WithCallerKindRules), returning the first error (wrapping configx.ErrInvalidResourceServer or configx.ErrInvalidCallerKindRules).

It then resolves the key set of every configured authorization server, before it returns, so every authorization server must be reachable at boot, and each must publish at least one RSA, EC or OKP key (an issuer whose set holds none fails construction, naming the issuer and wrapping ErrEmptyJWKSet); nothing is ever fetched for an issuer that is not configured: Verify selects a key set by an exact-match lookup of the token's iss among exactly these issuers. The jwks_uri each issuer resolves to is fixed for the verifier's life. With security.resource_server.token_jwks_uri, which configx allows beside a single issuer only, that issuer's key set is token_jwks_uri. Otherwise each issuer's jwks_uri comes from its own authorization-server metadata (RFC 8414, then OpenID Connect Discovery), which must name the issuer exactly and an https jwks_uri; an issuer that cannot be discovered fails construction, wrapping ErrDiscovery. Each key set is a JWKCache, built with opts (WithHTTPClient, WithFetchTimeout, WithMaxStaleness, ...), which also govern the discovery fetches: redirects are never followed and every fetch has a deadline.

Each key set refreshes in the background, at least every WithMaxRefreshInterval (24 hours by default), and when a token from a trusted issuer names a kid that issuer's set lacks, Verify re-fetches that one set first, at most once per issuer per KeyMissRefreshInterval, so a rotated-in key verifies on first sight.

ctx is the verifier's lifetime, not a construction deadline: the key-set refreshers run until it is done, and from then on Verify fails every token with ErrKeySetUnavailable. Pass the server's lifetime context, and bound construction with WithFetchTimeout, which caps every fetch it makes (up to three metadata documents and one key set per issuer).

func (*ResourceVerifier) Authenticate added in v0.24.0

func (v *ResourceVerifier) Authenticate(r *http.Request) error

Authenticate verifies r (Verify) and, on success, records the caller on r's context in place, with securex.ContextWithVerifiedCaller, so the route's caller (securex.CallerFromRequest) is the one the verifier mapped and RBAC, ownership and GraphQL authorization read claims built from the same verified claim set; nothing re-parses the token. It has the shape of a jwt verifier (func(*http.Request) error), so it backs a composite route's jwt leaf through securex.GateJWT as well as a single-mode route. On failure it returns Verify's error, for WriteError, and changes nothing.

func (*ResourceVerifier) Challenge added in v0.24.0

func (v *ResourceVerifier) Challenge(err error) Challenge

Challenge returns the WWW-Authenticate challenge for err, one of the errors Verify returns or an *InsufficientScopeError. Send it with securex.WriteChallenge(w, err, c.Status(), c.Header()). Every challenge carries the configured resource_metadata (oidcx.ResourceMetadataURL).

nil, ErrNoToken,
securex.ErrNoBearerToken             no error code (RFC 6750 §3.1), 401
securex.ErrIdentityConflict          no error code, 401 (WriteError answers it
                                     with 403 and no challenge instead)
ErrTokenExpired                      invalid_token, "The access token expired", 401
ErrIssuerNotTrusted,
ErrAudienceMismatch,
ErrResourceMismatch                  invalid_token, "... not valid for this resource", 401
any other error                      invalid_token, "The access token is invalid", 401
ErrInsufficientScope                 insufficient_scope with scope = Required, 403
ErrStepUpRequired                    insufficient_user_authentication with the
                                     configured acr_values, 401

scope and acr_values come only from configuration, never from the token: Scope is the InsufficientScopeError's Required, and ACRValues is step_up_acr_values, each a fresh copy per call, so a caller may change the returned challenge freely. A failed step_up_amr_values check gets only the error code, beside acr_values when those are configured too (RFC 9470 has no parameter for methods). No other error code is produced: in particular never invalid_request, which would need a 400 securex.WriteChallenge does not write.

func (*ResourceVerifier) Issuers added in v0.24.0

func (v *ResourceVerifier) Issuers() []string

Issuers returns the issuer identifiers v trusts (security.resource_server.authorization_servers at construction, in configured order), or nil for a nil v. The slice is a copy: changing it changes nothing in v. The generated Serve refuses to start when a verifier supplied with WithResourceVerifier trusts issuers other than runtime.json's authorization_servers (the RFC 9728 document would name one set of authorization servers while tokens from another were accepted), or trusts two or more beside a feature that keys a caller on its subject alone (RFC 9068 makes sub unique only per issuer; the APIC-UP-007 unit review, I-2).

func (*ResourceVerifier) RequiresCertificateBinding added in v0.24.0

func (v *ResourceVerifier) RequiresCertificateBinding() bool

RequiresCertificateBinding reports whether v refuses every access token not bound to the presented client certificate (security.resource_server.tls_client_certificate_bound_access_tokens at construction; RFC 8705 §3), or false for a nil v. The generated Serve refuses to start when a verifier supplied with WithResourceVerifier disagrees with runtime.json in either direction: the RFC 9728 document, built from runtime.json, would advertise certificate-bound tokens a verifier that does not require them never enforces, or a verifier that requires them would refuse tokens the document never asked a client to bind.

func (*ResourceVerifier) Resource added in v0.24.0

func (v *ResourceVerifier) Resource() string

Resource returns the resource identifier v verifies tokens for (security.resource_server.resource at construction), or "" for a nil v. The generated Serve refuses to start when a verifier supplied with WithResourceVerifier names a resource other than runtime.json's: the metadata document and every challenge would describe one resource while tokens were checked against another.

func (*ResourceVerifier) Verify added in v0.24.0

func (v *ResourceVerifier) Verify(r *http.Request) (*callerctx.Caller, error)

Verify authenticates r as a request to this protected resource and returns the mapped caller. It fails closed at every step, in this order:

  1. The token comes from the one Authorization header only, Bearer scheme in any case (RFC 7235 §2.1). No such header, or another scheme, is ErrNoToken. Two Authorization headers, empty Bearer credentials, or an access_token in the query (a key the raw query names between any "&" or ";" separators) or in an already-parsed form (RFC 6750 §2.2, §2.3; this resource advertises the header method only) are refused. The body is never read.
  2. Before any signature work, the token must be at most securex.MaxJWTBytes of compact JWS (three base64url segments), and only its header and payload JSON are decoded, once.
  3. Its unverified iss must equal one configured authorization server exactly (RFC 8414 §3.3), or it is ErrIssuerNotTrusted; nothing is fetched for any other issuer.
  4. Its typ header, when present, must name an access token (RFC 9068 §4: at+jwt, or a type WithAllowedTokenTypes adds).
  5. A kid header that is present must be a non-empty string. When the header names a kid, only the keys of the issuer's set with that kid may verify it. When the set has none, it is re-fetched first, at most once per issuer per KeyMissRefreshInterval, and the request waits for that refresh at most the smaller of WithFetchTimeout and two seconds (a token's jku or x5u is never followed); a kid still unknown is tried under the set's keys that carry no kid, never under another kid's, and refused when there are none. A token with no kid is tried under every key. The signature must verify under the set's RSA, EC or OKP keys with an asymmetric algorithm (never none or HMAC; securex.VerifyJWTWithJWKS), and exp must be present and unexpired (ErrTokenExpired otherwise), with exp, nbf and iat checked under WithAcceptableSkew; iss is checked again on the verified token.
  6. aud must name this resource (ErrAudienceMismatch), and so must the RFC 8707 resource claim when it is present or require_resource_claim is set (ErrResourceMismatch).
  7. The verified claims map to the caller. A mapped claim of the wrong JSON type, a control character in an identity or authorization claim (amr and the attributes included), a scope that is not an RFC 6749 scope-token, a top-level tenant or namespace that disagrees with its attributes entry, or an act chain deeper than 16 links is refused.
  8. Certificate binding (RFC 8705 §3): a token whose cnf claim carries an x5t#S256 thumbprint must arrive over a TLS connection whose client certificate has that SHA-256 thumbprint, and a cnf claim that is not an object, or names any other confirmation method (DPoP's jkt, a jwk, ...), is refused, since apic verifies no other proof of possession; with tls_client_certificate_bound_access_tokens a token without the binding is refused too (ErrCertificateBinding).
  9. Step-up: acr must be one of step_up_acr_values and amr must hold one of step_up_amr_values, each when configured, or it is a *StepUpError.

On any error the caller is nil.

func (*ResourceVerifier) WriteError added in v0.24.0

func (v *ResourceVerifier) WriteError(w http.ResponseWriter, err error)

WriteError answers a request a resource-server gate refused with err (an error Verify or Authenticate returned, an *InsufficientScopeError from RequireScopes, or a composite route's securex.BearerCause), in this order:

  1. errors.Is(err, ErrKeySetUnavailable), tested first because it wraps ErrInvalidToken: 503 with Retry-After (KeySetRetryAfter), through securex.WriteServiceUnavailable, and no challenge. The token was not judged, so telling the client it is invalid would send it for a new one it does not need.
  2. errors.Is(err, securex.ErrIdentityConflict), a composite AND-group whose credentials named two principals: 403 with no challenge (securex.WriteForbiddenChallenge with none, which also drops a stale WWW-Authenticate). No token failed, so invalid_token would have an OAuth client discard a valid one and fetch another that the same pairing refuses again; 403 tells it not to repeat the request with these credentials (RFC 9110 §15.5.4).
  3. Otherwise c := v.Challenge(err), sent with securex.WriteChallenge(w, err, c.Status(), c.Header()): 401 with the challenge for no token (no error code), an invalid, expired or wrong-resource token (invalid_token) and a step-up failure (insufficient_user_authentication), and 403 insufficient_scope with the scopes the route requires.

Every challenge names the RFC 9728 metadata document. err reaches the audit sink only, never the response. A nil v answers with the realm-only challenges of a server without a resource-server block.

type StepUpError added in v0.24.0

type StepUpError struct {
	// ACRValues is a copy of security.resource_server.step_up_acr_values:
	// the authentication context classes, one of which would satisfy the
	// request. Empty when only AMR values are configured.
	ACRValues []string
	// AMRValues is a copy of security.resource_server.step_up_amr_values:
	// the authentication methods, one of which would satisfy the request.
	// RFC 9470 has no challenge parameter for them.
	AMRValues []string
	// ACRUnmet and AMRUnmet report which requirement the token missed.
	ACRUnmet, AMRUnmet bool
}

StepUpError reports that a verified token's authentication does not meet the resource's RFC 9470 step-up requirements. Verify returns it with a nil caller, never a caller the gate might mistake for an authenticated one. Challenge answers it with insufficient_user_authentication, status 401, and the configured step_up_acr_values as acr_values.

func (*StepUpError) Error added in v0.24.0

func (e *StepUpError) Error() string

Error implements error without naming the token's own acr or amr.

func (*StepUpError) Is added in v0.24.0

func (e *StepUpError) Is(target error) bool

Is reports whether target is ErrStepUpRequired.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL