pub struct OAuthValidator { /* private fields */ }Expand description
Validates bearer credentials as JWT access tokens (RFC 9068) for one resource.
Built once from a ResolvedOAuthConfig and shared (Arc) for the life of
the process; nothing about it hot-reloads. It never issues, refreshes, revokes
or introspects tokens, and it only talks to the authorization server to fetch
its metadata (when no jwks_uri is configured) and its public signing keys.
Every check that can be made from the unverified header (size, JWS shape,
crit, alg allowlist, typ) runs before any key is fetched, so junk
cannot schedule IdP traffic. Signature, iss, aud, exp and nbf are all
checked inside one jsonwebtoken::decode, so the claim checks can never be
reordered ahead of the signature. Every failure fails closed.
§Runtime
Validation, OAuthValidator::refresh_now and
OAuthValidator::spawn_background_refresh need a Tokio 1.x runtime: key
fetches use reqwest (with a Tokio timer) and run in a spawned task. Called
outside one, the first key fetch panics. On async-std, smol or another
executor, drive them from a Tokio runtime handle.
§Extension point: opaque tokens
Opaque (non-JWT) access tokens are refused today. RFC 7662 introspection would
cover them but needs a client credential and per-request AS round trips, so it
is deliberately not built. An introspection backend would be a feature-gated
alternative to the JWKS key source, chosen at construction, and
OAuthValidator::validate would dispatch to it where it now refuses a
non-JWT credential. Its result is the same AuthorizedToken (and
TokenRejection), both #[non_exhaustive], so code consuming a validation
result is unaffected; ResolvedOAuthConfig is #[non_exhaustive] too, so a
new resolved setting is an additive change.
crate::OAuthConfig is deliberately NOT #[non_exhaustive]: applications
build it with a functional-record update (OAuthConfig { enabled: true, ..OAuthConfig::default() }), which that attribute would forbid outside
this crate, and which keeps compiling even after a field is added. Adding
introspection keys to it (endpoint, client credential) would instead break
only an exhaustive struct literal or destructuring pattern that names
every field — possible today because every field is public — and would
still ship in a new 0.x minor release, which Cargo already treats as
incompatible — unless those settings are passed to a separate constructor
instead, which leaves OAuthConfig untouched.
Implementations§
Source§impl OAuthValidator
impl OAuthValidator
Sourcepub fn new(config: &ResolvedOAuthConfig) -> Result<Self, ValidatorError>
pub fn new(config: &ResolvedOAuthConfig) -> Result<Self, ValidatorError>
Build a validator. Does no I/O: keys are fetched on first use, or earlier
by OAuthValidator::spawn_background_refresh /
OAuthValidator::refresh_now.
Build one per process and share it (Arc): it owns the key cache, so
separate validators would each fetch and refresh their own keys.
Logs a warn for a configuration that works but is weaker than it
probably should be: a required scope missing from scopes_supported
(clients that request the advertised scopes will get 403), no required
scope with require_at_jwt off (ID tokens for the same client are
accepted), and a plain-http issuer, jwks_uri or resource on a
non-loopback host. crate::OAuthConfig::resolve refuses the last two
unless the config opts in explicitly; the warning is for the deployments
that did.
§Errors
ValidatorError when the config has no accepted audience, no
algorithm, or a leeway_secs over crate::MAX_LEEWAY_SECS (none of
which a config from crate::OAuthConfig::resolve can have, but the
fields of ResolvedOAuthConfig are public), or when the HTTP client
for key fetches cannot be built (the TLS backend failed to initialize).
§Examples
use std::sync::Arc;
use oauth_resource_server::{KeyNaming, OAuthConfig, OAuthValidator};
let resolved = OAuthConfig {
enabled: true,
issuer: "https://auth.example.com/".into(),
audience: "example-api".into(),
resource: "https://api.example.com/v1".into(),
required_scope: Some("api:read".into()),
scopes_supported: Some(vec!["api:read".into()]),
..OAuthConfig::default()
}
.resolve(KeyNaming::Dotted("oauth"))
.unwrap()
.unwrap();
let validator = Arc::new(OAuthValidator::new(&resolved).unwrap());
assert_eq!(
validator.metadata_path(),
"/.well-known/oauth-protected-resource/v1"
);
assert_eq!(
validator.insufficient_scope_challenge(),
"Bearer error=\"insufficient_scope\", scope=\"api:read\", \
resource_metadata=\"https://api.example.com/.well-known/oauth-protected-resource/v1\""
);
// In a server, inside the tokio runtime:
// validator.spawn_background_refresh();Sourcepub fn builder(config: &ResolvedOAuthConfig) -> OAuthValidatorBuilder
pub fn builder(config: &ResolvedOAuthConfig) -> OAuthValidatorBuilder
A builder for a validator whose key fetches need more than
OAuthValidator::new gives them: an extra trust anchor for an
authorization server behind a private CA, an explicit proxy, a
different fetch timeout, or a key set to start from. With no option
set, OAuthValidatorBuilder::build is exactly
OAuthValidator::new.
These are code, not crate::OAuthConfig settings: like the config,
they take effect only when a validator is built (a restart).
§Examples
use std::time::Duration;
use oauth_resource_server::{KeyNaming, OAuthConfig, OAuthValidator};
let validator = OAuthValidator::builder(&resolved)
.fetch_timeout(Duration::from_secs(5))
.proxy("http://proxy.example.com:3128")
.build()
.unwrap();Sourcepub fn config(&self) -> &ResolvedOAuthConfig
pub fn config(&self) -> &ResolvedOAuthConfig
The config this validator was built from.
Sourcepub fn resource(&self) -> &str
pub fn resource(&self) -> &str
The protected resource’s identifier (crate::OAuthConfig::resource).
Sourcepub fn resource_metadata_url(&self) -> &str
pub fn resource_metadata_url(&self) -> &str
The protected-resource metadata URL advertised in every challenge’s
resource_metadata parameter (RFC 9728 §3: the well-known segment spliced
between the resource’s authority and path).
Sourcepub fn metadata_path(&self) -> &str
pub fn metadata_path(&self) -> &str
The path of OAuthValidator::resource_metadata_url — the route the
metadata document must be served on, e.g.
/.well-known/oauth-protected-resource/mcp for a resource at /mcp, or
the bare crate::PROTECTED_RESOURCE_METADATA_PREFIX for a resource at
the root.
It comes from config and may contain characters a router reads as
pattern syntax ({…}, or a segment starting with : or *, which axum
refuses with a panic), so an app serving it itself should compare the
request path against it literally rather than register it as a route.
The axum feature’s metadata_router does exactly that.
Sourcepub fn metadata(&self) -> &Value
pub fn metadata(&self) -> &Value
The RFC 9728 protected-resource metadata document, rendered once at
construction. scopes_supported is left out when the list is empty
(RFC 9728 §3.2: a parameter with zero values is omitted).
Sourcepub fn invalid_token_challenge(&self) -> String
pub fn invalid_token_challenge(&self) -> String
The WWW-Authenticate value for every 401 — a refused credential and, by
deliberate choice, a missing one too:
Bearer error="invalid_token", resource_metadata="…", scope="…".
scope lists scopes_supported, or the required scopes when nothing is
advertised (the MCP authorization spec asks servers to name the scopes
needed here). With neither, the parameter is omitted, not sent empty:
RFC 6749 §3.3 requires at least one scope-token.
Load-bearing, not cosmetic: claude.ai has been observed refusing to start
the authorization flow at all when a 401 arrives without it, because
resource_metadata is how the client finds the authorization server in the
first place. Claude Code tolerates its absence, which is exactly why it is
easy to drop and hard to notice. Emit it on EVERY 401 once OAuth is
configured — including a failed static-token request, since the server
cannot tell which credential the caller meant to present.
Always a valid header value: with a hand-edited config that would break
it (a control or non-ASCII character in resource or a scope), this is
the fallback Bearer error="invalid_token" (plus scope when that is
valid), logged at error when the validator is built.
Sourcepub fn insufficient_scope_challenge(&self) -> String
pub fn insufficient_scope_challenge(&self) -> String
The WWW-Authenticate value for a 403:
Bearer error="insufficient_scope", scope="…", resource_metadata="…".
The token was genuinely valid, so scope names what is required —
every required scope, space-delimited (RFC 6750 §3) — rather than
everything on offer. That is the difference that lets a client
re-authorize for the right thing instead of replaying the same request.
With no required scope (no token is ever refused for scope) the scope
parameter is omitted.
Always a valid header value, falling back as
OAuthValidator::invalid_token_challenge does.
Sourcepub fn insufficient_scope_challenge_for(
&self,
scopes: &[&str],
description: Option<&str>,
) -> String
pub fn insufficient_scope_challenge_for( &self, scopes: &[&str], description: Option<&str>, ) -> String
A 403 challenge for ONE request, naming the scopes that request needs
rather than the validator’s fixed set:
Bearer error="insufficient_scope", scope="…", resource_metadata="…", error_description="…" (the shape the MCP authorization spec asks for
on a per-operation refusal). insufficient_scope_challenge
is exactly this with the configured required_scopes and no
description.
scopes is named verbatim, deduplicated, in order — pass every scope
the request needs, the validator’s own required scopes included, so a
client that re-authorizes for exactly this set gets a token that
passes (crate::refusal_for_scopes and the layers add them for
you). With no scope to name, scope is omitted.
§Security
Always a valid header value, whatever the arguments:
- a
scopesentry that is not an RFC 6749 §3.3 scope-token (empty, or holding a space,",\, a control or non-ASCII character) is left out ofscope— no token can carry such a scope anyway; descriptionis reduced to RFC 6750 §3’serror_descriptioncharacter set: every",\, control (CR and LF included) and non-ASCII character becomes a space, so it can neither close the quoted string nor split the header. It is then trimmed, cut to 256 bytes, and left out when blank. It is still sent to the client: never put a token-derived or secret value in it;- a validator built from a hand-edited config whose challenges fell
back (see
invalid_token_challenge) leavesresource_metadataout here too.
§Examples
assert_eq!(
validator.insufficient_scope_challenge_for(
&["api:read", "api:write"],
Some("writing needs api:write"),
),
"Bearer error=\"insufficient_scope\", scope=\"api:read api:write\", \
resource_metadata=\"https://api.example.com/.well-known/oauth-protected-resource/v1\", \
error_description=\"writing needs api:write\""
);
// A description cannot inject an attribute or split the header.
let challenge = validator
.insufficient_scope_challenge_for(&["api:write"], Some("x\", scope=\"admin\r\nX: y"));
assert!(challenge.ends_with("error_description=\"x , scope= admin X: y\""));Sourcepub async fn validate(
&self,
token: &str,
) -> Result<AuthorizedToken, TokenRejection>
pub async fn validate( &self, token: &str, ) -> Result<AuthorizedToken, TokenRejection>
Validate a bearer credential as a JWT access token.
Order matters and is RFC 9068 §4’s: everything that can be refused from the
unverified header alone (size, shape, alg allowlist, typ) is refused
before any key is fetched, so junk cannot schedule IdP traffic; then the
signature; then issuer / audience / expiry / not-before — all inside
jsonwebtoken::decode, so they cannot be reordered ahead of the signature by
accident — then scope: the token must carry EVERY required scope.
token is the credential alone, without the Bearer prefix. When the
signing key is not cached this fetches the JWKS (at most once a minute
for an unknown kid), so the call can wait on a fetch, each bounded by
a 10-second timeout. The fetch runs in a task of its own, so dropping
this future does not cancel it. Logs an insufficient scope at info and
a failed key refresh at warn; logging the outcome is the caller’s job.
Runs in a debug span oauth_rs.validate recording the unverified
header’s kid and alg (cut to 128 characters, anything outside
printable ASCII escaped) and the outcome (auth.outcome,
auth.reason); a key fetch it triggers runs in an info span
oauth_rs.jwks_refresh below it. See the README’s “Observability”
section.
§Errors
TokenRejection::Missingfor an emptytoken.TokenRejection::Invalidfor everything that makes the token no good: over 16 KiB, not a JWT, an unparsable header, a header listing critical extensions (crit, RFC 7515 §4.1.11: this crate supports none), analgoutside the allowlist, a refusedtyp, no usable key, a bad signature, a wrong or missingiss/aud, an expired or not-yet-valid token, annbfthat is not a NumericDate, or a sender-constrained token (acnfclaim: DPoP, RFC 9449 §7.2, or mTLS, RFC 8705 §3), which this crate cannot verify the binding of and so will not accept as a plain bearer token; and, only when configured, a client not inallowed_client_ids, a token older thanmax_token_age_secs(or without a readableiat), or arequired_claimsentry missing or not matched. ItsInvalidToken::kindsays which.TokenRejection::InsufficientScopefor a valid token that lacks a required scope.
§Panics
Outside a Tokio 1.x runtime, when a key has to be fetched (see Runtime).
§Security
The reason inside Invalid names the check that failed. Log it; never
send it to the caller, for whom it would be an oracle. Answer with
OAuthValidator::invalid_token_challenge or
OAuthValidator::insufficient_scope_challenge instead.
§Examples
use oauth_resource_server::{OAuthValidator, TokenRejection};
/// The status and `WWW-Authenticate` value for a request.
async fn check(validator: &OAuthValidator, bearer: &str) -> (u16, Option<String>) {
match validator.validate(bearer).await {
Ok(token) => {
println!("accepted {:?} with scopes {:?}", token.subject, token.scopes);
(200, None)
}
Err(TokenRejection::InsufficientScope) => {
(403, Some(validator.insufficient_scope_challenge()))
}
Err(rejection) => {
eprintln!("refused: {rejection:?}"); // for the log only
(401, Some(validator.invalid_token_challenge()))
}
}
}Sourcepub async fn refresh_now(&self) -> Result<usize, RefreshError>
pub async fn refresh_now(&self) -> Result<usize, RefreshError>
Load (or reload) the key set now, discovering the JWKS URI first if needed. Returns how many usable keys it holds. On failure the previous keys are kept — a transient IdP outage must not invalidate keys that are still good.
Useful for a startup step that waits for the keys, or a test. It fetches
every time, so a probe should call OAuthValidator::is_ready or
OAuthValidator::key_set_status instead, which do no I/O.
OAuthValidator::spawn_background_refresh already calls it
once at startup and then hourly. The fetch runs in a task of its own,
so dropping this future does not cancel it.
§Errors
RefreshError when discovery fails (no metadata document, or one for
a different issuer), the JWKS cannot be fetched (network, TLS, status,
size cap, not JSON), or the key set holds no key usable with the
configured algorithms. Its Display includes the whole cause chain.
§Panics
Outside a Tokio 1.x runtime (see Runtime).
Sourcepub fn key_set_status(&self) -> KeySetStatus
pub fn key_set_status(&self) -> KeySetStatus
A snapshot of the signing keys this validator holds: how many, the JWKS URL in use, when a refresh was last attempted and last succeeded, and why the last one failed, if it did.
Passive: it does no I/O, never takes the refresh lock and never waits
on a refresh in flight — it copies a few fields under a lock that is
only ever held for such a copy. Unlike OAuthValidator::refresh_now
it is cheap enough for a readiness probe, a status page or a metrics
scrape to call on every request, and needs no Tokio runtime. It only
reports: nothing loads keys unless
OAuthValidator::spawn_background_refresh runs (or a request or
OAuthValidator::refresh_now triggers a fetch), so a probe gating on
it needs that task. The jwks_uri and any error message in it are
redacted (see RefreshError).
§Examples
A status report for an operator-facing page:
use std::time::SystemTime;
use oauth_resource_server::OAuthValidator;
fn key_report(validator: &OAuthValidator) -> String {
let status = validator.key_set_status();
let age = status
.last_success
.and_then(|t| SystemTime::now().duration_since(t).ok())
.map_or("never".to_string(), |d| format!("{}s ago", d.as_secs()));
let error = match &status.last_error {
// `kind()` is safe to show anyone; the full `Display` (URLs and
// upstream error text) is for logs and operators.
Some(e) => e.kind().as_str(),
None => "none",
};
format!(
"{} key(s) from {}, loaded {age}, last error: {error}",
status.keys,
status.jwks_uri.as_deref().unwrap_or("(not yet discovered)"),
)
}
// Before any key load:
assert_eq!(
key_report(&validator),
"0 key(s) from https://auth.example.com/jwks, loaded never, last error: none"
);Sourcepub fn is_ready(&self) -> bool
pub fn is_ready(&self) -> bool
At least one usable signing key is held, so a token signed by it can be
validated. Passive, like OAuthValidator::key_set_status: no I/O, no
waiting on a refresh.
It never goes back to false once true: a failed refresh keeps the
keys already held (see OAuthValidator::refresh_now). That makes it
the right readiness signal, and the wrong liveness one — see the
README’s “Readiness and liveness probes”.
Gate readiness on it only with OAuthValidator::spawn_background_refresh
running (or at least a startup OAuthValidator::refresh_now).
Otherwise keys load only when a request brings a token — and a
not-ready process gets no requests, so it would never become ready.
§Examples
use oauth_resource_server::OAuthValidator;
/// The status code for a readiness endpoint.
fn readiness(validator: &OAuthValidator) -> u16 {
if validator.is_ready() { 200 } else { 503 }
}
assert_eq!(readiness(&validator), 503); // no key loaded yetSourcepub fn spawn_background_refresh(self: &Arc<Self>) -> JoinHandle<()> ⓘ
pub fn spawn_background_refresh(self: &Arc<Self>) -> JoinHandle<()> ⓘ
Warm the key cache at startup and keep it fresh; returns the task’s handle.
The first pass turns a misconfigured issuer, an unreachable JWKS or a discovery mismatch into one clear log line at boot instead of a wall of 401s on the first real request — without making startup itself depend on the authorization server being up (a restart during an IdP outage must not take this service down too). Later passes, hourly, are what drop a key the AS has withdrawn — once one succeeds: a failed pass keeps every key held, and is retried after a minute, backing off to an hour.
While no key is held at all (the first load failed and nothing has
succeeded since), a failed pass is retried sooner: after 5 s, doubling
to at most 5 minutes. A keyless validator refuses every token, and a
readiness probe on OAuthValidator::is_ready keeps the traffic that
would otherwise trigger a refetch away from it, so this schedule is
what brings it back once the authorization server recovers. These
retries are timer-driven only; nothing in a request can schedule one.
The first load logs OAuth: authorization server signing keys loaded at
info, or OAuth: could not load the authorization server's signing keys at warn. The task holds only a weak reference between passes: it
stops once the last Arc of this validator is dropped (or when the
returned handle is aborted), so rebuilding a validator does not leave the
old one polling. Dropping the handle alone does not stop it.
§Panics
When called outside a Tokio 1.x runtime (it uses tokio::spawn).