Skip to main content

OAuthConfig

Struct OAuthConfig 

Source
pub struct OAuthConfig {
Show 20 fields pub enabled: bool, pub issuer: String, pub jwks_uri: Option<String>, pub audience: String, pub audiences: Vec<String>, pub resource: String, pub required_scope: Option<String>, pub required_scopes: Vec<String>, pub scopes_supported: Option<Vec<String>>, pub scope_claims: Vec<String>, pub principal_claims: Vec<String>, pub algorithms: Vec<String>, pub leeway_secs: u64, pub require_at_jwt: bool, pub allow_unscoped_tokens: bool, pub allow_insecure_http: bool, pub accept_static_bearer: bool, pub allowed_client_ids: Vec<String>, pub max_token_age_secs: Option<u64>, pub required_claims: BTreeMap<String, Value>,
}
Expand description

The OAuth resource-server settings, as written by an operator — unvalidated.

Turns the process into an OAuth 2.0 resource server (RFC 9728, RFC 9068, RFC 6750): it verifies JWT access tokens minted by a separate authorization server and never issues, refreshes or introspects anything itself. Call OAuthConfig::resolve to validate it into a ResolvedOAuthConfig.

Provider-agnostic by construction: every provider-specific difference (audience value, scope claim name and shape, signing algorithm, typ, username claim) is a field below rather than a code path.

Every field is optional in serde input (feature serde); unknown keys are refused. Nest this in your own config; do not #[serde(flatten)] it — flattening silently defeats the unknown-key check (a general serde limitation, not specific to this crate); see Embedding OAuthConfig in your own config in the README. Nothing here hot-reloads: a changed value takes effect only when a new validator is built from it, which in practice means a restart.

Deliberately not #[non_exhaustive], so applications can write OAuthConfig { enabled: true, ..OAuthConfig::default() } — a functional-record update that keeps compiling even after a field is added. What breaks instead is an exhaustive struct literal or destructuring pattern that names every field, which is possible only because every field here is public and the struct carries no #[non_exhaustive]; adding a field is still a breaking change under 0.x (a new minor release, which Cargo treats as incompatible), just not for the functional-record-update form above.

