Skip to main content

OAuthValidator

Struct OAuthValidator 

Source
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

Source

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();
Source

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();
Source

pub fn config(&self) -> &ResolvedOAuthConfig

The config this validator was built from.

Source

pub fn resource(&self) -> &str

The protected resource’s identifier (crate::OAuthConfig::resource).

Source

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).

Source

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.

Source

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).

Source

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.

Source

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.

Source

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 scopes entry that is not an RFC 6749 §3.3 scope-token (empty, or holding a space, ", \, a control or non-ASCII character) is left out of scope — no token can carry such a scope anyway;
  • description is reduced to RFC 6750 §3’s error_description character 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) leaves resource_metadata out 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\""));
Source

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::Missing for an empty token.
  • TokenRejection::Invalid for 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), an alg outside the allowlist, a refused typ, no usable key, a bad signature, a wrong or missing iss/aud, an expired or not-yet-valid token, an nbf that is not a NumericDate, or a sender-constrained token (a cnf claim: 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 in allowed_client_ids, a token older than max_token_age_secs (or without a readable iat), or a required_claims entry missing or not matched. Its InvalidToken::kind says which.
  • TokenRejection::InsufficientScope for 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()))
        }
    }
}
Source

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).

Source

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"
);
Source

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 yet
Source

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).

Trait Implementations§

Source§

impl Debug for OAuthValidator

Hand-written: prints the issuer and resource through redact_url, so a credential in either never reaches a log line through {:?}.

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<T> Instrument for T

Source§

fn instrument(self, span: Span) -> Instrumented<Self> ⓘ

Instruments this type with the provided Span, returning an Instrumented wrapper. Read more
Source§

fn in_current_span(self) -> Instrumented<Self> ⓘ

Instruments this type with the current Span, returning an Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T> PolicyExt for T
where T: ?Sized,

Source§

fn and<P, B, E>(self, other: P) -> And<T, P>
where T: Sized + Policy<B, E>, P: Policy<B, E>,

Create a new Policy that returns Action::Follow only if self and other return Action::Follow. Read more
Source§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where T: Sized + Policy<B, E>, P: Policy<B, E>,

Create a new Policy that returns Action::Follow if either self or other returns Action::Follow. Read more
Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = !

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, !>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
Source§

impl<T> WithSubscriber for T

Source§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self> ⓘ
where S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a WithDispatch wrapper. Read more
Source§

fn with_current_subscriber(self) -> WithDispatch<Self> ⓘ

Attaches the current default Subscriber to this type, returning a WithDispatch wrapper. Read more