Documentation
¶
Overview ¶
Package authn validates INBOUND JWT bearer tokens at a resource server.
It performs NO OAuth flows and issues nothing: there is no token endpoint, no authorization-code exchange, and no signing of tokens. The package only verifies bearer tokens presented by clients against configured keys and trusted issuer/audience policy.
Failures from Validate and ParseBearer are expressed as *Error (see errors.go), which carries a client-safe Code and Reason alongside a detail string intended for logging. NewValidator, by contrast, returns ordinary construction errors.
Index ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func ParseBearer ¶
ParseBearer extracts the token from an Authorization header value. The scheme match is CASE-INSENSITIVE per RFC 7235 §2.1 (this fixes ToolHive's case-sensitive match at pkg/auth/utils.go:45-47).
The missing-vs-malformed split lets callers distinguish "no credentials" (a bare WWW-Authenticate challenge, RFC 6750 §3.1) from "broken credentials" (error="invalid_request", 400): an empty value yields ReasonMissingHeader; anything else malformed — not exactly `Bearer <token>`, extra whitespace segments, an empty token part, or a token longer than maxTokenLength — yields ReasonMalformed.
All ParseBearer failures use Code CodeInvalidRequest (400), including ReasonMalformed. The ReasonMalformed value here is a malformed *header*, not a possibly-opaque token, so unlike Validate's CodeInvalidToken + ReasonMalformed it must NOT feed ToolHive's introspection fallback (see errors.go).
NO error returned here contains any byte of the header value. Error() is documented as log-safe, and a malformed header routinely still carries a live credential — "Bearer <token> junk", or a token sent with no scheme at all would put a replayable token into the logs (RFC 6750 §5 names token disclosure as a primary threat). Errors therefore report only the structural category and, where useful, a length.
func PossiblyOpaque ¶
PossiblyOpaque reports whether err indicates the token could not be parsed as a JWT at all, and so may be an opaque token worth validating via RFC 7662 introspection.
It is true only for a Validate failure (Code == CodeInvalidToken) with Reason == ReasonMalformed; ParseBearer's CodeInvalidRequest failures are a broken request, not a possibly-opaque token, and report false. A nil err also reports false.
Verified parity note: a token that is three base64url segments whose header decodes to JSON without an "alg" member yields ReasonUnsupportedAlg, not ReasonMalformed, so PossiblyOpaque reports false for it. That matches ToolHive's existing behavior: ToolHive keys its fallback on jwt.ErrTokenMalformed, while golang-jwt reports a missing alg as jwt.ErrTokenUnverifiable. Neither implementation introspects that shape.
Types ¶
type Code ¶
type Code string
Code is the OAuth2 error code, safe to send to a client.
It corresponds to the "error" parameter defined in RFC 6750 Section 3.1 and carries the coarse response semantics (the HTTP status is implied by the value).
const ( // CodeInvalidRequest is the OAuth2 "invalid_request" code (HTTP 400). CodeInvalidRequest Code = "invalid_request" // 400 // CodeInvalidToken is the OAuth2 "invalid_token" code (HTTP 401). CodeInvalidToken Code = "invalid_token" // 401 // HTTP 503. It is distinct from an invalid token: the verifier could not // make a determination because its inputs (e.g. the JWKS endpoint) were // unreachable. // // Unlike CodeInvalidRequest and CodeInvalidToken, CodeUnavailable is NOT a // registered RFC 6750 §3.1 error value (that section defines exactly // invalid_request, invalid_token, insufficient_scope). It is an internal // discriminator: map it to a plain HTTP 503 and do not emit it as an // OAuth2 error= parameter. CodeUnavailable Code = "unavailable" // 503 — key material unreachable )
type Config ¶
type Config struct {
// Issuer is the expected iss claim value. When JWKSURL is empty it is
// also the base for OIDC discovery ({Issuer}/.well-known/openid-configuration).
// Must be an https URI unless InsecureAllowHTTP is set.
//
// At least one of Issuer or JWKSURL is required. Leaving Issuer EMPTY
// (only legal alongside an explicit JWKSURL) DISABLES iss verification
// entirely: any issuer that can present a key from the configured JWKS is
// accepted. Only do this when the JWKS endpoint itself is the trust
// boundary.
Issuer string
// Audiences lists acceptable aud claim values; a token is accepted when
// ANY of its aud values matches ANY entry here.
//
// Each entry is compared byte-exact against the token's aud values and is
// validated only as a bounded, control-character-free string: RFC 7519
// §4.1.3 types aud as StringOrURI, so bare identifiers (an OAuth
// client_id) and opaque GUIDs (Entra ID) are conformant and accepted.
//
// Required and non-empty unless AllowAnyAudience is set.
Audiences []string
// AllowAnyAudience disables audience verification. It exists so a
// deployment with no audience policy can be expressed explicitly rather
// than by leaving Audiences unset, since a silently absent audience check
// is how confused-deputy bugs reach production: any token the issuer minted
// for ANY relying party is accepted by this resource server.
//
// Setting it together with a non-empty Audiences is an error, so the two
// cannot silently disagree.
AllowAnyAudience bool
// JWKSURL, when set, points directly at the JWKS endpoint and skips OIDC
// discovery. Optional. Must be an https URI unless InsecureAllowHTTP is
// set.
JWKSURL string
// Leeway is the clock-skew tolerance applied to exp/nbf/iat. Zero uses
// the default of 60s; negative is an error; values above 2m are an
// error.
Leeway time.Duration
// DisableLeeway sets the clock-skew tolerance to zero, so exp is enforced
// exactly. It exists because Leeway == 0 means "use the 60s default", which
// left no way to ask for no tolerance at all. Setting it together with a
// non-zero Leeway is an error, so the two cannot silently disagree.
//
// IMPORTANT for anyone setting this for parity with a validator that has no
// leeway: it applies to nbf and iat as well as exp, and this package checks
// iat (a token issued in the future beyond tolerance is rejected) where many
// validators do not check it at all. So NEITHER setting is exact parity with
// such a validator:
//
// - set → exp and nbf match exactly, but iat becomes zero-tolerance
// where the other validator ignores iat entirely, so IdP clock
// skew that was previously invisible starts failing.
// - unset → 60s of slack on exp that the other validator does not give.
//
// Pick based on which mismatch your deployment can absorb. There is
// deliberately no separate iat opt-out: adding one before a concrete
// deployment needs it would be a speculative knob.
DisableLeeway bool
// MaxJWKSStaleness bounds how long cached JWKS key material stays trusted
// without a confirmed successful fetch. Negative is an error.
//
// Zero DISABLES the bound, and is the default. The bound exists because the
// cache keeps serving its last good key set after every failed refresh, so a
// key revoked at the IdP stays trusted for as long as the endpoint is
// unreachable — the trust window equals the outage length.
//
// Enabling it trades availability for that bound: once exceeded, validation
// fails with CodeUnavailable/ReasonKeysStale, so a prolonged IdP outage
// becomes an authentication outage instead of an invisible one. Pick a value
// that reflects how long you are willing to honour a revoked key; hours
// rather than minutes is usually the right order of magnitude.
//
// Freshness is self-correcting rather than schedule-based: when the bound is
// exceeded the validator attempts a refresh before rejecting anything, so a
// healthy endpoint never trips it regardless of when the background refresh
// last ran. It applies only to JWKS material — keys from a KeyProvider are
// resolved in-process and are never considered stale.
MaxJWKSStaleness time.Duration
// AcceptedTokenTypes, when non-empty, requires the token's `typ` header to
// match one of these media types. Comparison is case-insensitive and an
// `application/` prefix is optional on both sides (RFC 7515 §4.1.9), so
// "at+jwt" matches a token carrying "application/at+JWT".
//
// Empty (the default) accepts any `typ`, including none. It cannot default
// to requiring RFC 9068's "at+jwt": that is a SHOULD, and many IdPs emit
// "JWT" or omit the header entirely, so a default would reject conformant
// tokens.
//
// This guards against ID-token substitution — an ID token sharing the
// issuer, audience, subject and expiry of an access token. Note the AUDIENCE
// check is the primary defence there, since an ID token's aud is the
// client_id rather than the resource: this matters most when
// AllowAnyAudience is set, or when a deployment uses its client_id as the
// resource audience, because in those cases the audience check cannot tell
// the two kinds of token apart.
AcceptedTokenTypes []string
// MaxTokenLifetime rejects tokens whose exp-iat span exceeds it, when both
// claims are present. Negative is an error.
//
// Zero DISABLES the check, and is the default: a resource server that
// accepts long-lived tokens today (service-account credentials commonly
// outlive a day) must not start rejecting them merely by adopting this
// package. Set it explicitly to opt into a lifetime bound.
MaxTokenLifetime time.Duration
// HTTPClient is used for discovery and JWKS fetches. Leave it nil unless you
// need something the default cannot express: the default is built by
// toolhive-core's networking package and is secure by default (private-IP
// dial guard, 15s timeout, redirects refused, 1 MiB body cap).
//
// IMPORTANT — a supplied client OPTS OUT of the address-level SSRF guard.
// That guard lives in the transport's DialContext, which this package cannot
// retrofit onto a client it did not build; AllowPrivateIP and CACertPath are
// likewise ignored. Build your client with
// networking.NewHttpClientBuilder() (or install
// networking.NewPrivateIPBlockingDialContext yourself) or you will have a
// weaker configuration than the default.
//
// What IS still enforced for a supplied client: the 1 MiB response-body cap
// (its Transport is wrapped), plus redirect refusal and the 15s timeout when
// the caller left CheckRedirect / Timeout unset — so a bare &http.Client{}
// cannot silently drop them. Explicit caller values are preserved.
//
// To attach a bearer token to the OUTBOUND JWKS/discovery request (an IdP
// whose JWKS endpoint is itself gated), supply a client whose Transport
// injects the Authorization header. That RoundTripper must be the innermost
// one — this package wraps whatever it is given in its body-capping
// transport, so set it as your client's Transport and let the wrapping
// happen around it.
//
// If your IdP legitimately redirects its discovery or JWKS endpoint, set
// JWKSURL to the final target rather than relaxing the redirect policy.
HTTPClient *http.Client
// InsecureAllowHTTP permits http:// Issuer and JWKSURL values. It exists
// for development and test environments only; never set it in
// production.
InsecureAllowHTTP bool
// AllowPrivateIP permits discovery and JWKS fetches to reach private,
// loopback, and link-local addresses. It defaults to FALSE, which is what
// blocks a jwks_uri resolving to cloud instance metadata
// (169.254.169.254) or an in-cluster address.
//
// The check runs on the resolved address at dial time, so it also defends
// against DNS rebinding and re-applies per redirect hop. Set it only for an
// issuer that legitimately lives on a private network — an in-cluster OIDC
// provider, or a test server on localhost.
//
// It applies ONLY to the default client. A caller-supplied HTTPClient brings
// its own dial policy; see that field.
AllowPrivateIP bool
// CACertPath optionally points at a PEM CA bundle used to verify the
// discovery and JWKS endpoints, for an issuer fronted by a private CA.
//
// It applies ONLY to the default client, for the same reason as
// AllowPrivateIP.
CACertPath string
// AuthTokenFile optionally points at a file containing a bearer token to
// attach to the OUTBOUND discovery and JWKS requests, for an IdP whose own
// endpoints are gated behind auth (mirrors ToolHive's
// --jwks-auth-token-file). The networking package re-reads the file per
// request, so a rotated token is picked up without restarting the
// validator.
//
// It applies ONLY to the default client, for the same reason as
// AllowPrivateIP and CACertPath: it is implemented by networking's HTTP
// client builder, which cannot be retrofitted onto a caller-supplied
// HTTPClient.
AuthTokenFile string
// KeyProvider optionally supplies verification keys in-process, for an
// embedded issuer. It is consulted BEFORE the JWKS cache on every
// validation.
//
// A provider MISS falls through to the JWKS when one is configured. A
// provider REJECTION does not: if the provider offers a key for the token's
// kid but that key is unusable — a sub-2048-bit modulus, a declared alg that
// disagrees, a malformed key — validation fails immediately with
// ReasonKeyUnsupported and the JWKS is never consulted. An unusable key is a
// permanent condition, so reporting it beats masking it behind a fallback
// that would end up reporting an unknown kid instead.
//
// The practical effect is that an unusable provider key shadows a usable
// JWKS key under the same kid. That is accepted: in the only topology that
// has a provider, the provider and the JWKS are the same key source, so the
// two disagreeing means something is wrong that a silent fallback would
// hide.
//
// Setting it also relaxes construction, because an embedded issuer's JWKS
// endpoint is characteristically not reachable at the moment the Validator
// is built:
//
// - OIDC discovery failure becomes non-fatal.
// - The first JWKS fetch is no longer required to succeed; key material
// is fetched lazily by the background refresh instead.
//
// Until that first fetch lands, a token whose kid the provider does not
// offer fails with CodeUnavailable/ReasonKeysUnavailable rather than being
// rejected as invalid — the verifier could not make a determination.
//
// With no KeyProvider, construction stays fail-closed: discovery and the
// first JWKS fetch must both succeed or NewValidator returns an error.
KeyProvider KeyProvider
}
Config holds the trusted issuer/audience policy and fetch behavior for a Validator. A Config is validated eagerly by NewValidator so that a typo is a startup failure, not a 401 on every request.
func ValidateConfig ¶
ValidateConfig validates cfg and returns a normalized copy with defaults applied. It is exactly the static policy checking and defaulting NewValidator performs before it builds anything, exposed on its own.
It exists for a caller that defers construction. A resource server whose issuer may not be reachable yet has good reason to build its Validator lazily in the background — but that also defers every error, so a plain typo in the issuer or a contradictory audience policy would stop being a startup failure and become a per-request one, discovered from a log line rather than at deploy time. ValidateConfig separates the two: static policy can be rejected synchronously while discovery and JWKS work stay asynchronous.
It performs NO I/O. No HTTP client is built, no file is read, no discovery or JWKS fetch is made, no cache or goroutine is started, and no context is required. cfg is not mutated, and every caller-owned slice is cloned, so the returned Config shares no backing array with the argument.
A successful return does NOT promise that NewValidator will succeed: everything dynamic remains unchecked. Whether CACertPath and AuthTokenFile exist and parse, whether the issuer resolves, whether its discovery document is conformant, and whether the JWKS is reachable are all still open questions — deliberately, since they are precisely the part a deferring caller wants to keep asynchronous.
On failure the zero Config is returned, so a half-defaulted value cannot be used by mistake.
type Error ¶
type Error struct {
// Code is the coarse, client-safe OAuth2 error code.
Code Code
// Reason is the finer-grained, client-safe failure cause.
Reason Reason
// contains filtered or unexported fields
}
Error is the only error type Validate and ParseBearer return.
Log-vs-wire split:
Code and Reason are both client-safe, but not equally wire-safe. CodeInvalidRequest and CodeInvalidToken are registered RFC 6750 §3.1 error values and may be surfaced on the wire verbatim (e.g. in the WWW-Authenticate / error response body). CodeUnavailable is not registered and must map to a plain HTTP 503 instead (see its doc comment). Error() is NOT safe to send to a client — it includes wrapped detail that can carry key ids, JWKS URLs, issuer-mismatch specifics, and underlying transport errors. Log Error(); send Code/Reason.
As a special case, a Validate failure with Code == CodeInvalidToken && Reason == ReasonMalformed means the token could not be parsed as a JWT at all, and so may be an opaque token worth validating via RFC 7662 introspection — ToolHive layers that introspection above Validate. Use PossiblyOpaque to ask this rather than comparing Code and Reason by hand: it is the stable API, and the Reason taxonomy may grow. The contract is specific to Validate: ParseBearer reports a malformed header as CodeInvalidRequest + ReasonMalformed (400, broken request), which PossiblyOpaque correctly reports as false.
func (*Error) Error ¶
Error returns a human-readable message that combines the Reason with the wrapped detail. The output is intended for server-side logs and is NOT safe to send to a client; it may contain key ids, JWKS URLs, or issuer-mismatch specifics. It is always distinct from string(Reason) when err is non-nil and carries the wrapped detail via fmt's %v.
type KeyProvider ¶
type KeyProvider interface {
// PublicKeys returns the currently valid verification keys. It is called
// per validation, so an implementation that computes or reads keys should
// cache them itself; this package deliberately does not, so that key
// rotation inside the provider takes effect immediately.
PublicKeys(ctx context.Context) ([]PublicKey, error)
}
KeyProvider supplies verification keys in-process, without an HTTP JWKS fetch.
It exists for an EMBEDDED issuer — an authorization server running inside the same process or pod as the resource server. Two things make an HTTP fetch unworkable in that topology, and a provider solves both:
- Startup ordering. The embedded issuer's JWKS route is typically mounted on the same listener that serves the resource server, so at the moment the Validator is constructed that listener does not exist yet and a fetch connection-refuses.
- Reachability. The issuer's advertised URL may be an external-facing address that is not routable from inside the cluster, so OIDC discovery against it can fail permanently rather than transiently.
Configuring a KeyProvider therefore also relaxes construction: see the Config.KeyProvider docs for exactly what becomes non-fatal.
Implementations must be safe for concurrent use: PublicKeys is called on the request path, once per token whose kid is not already resolved.
type Principal ¶
type Principal struct {
// Issuer is the verified `iss` claim.
Issuer string
// Subject is the verified `sub` claim, guaranteed non-empty.
Subject string
// Name is the `name` claim, may be empty.
Name string
// Claims is the full verified claim set. It is the raw credential (the
// serialized token) that is deliberately absent, never claim data. Claims
// beyond iss/sub/name — e.g. the `email` claim ToolHive's claimsToIdentity
// uses for its display-name fallback, or an IdP-specific claim like Okta's
// `tsid` — are read here by consumers; they are intentionally NOT
// first-class fields, since the struct carries only what every resource
// server needs.
Claims map[string]any
}
Principal is the verified identity carried by a token. It never carries the serialized token or any other credential.
type PublicKey ¶
type PublicKey struct {
// KeyID is the JWK kid this key answers to. It may be empty, in which case
// the key is considered a candidate for any token — including one that
// carries a kid, since an embedded issuer with a single key commonly omits
// it on one side or the other.
KeyID string
// Alg optionally restricts the key to a single JWA algorithm (e.g.
// "RS256"). When empty the key is eligible for any allow-listed algorithm
// its type supports, subject to the same kty/curve backstop applied to
// JWKS keys.
Alg string
// Key is the public key itself: *rsa.PublicKey or *ecdsa.PublicKey. Any
// other type is rejected, since the algorithm allow-list admits only RSA
// and ECDSA families.
Key crypto.PublicKey
}
PublicKey is one verification key offered by a KeyProvider.
type Reason ¶
type Reason string
Reason is a finer-grained, client-safe failure cause.
Reason refines a Code without leaking sensitive detail. Both Code and Reason are safe to transmit to a client; the human-readable detail in Error is not.
const ( // ReasonMissingHeader indicates no Authorization header (or no Bearer // scheme) was present in the request. ReasonMissingHeader Reason = "missing_header" // ReasonMalformed indicates the token could not be parsed as a // structurally valid JWT. From Validate, this pairs with CodeInvalidToken // and means the token may be an opaque token worth validating via RFC // 7662 introspection — use PossiblyOpaque to ask that, rather than // comparing Code and Reason by hand; the Reason taxonomy may grow. // // That opaque-token meaning applies ONLY to Validate. ParseBearer also // returns ReasonMalformed, but with Code == CodeInvalidRequest (400): a // malformed Authorization header is a broken request, not a // possibly-opaque token. PossiblyOpaque already accounts for this split. ReasonMalformed Reason = "malformed" // ReasonUnsupportedAlg indicates the "alg" header is missing, none, or // not in the configured allow-list. ReasonUnsupportedAlg Reason = "unsupported_alg" // ReasonCriticalHeader indicates a "crit" header listed a header the // verifier does not understand or a protected header was malformed. ReasonCriticalHeader Reason = "critical_header" // ReasonUnknownKID indicates the "kid" in the token header does not // match any currently known key. ReasonUnknownKID Reason = "unknown_kid" // ReasonSignature indicates the signature did not verify against the // selected key. ReasonSignature Reason = "signature" // ReasonExpired indicates the "exp" claim has passed. ReasonExpired Reason = "expired" // ReasonExpirationMissing indicates the "exp" claim is absent. It is // distinct from ReasonExpired (whose contract is "exp has passed"): the // parser is configured with WithExpirationRequired, so a missing exp is // a missing-claim failure, not a lifetime-expired one. ReasonExpirationMissing Reason = "expiration_missing" // ReasonNotYetValid indicates the "nbf" claim is in the future. ReasonNotYetValid Reason = "not_yet_valid" // ReasonIssuedInFuture indicates the "iat" claim is in the future // beyond the configured clock skew. ReasonIssuedInFuture Reason = "issued_in_future" // ReasonLifetime indicates the token's total lifetime (exp - iat) // exceeds the configured maximum. ReasonLifetime Reason = "lifetime" // ReasonIssuer indicates the "iss" claim is missing or not in the // trusted set. ReasonIssuer Reason = "issuer" // ReasonAudience indicates the "aud" claim is missing, has the wrong // type, or none of its values match a configured audience. ReasonAudience Reason = "audience" // ReasonMissingClaim indicates a required claim is absent but could not // be identified as iss/aud/exp (e.g. a future required claim added by a // custom parser option). It is deliberately NOT ReasonMalformed so it // does not trigger the introspection fallback. ReasonMissingClaim Reason = "missing_claim" // ReasonSubject indicates the "sub" claim is missing or rejected by // policy. ReasonSubject Reason = "subject" // endpoint) could not be fetched; paired with CodeUnavailable. ReasonKeysUnavailable Reason = "keys_unavailable" // ReasonKeyUnsupported indicates a key matching the token WAS found, but is // not usable to verify it — a sub-2048-bit RSA modulus, a `use` that is not // `sig`, `key_ops` without `verify`, a declared `alg` that disagrees with the // token, or a key type/curve inconsistent with the token's alg. // // It is distinct from ReasonUnknownKID on purpose, and the distinction is // operational rather than cosmetic: unknown_kid says "rotate or re-check the // kid", while this says "the key is right there and your configuration or // key material is the problem". Reporting the former for the latter sends an // operator hunting the wrong fault. The specific cause is in the log-only // detail, never on the wire. ReasonKeyUnsupported Reason = "key_unsupported" // ReasonKeysStale indicates cached key material is older than the // configured Config.MaxJWKSStaleness and a fresh fetch did not succeed, so // it is no longer trusted; paired with CodeUnavailable. // // It is distinct from ReasonKeysUnavailable on purpose: keys_unavailable // means the verifier never had usable key material, while keys_stale means // it has some and is deliberately refusing to keep trusting it. For an // operator those point at the same root cause (the issuer is unreachable) // but different urgency — a revocation may already have been published. ReasonKeysStale Reason = "keys_stale" // ReasonTokenType indicates the "typ" header does not match any entry in // Config.AcceptedTokenTypes (e.g. an ID token presented as an access // token). ReasonTokenType Reason = "token_type" // ReasonInvalidClaims indicates the token parsed as a structurally valid // JWT but its claim set was rejected by golang-jwt for a reason this // package does not otherwise enumerate (e.g. a claim with the wrong JSON // type, such as a numeric iss). It is deliberately NOT ReasonMalformed: // that pair is the load-bearing introspection-fallback trigger (see // Error), and a token that parsed as a JWT is not an opaque token worth // introspecting, no matter which claim check subsequently failed it. ReasonInvalidClaims Reason = "invalid_claims" )
type Validator ¶
type Validator struct {
// contains filtered or unexported fields
}
Validator verifies inbound JWT bearer tokens against the configured issuer and audience policy and the issuer's JWKS key material.
A Validator owns background JWKS refresh goroutines; always call Close when it is no longer needed.
func NewValidator ¶
NewValidator validates cfg and constructs a Validator.
The ctx argument governs the LIFETIME of the validator's background JWKS refresh, not just construction: the refresh goroutines stop when ctx is canceled or Close is called. Pass an application-lifetime context, not a per-request context.
Construction-time network I/O (discovery and the initial JWKS fetch) is separately bounded by an internal timeout, so passing context.Background() cannot hang NewValidator indefinitely.
Errors returned here are ordinary construction errors, not the *Error type: *Error is reserved for Validate/ParseBearer runtime failures.
func (*Validator) Close ¶
func (v *Validator) Close()
Close stops the validator's background JWKS refresh by canceling its internal context. It is idempotent and safe to call concurrently. It does not wait for in-flight fetches to finish.
Validate must not be called after Close; if it is, it fails fast with CodeUnavailable rather than blocking. A concurrent Close during an in-flight Validate is also safe: cache operations are bound to the validator lifetime, so they unblock instead of waiting on a controller that has stopped.
func (*Validator) Validate ¶
Validate verifies a bare JWT (no "Bearer " prefix — callers use ParseBearer for that) against the validator's issuer/audience policy and JWKS key material, returning the verified Principal.
Every failure is a *Error. The verification order is fixed so that nothing touching key material runs before the algorithm gate (RFC 8725 §3.1) and nothing expensive runs before the cheap structural rejects.