Debug is hand-written: issuer, jwks_uri and resource are shown with any userinfo, query or fragment masked (***@, ?***, #***), since a URL may carry a credential; every other field prints as a derive would.

Fields§

§enabled: bool

Master switch. False (the default) means OAuthConfig::resolve returns Ok(None): no JWT validation happens at all and nothing else here is checked.

§issuer: String

The authorization server’s issuer identifier, compared BYTE-EXACTLY against each token’s iss claim and echoed verbatim in the protected-resource metadata’s authorization_servers. Copy it from the AS’s own discovery document including any trailing slash — Authentik’s issuer ends in one, and a token minted with .../app/ will not match .../app. Required; an absolute URL with no query or fragment, and no space, control or non-ASCII character. Write it https://host/...: a spelling the URL parser repairs (https:/host) never matches a token’s iss byte-for-byte, and OAuthValidator::new warns about one.

https, as RFC 8414 §2 requires of an issuer: the signing keys are found through it, and keys fetched over cleartext can be substituted by anyone on the path. Plain http is accepted only for a loopback host, or with OAuthConfig::allow_insecure_http.

Userinfo (https://user:pass@…) is accepted, and sent as HTTP Basic auth on the discovery fetches; this crate redacts it wherever it displays the URL (log lines, crate::RefreshError, configuration problems, the Debug output of the config types, the validator and the layers). The issuer is also published verbatim in the RFC 9728 metadata document, though, so a credential does not belong in it.

§jwks_uri: Option<String>

Where to fetch the signing keys (JWKS). Optional: absent or blank, it is discovered from the issuer’s own metadata (OpenID Connect Discovery, then RFC 8414), and the discovered document’s issuer must equal issuer byte-for-byte or it is refused. Setting it explicitly skips discovery. Fetched at startup and hourly in the background, and on an unknown kid at most once a minute. The same URL rules as issuer apply, except that a query is allowed; RFC 8414 §2 requires https for it too.

Userinfo (https://user:pass@…, sent as HTTP Basic auth) and a query (…/jwks?key=…) are accepted and used unchanged for the fetch; this crate redacts both wherever it displays the URL (log lines, crate::RefreshError, crate::KeySetStatus::jwks_uri, configuration problems, the Debug output of the config types, the validator and the layers).

§audience: String

A value each token’s aud claim must contain (string or array, RFC 7519 §4.1.3). Unioned with audiences; at least one of the two must be set, and there is deliberately no default, because the right value depends on the authorization server and a wrong guess either rejects everything or accepts tokens meant for another service:

  • servers that honour RFC 8707 or let you configure an access-token audience (Authelia with a client audience) put the RESOURCE URL there — use the same value as resource;
  • servers that ignore RFC 8707 and stamp the OAuth CLIENT ID (Authentik, Kanidm) need the client_id here.

A client_id audience departs from RFC 9068 §4 (the aud must identify this resource server) and from MCP’s requirement that a server accept only tokens issued for it as audience (RFC 8707 §2): every token that client obtains from the authorization server, for any resource, carries the same aud. It is sound only when that OAuth client is dedicated to this one resource server and shared with no other API. Prefer a resource-URL audience wherever the authorization server supports one.

§audiences: Vec<String>

Additional accepted audiences. A token passes the audience check if its aud contains ANY configured value. Useful while migrating from a client_id audience to a resource-URL audience.

§resource: String

This resource server’s canonical identifier, published as resource in the protected-resource metadata and used to derive the metadata URL advertised in WWW-Authenticate (RFC 9728 §3). The public URL of the protected endpoint, e.g. https://api.example.com/v1; required, with the same URL rules as issuer.

https, as RFC 9728 §1.2 requires of a resource identifier: it is the URL clients send their bearer tokens to (RFC 6750 §5.3). Plain http is accepted only for a loopback host, or with OAuthConfig::allow_insecure_http.

Not implicitly compared against aud — list it in audience/audiences when the authorization server stamps it there.

It is published verbatim in the RFC 9728 metadata document and in the resource_metadata of every WWW-Authenticate challenge, so it must never carry a credential (userinfo is not refused, but has no place here). This crate’s own displays of it — log lines, configuration problems, Debug — redact any userinfo, query or fragment all the same; the published document and challenges cannot.

§required_scope: Option<String>

A scope every token must carry. A valid token missing it gets 403 insufficient_scope, not 401. A single RFC 6749 §3.3 scope-token — printable ASCII with no space, " or \ — matched exactly and case-sensitively. Unioned with required_scopes.

No default. With neither this nor required_scopes set, no scope is checked, and OAuthConfig::resolve then insists on require_at_jwt or OAuthConfig::allow_unscoped_tokens. An explicitly empty value is an error rather than “no scope”, so a typo cannot silently drop the check.

The check is an exact all-of match with no scope hierarchy: a token holding only api:write does not satisfy api:read, whatever the authorization server means by it. Require a scope every accepted token carries, and make finer, hierarchy-aware decisions in the application with crate::AuthorizedToken::has_scope.

§required_scopes: Vec<String>

Further scopes every token must carry — ALL of them, together with required_scope. Each entry follows the same rules as required_scope. Empty (the default) adds nothing.

§scopes_supported: Option<Vec<String>>

Advertised in the metadata document’s scopes_supported and the 401 challenge’s scope so a client knows what to ask for. Purely declarative — enforcement is required_scope/required_scopes. Each entry is a scope-token, as for required_scope.

None (the default, and what an omitted key deserializes to) resolves to the required scopes (required_scope, then required_scopes), so a client that asks for exactly what is advertised gets a token that passes. Some(vec![]) resolves to an empty list: the metadata document then omits scopes_supported (RFC 9728 §3.2), and the 401 challenge names the required scopes instead. The two are kept distinct so an application can supply its own default for an omitted key without overriding an operator’s explicit empty list — e.g. cfg.scopes_supported.get_or_insert_with(|| vec!["api:read".into()]) before OAuthConfig::resolve.

§scope_claims: Vec<String>

Which claims hold the token’s scopes. Every listed claim is read in every shape — a space-delimited string or an array of strings — and the results are unioned. The default (DEFAULT_SCOPE_CLAIMS) reads RFC 9068’s scope AND the scp that Authelia, Okta, Ory Hydra and Entra ID use instead; reading a claim a token does not carry changes nothing.

§principal_claims: Vec<String>

Claims tried in order to name the caller in logs — the first present, non-empty string wins. Several servers put no username in access tokens (Authelia, Kanidm: only a UUID sub), hence a chain ending in sub. email is not in the default (DEFAULT_PRINCIPAL_CLAIMS) so addresses do not land in logs unasked. Used for logging only, never for an authorization decision.

§algorithms: Vec<String>

JWS algorithms a token may be signed with. Each key in the JWKS is additionally limited to the algorithms its own type (and its alg, when it declares one) can produce. HS256/HS384/HS512 and none are refused at resolve time: a resource server must never verify with a shared secret. The default (DEFAULT_ALGORITHMS) is every asymmetric algorithm this build can verify.

§leeway_secs: u64

Clock-skew allowance, in seconds, applied to exp and nbf. Default DEFAULT_LEEWAY_SECS; capped at MAX_LEEWAY_SECS, since a leeway comparable to a token’s lifetime is a way of disabling expiry.

§require_at_jwt: bool

Require the JWT header typ to be at+jwt (RFC 9068 §2.1). Off by default because Authentik, Keycloak, Entra ID and Okta emit JWT or no typ. Turn it ON for servers that do emit at+jwt (Authelia, Kanidm): it is the check that stops an ID token minted for the same client from being replayed as an access token. With it off, at+jwt, JWT and no typ pass and any other type (dpop+jwt, logout+jwt…) is still refused.

Off is a deliberate, configurable departure from RFC 9068 §4, under which a resource server MUST reject any typ other than at+jwt or application/at+jwt; RFC 8725 §3.11 recommends the same explicit typing. With it off, a required scope is what keeps ID tokens out.

§allow_unscoped_tokens: bool

Accept a configuration with no required scope and require_at_jwt off. Default false: OAuthConfig::resolve refuses that combination, because nothing in it tells an access token from an OIDC ID token minted for the same client (RFC 8725 §3.11–3.12), and on servers that stamp the client_id as aud (Authentik, Kanidm) the ID token a front end got at login would then be a working API credential. Set it only when “signed by this issuer for this audience” really is all the application needs; the validator still logs a warn at startup.

§allow_insecure_http: bool

Accept a plain-http issuer, jwks_uri or resource on a non-loopback host. Default false: OAuthConfig::resolve refuses one, because signing keys fetched over cleartext can be substituted by anyone on the path (RFC 8414 §2 requires https for the issuer and its jwks_uri), and bearer tokens sent to a cleartext resource can be read in transit (RFC 9728 §1.2, RFC 6750 §5.3). Loopback hosts (127.0.0.0/8, ::1, localhost) are always allowed, for tests and local development.

The same rule holds at run time for URLs the configuration does not name: a jwks_uri discovered from a (plain-http) issuer’s metadata, and every redirect a metadata or JWKS fetch follows, may reach plain http on a non-loopback host only with this set. An https issuer never hands out an http jwks_uri, and a redirect from https to http is never followed, whatever this says.

Set it for an in-cluster address on a private network (an http://idp:9000/... jwks_uri, say) where the path itself is trusted; the validator still logs a warn for each such URL at startup, and for each such discovered URL or redirect when it is used.

§accept_static_bearer: bool

Whether a static token (an API key the application configures separately) is still accepted while OAuth is on. Default true: both credentials work side by side. Set false to run OAuth-only even if a static token is configured (it is then ignored). Read by crate::static_token_policy; the validator itself never looks at it.

§allowed_client_ids: Vec<String>

The OAuth clients whose tokens are accepted. A token’s client is its client_id claim (RFC 9068 §2.2), else its azp — the first that is a non-empty string, exactly as crate::AuthorizedToken::client_id reads it — and it must equal one entry, byte for byte. A token naming no client, or another one, is refused (401, crate::InvalidTokenKind::ClientNotAllowed). client_id wins when both are present: a token whose client_id is not listed is refused even if its azp is, and a client_id that is present but empty or not a string is refused too, never read past to azp (stricter than the AuthorizedToken::client_id accessor, which skips it).

Empty (the default) checks nothing. Use it where the audience is shared: an authorization server that stamps an API identifier as aud (Auth0, Okta custom authorization servers, Entra ID app ID URIs) gives every client of that API a token this resource server would otherwise accept. Entries must not be blank.

§Examples

let base = OAuthConfig {
    enabled: true,
    issuer: "https://auth.example.com/".into(),
    audience: "https://api.example.com/".into(),
    resource: "https://api.example.com/".into(),
    required_scope: Some("api:read".into()),
    ..OAuthConfig::default()
};
let resolved = OAuthConfig {
    allowed_client_ids: vec!["web-app".into(), "cli".into()],
    ..base
}
.resolve(KeyNaming::Dotted("oauth"))
.unwrap()
.unwrap();
assert_eq!(resolved.allowed_client_ids, ["web-app", "cli"]);
§max_token_age_secs: Option<u64>

Refuse a token issued more than this many seconds ago: now - iat must not exceed it, with OAuthConfig::leeway_secs of slack. With it set, a token must carry iat as a NumericDate (a missing one is crate::InvalidTokenKind::MissingClaim, a malformed one MalformedClaim), and an iat later than now plus the leeway is refused as NotYetValid; too old is TokenTooOld. All 401.

None (the default) checks nothing, and iat stays optional. Bounds a token’s usable age independently of the exp the authorization server chose — useful when it issues long-lived tokens. 1..= MAX_TOKEN_AGE_SECS (30 days); 0 is refused.

§Examples

let base = OAuthConfig {
    enabled: true,
    issuer: "https://auth.example.com/".into(),
    audience: "https://api.example.com/".into(),
    resource: "https://api.example.com/".into(),
    required_scope: Some("api:read".into()),
    ..OAuthConfig::default()
};
// Refuse tokens issued more than an hour ago (plus the leeway).
let one_hour = OAuthConfig { max_token_age_secs: Some(3600), ..base.clone() };
assert!(one_hour.resolve(KeyNaming::Dotted("oauth")).is_ok());

let zero = OAuthConfig { max_token_age_secs: Some(0), ..base };
let err = zero.resolve(KeyNaming::Dotted("oauth")).unwrap_err();
assert!(err.problems[0].contains("oauth.max_token_age_secs"));
§required_claims: BTreeMap<String, Value>

Claims every token must carry with a given value, e.g. a tenant ({"tid": "<tenant id>"}) or a group ({"groups": "api-users"}). Each entry is checked against the verified claim of that name:

  • absent from the token: refused (MissingClaim);
  • equal to the value (JSON equality: type and value, so "1" is not 1, and an integer 1 is not the float 1.0): passes;
  • an array containing an element equal to the value: passes (membership, for groups, roles and the like);
  • anything else — a different value, null, an object, an array without the value: refused (ClaimMismatch).

Every entry must pass. The value must be a string, number or boolean (null, an array or an object is refused by OAuthConfig::resolve); only top-level claims are matched, never a path into a nested object. The name must not be blank, nor one of the claims this crate already checks (iss, aud, exp, nbf, iat, cnf). Empty (the default) checks nothing. All refusals are 401.

Naming a scope claim (scope, scp, or any scope_claims entry), or azp/client_id, is accepted but logged as a warn when the validator is built: a scope string is compared as one whole value (use required_scopes), and one client claim sidesteps allowed_client_ids’ precedence. An array value is refused today; “any of these values” may be given that meaning later, as an additive change.

§Examples

use serde_json::json;
let base = OAuthConfig {
    enabled: true,
    issuer: "https://auth.example.com/".into(),
    audience: "https://api.example.com/".into(),
    resource: "https://api.example.com/".into(),
    required_scope: Some("api:read".into()),
    ..OAuthConfig::default()
};
// One tenant, and membership of one group (`groups` is an array claim).
let config = OAuthConfig {
    required_claims: [
        ("tid".to_string(), json!("00000000-0000-0000-0000-000000000000")),
        ("groups".to_string(), json!("api-users")),
    ]
    .into_iter()
    .collect(),
    ..base.clone()
};
assert!(config.resolve(KeyNaming::Dotted("oauth")).is_ok());

// A claim this crate already checks, or a non-scalar value, is refused.
let err = OAuthConfig {
    required_claims: [("aud".to_string(), json!("x")), ("org".to_string(), json!({}))]
        .into_iter()
        .collect(),
    ..base
}
.resolve(KeyNaming::Dotted("oauth"))
.unwrap_err();
assert_eq!(err.problems.len(), 2);

A config file naming one claim twice is refused when it is deserialized (a serde error naming the claim), never read as “the last one wins”.

Implementations§

Source§

impl OAuthConfig

Source

pub fn resolve( self, naming: KeyNaming<'_>, ) -> Result<Option<ResolvedOAuthConfig>, ConfigError>

Validate and resolve: Ok(None) when disabled, Ok(Some(..)) when enabled and usable, Err naming every problem it can find at once when enabled and not. naming decides how the problems (and, later, the validator’s log lines) name each setting.

Does no I/O: whether the issuer is reachable and publishes usable keys is found out later, by the validator.

§Errors

A ConfigError listing every problem in an enabled config: a blank issuer or resource, no audience in either audience or audiences, a URL (issuer, resource or jwks_uri) that is not absolute http/https, has surrounding whitespace or contains a space, control or non-ASCII character (or, for issuer and resource, that has a query or a fragment), a plain-http URL on a non-loopback host — decided on the URL as parsed, however it is spelled — without OAuthConfig::allow_insecure_http, a blank or multi-word required scope, a required or supported scope that is not an RFC 6749 §3.3 scope-token, no required scope with neither require_at_jwt nor OAuthConfig::allow_unscoped_tokens set, an empty scope_claims, a blank entry in a list, an algorithm that is HMAC, none or unknown, an empty algorithm list, a leeway_secs over MAX_LEEWAY_SECS, a blank allowed_client_ids entry, a max_token_age_secs of 0 or over MAX_TOKEN_AGE_SECS, or a required_claims entry with a blank or reserved name or a value that is not a string, number or boolean.

§Examples
use oauth_resource_server::{KeyNaming, OAuthConfig};

let config = OAuthConfig {
    enabled: true,
    issuer: "https://auth.example.com/".into(),
    audience: "example-api".into(),
    resource: "https://api.example.com".into(),
    required_scope: Some("api:read".into()),
    scopes_supported: Some(vec!["api:read".into()]),
    ..OAuthConfig::default()
};
let resolved = config.resolve(KeyNaming::Dotted("oauth")).unwrap().unwrap();
assert_eq!(resolved.required_scopes, ["api:read"]);
assert_eq!(resolved.jwks_uri, None); // discovered from the issuer later

// Disabled: nothing is checked.
assert_eq!(OAuthConfig::default().resolve(KeyNaming::Dotted("oauth")), Ok(None));

// Broken: every problem at once, each named the way the operator wrote it.
let err = OAuthConfig {
    enabled: true,
    required_scope: Some("api:read".into()),
    leeway_secs: 3600,
    ..OAuthConfig::default()
}
.resolve(KeyNaming::Env("MYAPP_OAUTH_"))
.unwrap_err();
assert_eq!(err.problems.len(), 2);
assert!(err.problems[0].contains("MYAPP_OAUTH_ISSUER"));
assert!(err.problems[1].contains("MYAPP_OAUTH_LEEWAY_SECS"));

Trait Implementations§

Source§

impl Clone for OAuthConfig

Source§

fn clone(&self) -> Self

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for OAuthConfig

Hand-written so a credential in a URL setting never reaches a log line through {:?}.

Source§

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

Formats the value using the given formatter. Read more
Source§

impl Default for OAuthConfig

Source§

fn default() -> Self

Returns the “default value” for a type. Read more
Source§

impl<'de> Deserialize<'de> for OAuthConfig

Available on crate feature serde only.
Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl Eq for OAuthConfig

Source§

impl PartialEq for OAuthConfig

Source§

fn eq(&self, other: &Self) -> bool

Equality operator ==. Read more
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Inequality operator !=. Read more
Source§

impl Serialize for OAuthConfig

Available on crate feature serde only.
Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl StructuralPartialEq for OAuthConfig

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> CloneToUninit for T
where T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> DeserializeOwned for T
where T: for<'de> Deserialize<'de>,

Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<T> FromRef<T> for T
where T: Clone,

Source§

fn from_ref(input: &T) -> T

Converts to this type from a reference to the input type.
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> ToOwned for T
where T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. 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