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: boolMaster switch. False (the default) means OAuthConfig::resolve returns
Ok(None): no JWT validation happens at all and nothing else here is
checked.
issuer: StringThe 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: StringA 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 asresource; - 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: StringThis 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: u64Clock-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: boolRequire 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: boolAccept 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: boolAccept 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: boolWhether 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 not1, and an integer1is not the float1.0): passes; - an array containing an element equal to the value: passes
(membership, for
groups,rolesand 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
impl OAuthConfig
Sourcepub fn resolve(
self,
naming: KeyNaming<'_>,
) -> Result<Option<ResolvedOAuthConfig>, ConfigError>
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
impl Clone for OAuthConfig
Source§impl Debug for OAuthConfig
Hand-written so a credential in a URL setting never reaches a log line
through {:?}.
impl Debug for OAuthConfig
Hand-written so a credential in a URL setting never reaches a log line
through {:?}.
Source§impl Default for OAuthConfig
impl Default for OAuthConfig
Source§impl<'de> Deserialize<'de> for OAuthConfig
Available on crate feature serde only.
impl<'de> Deserialize<'de> for OAuthConfig
serde only.Source§fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where
__D: Deserializer<'de>,
fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where
__D: Deserializer<'de>,
impl Eq for OAuthConfig
Source§impl PartialEq for OAuthConfig
impl PartialEq for OAuthConfig
Source§impl Serialize for OAuthConfig
Available on crate feature serde only.
impl Serialize for OAuthConfig
serde only.