Skip to main content

oauth_resource_server/
config.rs

1//! Configuration: the unvalidated input shape ([`OAuthConfig`]), its validation
2//! ([`OAuthConfig::resolve`]), and the validated shape the validator is built from
3//! ([`ResolvedOAuthConfig`]).
4//!
5//! Validation is all-or-nothing: an enabled config either resolves completely or
6//! fails with **every** problem at once, each naming the offending setting the way
7//! the operator spelled it — a dotted YAML key or an environment variable, chosen
8//! by [`KeyNaming`]. A half-usable OAuth config must fail at startup, with the key
9//! in the message, rather than as a wall of 401s later.
10
11use std::collections::BTreeMap;
12use std::fmt;
13
14use serde_json::Value;
15
16use crate::algorithms::{Algorithm, DEFAULT_ALGORITHMS, parse_algorithm};
17use crate::jwks::{debug_url, try_redact_url};
18use crate::token::for_log;
19use crate::validator::plain_http_non_loopback;
20
21/// Default [`OAuthConfig::scope_claims`]. `scope` is RFC 9068 §2.2.3's
22/// space-delimited string (Authentik, Kanidm, Keycloak); `scp` is what Authelia
23/// (array), Okta (array), Ory Hydra (array or string) and Entra ID (string) emit
24/// instead. Reading both by default is what makes an Authelia token pass without
25/// per-provider config, and it cannot widen access for a token that carries only
26/// `scope` (every Authentik token) because a claim the token does not have
27/// contributes nothing.
28pub const DEFAULT_SCOPE_CLAIMS: &[&str] = &["scope", "scp"];
29
30/// Default [`OAuthConfig::principal_claims`]. `email` is left out on purpose so a
31/// default deployment does not write addresses into its logs; an operator who
32/// wants it adds it.
33pub const DEFAULT_PRINCIPAL_CLAIMS: &[&str] = &["preferred_username", "sub"];
34
35/// Default [`OAuthConfig::leeway_secs`]: the clock-skew allowance, in seconds.
36pub const DEFAULT_LEEWAY_SECS: u64 = 60;
37
38/// Ceiling on [`OAuthConfig::leeway_secs`]. Leeway is for clock drift; a value
39/// large enough to matter against a 5–15 minute token lifetime (Kanidm issues
40/// 900 s tokens) is a way of switching `exp` off, which config must not be able
41/// to do.
42pub const MAX_LEEWAY_SECS: u64 = 300;
43
44/// Ceiling on [`OAuthConfig::max_token_age_secs`]: 30 days. The setting bounds
45/// how long ago a token may have been issued; an access token older than a
46/// month is not something any deployment means to bound *to*, so a larger
47/// value is a typo (seconds meant as minutes, an extra digit), not a policy.
48pub const MAX_TOKEN_AGE_SECS: u64 = 30 * 24 * 3600;
49
50/// Claims [`OAuthConfig::required_claims`] may not name, because this crate
51/// already checks them, or refuses them outright: `iss`, `aud`, `exp` and
52/// `nbf` are validated inside the signature-checking `decode` (an exact-value
53/// requirement on top would either repeat that check or, for `aud`
54/// membership and the time claims, contradict it); `iat` is what
55/// [`OAuthConfig::max_token_age_secs`] bounds (a fixed `iat` would match one
56/// token only); and a token carrying `cnf` is always refused.
57pub(crate) const RESERVED_REQUIRED_CLAIMS: &[&str] = &["iss", "aud", "exp", "nbf", "iat", "cnf"];
58
59/// How problem messages name a setting, so they match how the operator wrote it.
60///
61/// - `Dotted("mcp.oauth")` names the issuer `mcp.oauth.issuer` — a key in a YAML
62///   (or other serde) config nested under that path.
63/// - `Env("MYAPP_OAUTH_")` names it `MYAPP_OAUTH_ISSUER` — the field name
64///   uppercased and appended to the prefix.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66#[non_exhaustive]
67pub enum KeyNaming<'a> {
68    /// A dotted path prefix; a field is named `<prefix>.<field>`.
69    Dotted(&'a str),
70    /// An environment-variable prefix; a field is named `<PREFIX><FIELD>`.
71    Env(&'a str),
72}
73
74impl KeyNaming<'_> {
75    /// The name of setting `field` (a lower-case [`OAuthConfig`] field name).
76    pub fn key(&self, field: &str) -> String {
77        match self {
78            KeyNaming::Dotted("") => field.to_string(),
79            KeyNaming::Dotted(prefix) => format!("{prefix}.{field}"),
80            KeyNaming::Env(prefix) => format!("{prefix}{}", field.to_ascii_uppercase()),
81        }
82    }
83
84    /// The name of the whole block: the dotted prefix itself, or `PREFIX*` for
85    /// environment variables. An empty dotted prefix (the OAuth fields at the
86    /// root of the config) has no path to name, so the block is called
87    /// `OAuth config` rather than rendering as an empty string.
88    pub fn section(&self) -> String {
89        match self {
90            KeyNaming::Dotted("") => "OAuth config".to_string(),
91            KeyNaming::Dotted(prefix) => prefix.to_string(),
92            KeyNaming::Env(prefix) => format!("{prefix}*"),
93        }
94    }
95
96    /// The owned form, for storing past the borrow.
97    pub fn to_buf(&self) -> KeyNamingBuf {
98        match self {
99            KeyNaming::Dotted(p) => KeyNamingBuf::Dotted((*p).to_string()),
100            KeyNaming::Env(p) => KeyNamingBuf::Env((*p).to_string()),
101        }
102    }
103}
104
105/// Owned [`KeyNaming`], carried on [`ResolvedOAuthConfig`] and [`ConfigError`] so
106/// that log lines and errors produced after resolution (a token naming an
107/// algorithm outside the allowlist, a discovery document for another issuer) name
108/// settings the same way the config did.
109#[derive(Debug, Clone, PartialEq, Eq)]
110#[non_exhaustive]
111pub enum KeyNamingBuf {
112    /// See [`KeyNaming::Dotted`].
113    Dotted(String),
114    /// See [`KeyNaming::Env`].
115    Env(String),
116}
117
118impl KeyNamingBuf {
119    /// Borrow as a [`KeyNaming`].
120    pub fn as_naming(&self) -> KeyNaming<'_> {
121        match self {
122            KeyNamingBuf::Dotted(p) => KeyNaming::Dotted(p),
123            KeyNamingBuf::Env(p) => KeyNaming::Env(p),
124        }
125    }
126
127    /// Shorthand for `self.as_naming().key(field)`.
128    pub fn key(&self, field: &str) -> String {
129        self.as_naming().key(field)
130    }
131
132    /// Shorthand for `self.as_naming().section()`.
133    pub fn section(&self) -> String {
134        self.as_naming().section()
135    }
136}
137
138/// What kind of problem a [`ConfigProblem`] is, so a caller can react to it —
139/// or write its own sentence for it — without matching the crate's prose.
140///
141/// `#[non_exhaustive]`: a new kind is an additive change, so match with a
142/// wildcard arm. Every problem [`OAuthConfig::resolve`] and the
143/// [`env`](crate::env) loader find has a specific kind; [`Other`](Self::Other)
144/// is what a problem built from a plain `String` (an application loader's own)
145/// gets.
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
147#[non_exhaustive]
148pub enum ProblemKind {
149    /// A required setting is empty: `issuer`, `resource`, or both `audience`
150    /// and `audiences`.
151    MissingRequired,
152    /// A URL setting (`issuer`, `resource`, `jwks_uri`) failed a `check_url`
153    /// check: not an absolute `http(s)` URL, a query or fragment in an
154    /// identifier (`issuer`, `resource`), leading or trailing whitespace, or a
155    /// control or non-ASCII character.
156    InvalidUrl,
157    /// A plain-`http` URL on a non-loopback host without
158    /// `allow_insecure_http`.
159    InsecureHttp,
160    /// `required_scope`, or an entry of `required_scopes`, is blank.
161    BlankRequiredScope,
162    /// `required_scope`, or an entry of `required_scopes`, holds more than one
163    /// scope (whitespace inside it).
164    MultiWordScope,
165    /// A required or advertised scope is not an RFC 6749 §3.3 scope-token.
166    InvalidScopeToken,
167    /// A list setting (`audiences`, `scopes_supported`, `principal_claims`,
168    /// `allowed_client_ids`) has a blank entry.
169    EmptyListEntry,
170    /// No required scope is configured, `require_at_jwt` is off and
171    /// `allow_unscoped_tokens` is not set.
172    NoRequiredScope,
173    /// `scope_claims` is empty or has a blank entry.
174    EmptyScopeClaims,
175    /// `algorithms` has an entry that is unknown or refused (HMAC, `none`).
176    BadAlgorithm,
177    /// `algorithms` is empty.
178    NoAlgorithms,
179    /// `leeway_secs` is over [`MAX_LEEWAY_SECS`].
180    LeewayTooLarge,
181    /// `max_token_age_secs` is `0` or over [`MAX_TOKEN_AGE_SECS`].
182    TokenAgeOutOfRange,
183    /// A `required_claims` entry has a blank name, names a claim this crate
184    /// already checks (`iss`, `aud`, `exp`, `nbf`, `iat`, `cnf`), or requires
185    /// a value other than a string, number or boolean — or, from the env
186    /// loader, the `REQUIRED_CLAIMS` JSON names one claim twice.
187    InvalidRequiredClaim,
188    /// The env loader could not read a variable or its `_FILE` (both set, an
189    /// unreadable, empty, oversized or not-a-regular file).
190    EnvLoad,
191    /// The env loader read a value it could not parse (a bool other than
192    /// `"true"`/`"false"`, a non-integer `leeway_secs` or
193    /// `max_token_age_secs`, a `REQUIRED_CLAIMS` that is not valid JSON or not
194    /// a JSON object).
195    EnvParse,
196    /// Anything else, including every problem converted from a `String`.
197    Other,
198}
199
200impl ProblemKind {
201    /// A stable, lowercase `snake_case` label for the kind (`"missing_required"`,
202    /// `"invalid_url"`, ...), for logs and metrics. Unlike a problem's message,
203    /// it does not change between releases for an existing kind.
204    pub fn as_str(self) -> &'static str {
205        match self {
206            Self::MissingRequired => "missing_required",
207            Self::InvalidUrl => "invalid_url",
208            Self::InsecureHttp => "insecure_http",
209            Self::BlankRequiredScope => "blank_required_scope",
210            Self::MultiWordScope => "multi_word_scope",
211            Self::InvalidScopeToken => "invalid_scope_token",
212            Self::EmptyListEntry => "empty_list_entry",
213            Self::NoRequiredScope => "no_required_scope",
214            Self::EmptyScopeClaims => "empty_scope_claims",
215            Self::BadAlgorithm => "bad_algorithm",
216            Self::NoAlgorithms => "no_algorithms",
217            Self::LeewayTooLarge => "leeway_too_large",
218            Self::TokenAgeOutOfRange => "token_age_out_of_range",
219            Self::InvalidRequiredClaim => "invalid_required_claim",
220            Self::EnvLoad => "env_load",
221            Self::EnvParse => "env_parse",
222            Self::Other => "other",
223        }
224    }
225}
226
227impl fmt::Display for ProblemKind {
228    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
229        f.write_str(self.as_str())
230    }
231}
232
233/// One configuration problem: its [`ProblemKind`], the settings it names, and
234/// the human-readable sentence.
235///
236/// [`kind`](Self::kind) and [`keys`](Self::keys) are what a caller matches on;
237/// [`message`](Self::message) (also its `Display`) is for people, and its
238/// wording may change in any release. `#[non_exhaustive]`.
239///
240/// An application loader mixes its own problems in with
241/// `ConfigProblem::from(String)` (kind [`ProblemKind::Other`]) or
242/// [`ConfigProblem::new`].
243#[derive(Debug, Clone, PartialEq, Eq)]
244#[non_exhaustive]
245pub struct ConfigProblem {
246    kind: ProblemKind,
247    keys: Vec<String>,
248    message: String,
249}
250
251impl ConfigProblem {
252    /// A problem of `kind` naming `keys` (already spelled the way the error's
253    /// [`KeyNaming`] spells them), described by `message`.
254    pub fn new(
255        kind: ProblemKind,
256        keys: impl IntoIterator<Item = impl Into<String>>,
257        message: impl Into<String>,
258    ) -> Self {
259        Self {
260            kind,
261            keys: keys.into_iter().map(Into::into).collect(),
262            message: message.into(),
263        }
264    }
265
266    /// What kind of problem this is.
267    pub fn kind(&self) -> ProblemKind {
268        self.kind
269    }
270
271    /// The settings this problem names, rendered via [`KeyNaming`] (a dotted
272    /// key or an environment variable). Empty for a problem converted from a
273    /// `String`.
274    pub fn keys(&self) -> &[String] {
275        &self.keys
276    }
277
278    /// The human-readable sentence. Not a stable API: match on
279    /// [`kind`](Self::kind) instead.
280    pub fn message(&self) -> &str {
281        &self.message
282    }
283}
284
285impl From<String> for ConfigProblem {
286    /// A problem of kind [`ProblemKind::Other`] with no keys.
287    fn from(message: String) -> Self {
288        Self::new(ProblemKind::Other, Vec::<String>::new(), message)
289    }
290}
291
292impl fmt::Display for ConfigProblem {
293    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
294        f.write_str(&self.message)
295    }
296}
297
298/// An enabled config that cannot be used, with every problem found.
299///
300/// `Display` renders all of them under one header naming the block the way
301/// [`KeyNaming`] spells it; for `Dotted("mcp.oauth")` that is
302///
303/// ```text
304/// mcp.oauth.enabled is true but the OAuth config is not usable:
305///   - <problem>
306///   - <problem>
307/// Fix these, or set mcp.oauth.enabled: false.
308/// ```
309///
310/// Each problem is available two ways.
311/// [`problem_details`](Self::problem_details) is the structured list
312/// ([`ConfigProblem`]: a [`ProblemKind`], the settings named, the message); the
313/// public [`problems`](Self::problems) field holds the same messages as plain
314/// strings, kept for compatibility. Prefer matching on [`ProblemKind`] to
315/// matching text: message wording is not a stable API.
316#[derive(Debug, Clone, thiserror::Error)]
317pub struct ConfigError {
318    /// One human-readable sentence per problem, each naming its setting.
319    ///
320    /// Kept for compatibility; the structured form is
321    /// [`problem_details`](Self::problem_details). Both are filled from one
322    /// list at construction, in the same order. Editing this field in place
323    /// does not update `problem_details()`; `Display` renders this field.
324    pub problems: Vec<String>,
325    details: Vec<ConfigProblem>,
326    naming: KeyNamingBuf,
327}
328
329impl ConfigError {
330    /// An error listing `problems`, whose header names settings per `naming`.
331    /// For loaders that add their own problems (parse errors, say) alongside the
332    /// ones [`OAuthConfig::resolve`] finds. Each string becomes a
333    /// [`ProblemKind::Other`] problem in
334    /// [`problem_details`](Self::problem_details); use
335    /// [`from_problems`](Self::from_problems) to give them kinds.
336    ///
337    /// `problems` must not be empty: an error with nothing to fix would display
338    /// as a header over one blank bullet. Debug builds assert it.
339    pub fn new(naming: KeyNaming<'_>, problems: Vec<String>) -> Self {
340        let details = problems.iter().cloned().map(ConfigProblem::from).collect();
341        Self::assemble(naming, problems, details)
342    }
343
344    /// An error listing structured `problems`, whose header names settings per
345    /// `naming`. [`problems`](Self::problems) is rendered from their messages,
346    /// so the two views agree. A `String` converts with `.into()` (kind
347    /// [`ProblemKind::Other`]), so an application loader can mix its own
348    /// problems in with the crate's.
349    ///
350    /// `problems` must not be empty; debug builds assert it.
351    pub fn from_problems(
352        naming: KeyNaming<'_>,
353        problems: impl IntoIterator<Item = ConfigProblem>,
354    ) -> Self {
355        let details: Vec<ConfigProblem> = problems.into_iter().collect();
356        let text = details.iter().map(|p| p.message.clone()).collect();
357        Self::assemble(naming, text, details)
358    }
359
360    /// `problems` and `details` are the same length, element for element.
361    fn assemble(naming: KeyNaming<'_>, problems: Vec<String>, details: Vec<ConfigProblem>) -> Self {
362        debug_assert!(!problems.is_empty(), "ConfigError built with no problems");
363        debug_assert_eq!(problems.len(), details.len());
364        Self {
365            problems,
366            details,
367            naming: naming.to_buf(),
368        }
369    }
370
371    /// The structured problems, in the order [`problems`](Self::problems) lists
372    /// them. Match on [`ProblemKind`] rather than on message text:
373    ///
374    /// ```
375    /// use oauth_resource_server::{KeyNaming, OAuthConfig, ProblemKind};
376    ///
377    /// let err = OAuthConfig {
378    ///     enabled: true,
379    ///     leeway_secs: 3600,
380    ///     ..OAuthConfig::default()
381    /// }
382    /// .resolve(KeyNaming::Env("MYAPP_OAUTH_"))
383    /// .unwrap_err();
384    ///
385    /// for problem in err.problem_details() {
386    ///     match problem.kind() {
387    ///         ProblemKind::MissingRequired => {
388    ///             assert!(problem.keys().contains(&"MYAPP_OAUTH_ISSUER".to_string()));
389    ///         }
390    ///         ProblemKind::LeewayTooLarge => {
391    ///             assert_eq!(problem.keys(), ["MYAPP_OAUTH_LEEWAY_SECS"]);
392    ///         }
393    ///         // `ProblemKind` is `#[non_exhaustive]`: keep a wildcard arm.
394    ///         _ => {}
395    ///     }
396    /// }
397    /// ```
398    ///
399    /// An error built with [`ConfigError::new`] reports every problem as
400    /// [`ProblemKind::Other`]. The list is fixed at construction: editing the
401    /// public `problems` field in place does not change it (that field is kept
402    /// for compatibility).
403    pub fn problem_details(&self) -> &[ConfigProblem] {
404        &self.details
405    }
406
407    /// How this error names settings.
408    pub fn naming(&self) -> KeyNaming<'_> {
409        self.naming.as_naming()
410    }
411}
412
413/// Equality is over [`problems`](ConfigError::problems) and the naming only,
414/// as before structured details existed: an error rebuilt with
415/// [`ConfigError::new`] from another's `problems` compares equal to it.
416impl PartialEq for ConfigError {
417    fn eq(&self, other: &Self) -> bool {
418        self.problems == other.problems && self.naming == other.naming
419    }
420}
421
422impl Eq for ConfigError {}
423
424impl fmt::Display for ConfigError {
425    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
426        let list = self.problems.join("\n  - ");
427        match &self.naming {
428            KeyNamingBuf::Dotted(_) => {
429                let enabled = self.naming.key("enabled");
430                write!(
431                    f,
432                    "{enabled} is true but the OAuth config is not usable:\n  - {list}\n\
433                     Fix these, or set {enabled}: false."
434                )
435            }
436            KeyNamingBuf::Env(_) => {
437                let section = self.naming.section();
438                write!(
439                    f,
440                    "OAuth is configured through {section} but the config is not usable:\n  \
441                     - {list}\nFix these, or unset every {section} variable."
442                )
443            }
444        }
445    }
446}
447
448/// The OAuth resource-server settings, as written by an operator — unvalidated.
449///
450/// Turns the process into an OAuth 2.0 *resource server* (RFC 9728, RFC 9068,
451/// RFC 6750): it verifies JWT access tokens minted by a separate authorization
452/// server and never issues, refreshes or introspects anything itself. Call
453/// [`OAuthConfig::resolve`] to validate it into a [`ResolvedOAuthConfig`].
454///
455/// Provider-agnostic by construction: every provider-specific difference
456/// (audience value, scope claim name and shape, signing algorithm, `typ`,
457/// username claim) is a field below rather than a code path.
458///
459/// Every field is optional in serde input (feature `serde`); unknown keys are
460/// refused. **Nest this in your own config; do not `#[serde(flatten)]` it** —
461/// flattening silently defeats the unknown-key check (a general serde
462/// limitation, not specific to this crate); see [Embedding `OAuthConfig` in
463/// your own
464/// config](https://github.com/St0nefish/oauth-resource-server#embedding-oauthconfig-in-your-own-config)
465/// in the README. Nothing here hot-reloads: a changed value takes effect only
466/// when a new validator is built from it, which in practice means a restart.
467///
468/// Deliberately not `#[non_exhaustive]`, so applications can write
469/// `OAuthConfig { enabled: true, ..OAuthConfig::default() }` — a
470/// functional-record update that keeps compiling even after a field is
471/// added. What breaks instead is an exhaustive struct literal or
472/// destructuring pattern that names every field, which is possible only
473/// because every field here is public and the struct carries no
474/// `#[non_exhaustive]`; adding a field is still a breaking change under
475/// `0.x` (a new minor release, which Cargo treats as incompatible), just not
476/// for the functional-record-update form above.
477///
478/// `Debug` is hand-written: `issuer`, `jwks_uri` and `resource` are shown with
479/// any userinfo, query or fragment masked (`***@`, `?***`, `#***`), since a
480/// URL may carry a credential; every other field prints as a derive would.
481#[derive(Clone, PartialEq, Eq)]
482#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
483#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
484pub struct OAuthConfig {
485    /// Master switch. False (the default) means [`OAuthConfig::resolve`] returns
486    /// `Ok(None)`: no JWT validation happens at all and nothing else here is
487    /// checked.
488    #[cfg_attr(feature = "serde", serde(default))]
489    pub enabled: bool,
490    /// The authorization server's issuer identifier, compared BYTE-EXACTLY
491    /// against each token's `iss` claim and echoed verbatim in the
492    /// protected-resource metadata's `authorization_servers`. Copy it from the
493    /// AS's own discovery document including any trailing slash — Authentik's
494    /// issuer ends in one, and a token minted with `.../app/` will not match
495    /// `.../app`. Required; an absolute URL with no query or fragment, and no
496    /// space, control or non-ASCII character. Write it `https://host/...`: a
497    /// spelling the URL parser repairs (`https:/host`) never matches a token's
498    /// `iss` byte-for-byte, and `OAuthValidator::new` warns about one.
499    ///
500    /// `https`, as RFC 8414 §2 requires of an issuer: the signing keys are
501    /// found through it, and keys fetched over cleartext can be substituted by
502    /// anyone on the path. Plain `http` is accepted only for a loopback host,
503    /// or with [`OAuthConfig::allow_insecure_http`].
504    ///
505    /// Userinfo (`https://user:pass@…`) is accepted, and sent as HTTP Basic
506    /// auth on the discovery fetches; this crate redacts it wherever it
507    /// displays the URL (log lines, [`crate::RefreshError`], configuration
508    /// problems, the `Debug` output of the config types, the validator and the
509    /// layers). The issuer is
510    /// also published verbatim in the RFC 9728 metadata document, though, so
511    /// a credential does not belong in it.
512    #[cfg_attr(feature = "serde", serde(default))]
513    pub issuer: String,
514    /// Where to fetch the signing keys (JWKS). Optional: absent or blank, it is
515    /// discovered from the issuer's own metadata (OpenID Connect Discovery, then
516    /// RFC 8414), and the discovered document's `issuer` must equal `issuer`
517    /// byte-for-byte or it is refused. Setting it explicitly skips discovery.
518    /// Fetched at startup and hourly in the background, and on an unknown `kid`
519    /// at most once a minute. The same URL rules as `issuer` apply, except
520    /// that a query is allowed; RFC 8414 §2 requires `https` for it too.
521    ///
522    /// Userinfo (`https://user:pass@…`, sent as HTTP Basic auth) and a query
523    /// (`…/jwks?key=…`) are accepted and used unchanged for the fetch; this
524    /// crate redacts both wherever it displays the URL (log lines,
525    /// [`crate::RefreshError`], [`crate::KeySetStatus::jwks_uri`],
526    /// configuration problems, the `Debug` output of the config types, the
527    /// validator and the layers).
528    #[cfg_attr(
529        feature = "serde",
530        serde(default, skip_serializing_if = "Option::is_none")
531    )]
532    pub jwks_uri: Option<String>,
533    /// A value each token's `aud` claim must contain (string or array, RFC 7519
534    /// §4.1.3). Unioned with `audiences`; at least one of the two must be set,
535    /// and there is deliberately no default, because the right value depends on
536    /// the authorization server and a wrong guess either rejects everything or
537    /// accepts tokens meant for another service:
538    ///
539    /// - servers that honour RFC 8707 or let you configure an access-token
540    ///   audience (Authelia with a client `audience`) put the RESOURCE URL there
541    ///   — use the same value as `resource`;
542    /// - servers that ignore RFC 8707 and stamp the OAuth CLIENT ID (Authentik,
543    ///   Kanidm) need the client_id here.
544    ///
545    /// A client_id audience departs from RFC 9068 §4 (the `aud` must identify
546    /// this resource server) and from MCP's requirement that a server accept
547    /// only tokens issued for it as audience (RFC 8707 §2): every token that
548    /// client obtains from the authorization server, for any resource, carries
549    /// the same `aud`. It is sound only when that OAuth client is dedicated to
550    /// this one resource server and shared with no other API. Prefer a
551    /// resource-URL audience wherever the authorization server supports one.
552    #[cfg_attr(feature = "serde", serde(default))]
553    pub audience: String,
554    /// Additional accepted audiences. A token passes the audience check if its
555    /// `aud` contains ANY configured value. Useful while migrating from a
556    /// client_id audience to a resource-URL audience.
557    #[cfg_attr(feature = "serde", serde(default))]
558    pub audiences: Vec<String>,
559    /// This resource server's canonical identifier, published as `resource` in
560    /// the protected-resource metadata and used to derive the metadata URL
561    /// advertised in `WWW-Authenticate` (RFC 9728 §3). The public URL of the
562    /// protected endpoint, e.g. `https://api.example.com/v1`; required, with
563    /// the same URL rules as `issuer`.
564    ///
565    /// `https`, as RFC 9728 §1.2 requires of a resource identifier: it is the
566    /// URL clients send their bearer tokens to (RFC 6750 §5.3). Plain `http` is
567    /// accepted only for a loopback host, or with
568    /// [`OAuthConfig::allow_insecure_http`].
569    ///
570    /// Not implicitly compared against `aud` — list it in `audience`/`audiences`
571    /// when the authorization server stamps it there.
572    ///
573    /// It is published verbatim in the RFC 9728 metadata document and in the
574    /// `resource_metadata` of every `WWW-Authenticate` challenge, so it must
575    /// never carry a credential (userinfo is not refused, but has no place
576    /// here). This crate's own displays of it — log lines, configuration
577    /// problems, `Debug` — redact any userinfo, query or fragment all the
578    /// same; the published document and challenges cannot.
579    #[cfg_attr(feature = "serde", serde(default))]
580    pub resource: String,
581    /// A scope every token must carry. A valid token missing it gets 403
582    /// `insufficient_scope`, not 401. A single RFC 6749 §3.3 scope-token —
583    /// printable ASCII with no space, `"` or `\` — matched exactly and
584    /// case-sensitively. Unioned with `required_scopes`.
585    ///
586    /// No default. With neither this nor `required_scopes` set, no scope is
587    /// checked, and [`OAuthConfig::resolve`] then insists on
588    /// `require_at_jwt` or [`OAuthConfig::allow_unscoped_tokens`]. An
589    /// explicitly empty value is an error rather than "no scope", so a typo
590    /// cannot silently drop the check.
591    ///
592    /// The check is an exact all-of match with no scope hierarchy: a token
593    /// holding only `api:write` does not satisfy `api:read`, whatever the
594    /// authorization server means by it. Require a scope every accepted token
595    /// carries, and make finer, hierarchy-aware decisions in the application
596    /// with [`crate::AuthorizedToken::has_scope`].
597    #[cfg_attr(
598        feature = "serde",
599        serde(default, skip_serializing_if = "Option::is_none")
600    )]
601    pub required_scope: Option<String>,
602    /// Further scopes every token must carry — ALL of them, together with
603    /// `required_scope`. Each entry follows the same rules as `required_scope`.
604    /// Empty (the default) adds nothing.
605    #[cfg_attr(feature = "serde", serde(default))]
606    pub required_scopes: Vec<String>,
607    /// Advertised in the metadata document's `scopes_supported` and the 401
608    /// challenge's `scope` so a client knows what to ask for. Purely declarative —
609    /// enforcement is `required_scope`/`required_scopes`. Each entry is a
610    /// scope-token, as for `required_scope`.
611    ///
612    /// `None` (the default, and what an omitted key deserializes to) resolves to
613    /// the required scopes (`required_scope`, then `required_scopes`), so a
614    /// client that asks for exactly what is advertised gets a token that
615    /// passes. `Some(vec![])` resolves to an empty list: the metadata document
616    /// then omits `scopes_supported` (RFC 9728 §3.2), and the 401 challenge
617    /// names the required scopes instead. The two are kept distinct so an
618    /// application can supply its own default for an omitted key without
619    /// overriding an operator's explicit empty list — e.g.
620    /// `cfg.scopes_supported.get_or_insert_with(|| vec!["api:read".into()])`
621    /// before [`OAuthConfig::resolve`].
622    #[cfg_attr(
623        feature = "serde",
624        serde(default, skip_serializing_if = "Option::is_none")
625    )]
626    pub scopes_supported: Option<Vec<String>>,
627    /// Which claims hold the token's scopes. Every listed claim is read in every
628    /// shape — a space-delimited string or an array of strings — and the results
629    /// are unioned. The default ([`DEFAULT_SCOPE_CLAIMS`]) reads RFC 9068's
630    /// `scope` AND the `scp` that Authelia, Okta, Ory Hydra and Entra ID use
631    /// instead; reading a claim a token does not carry changes nothing.
632    #[cfg_attr(feature = "serde", serde(default = "default_scope_claims"))]
633    pub scope_claims: Vec<String>,
634    /// Claims tried in order to name the caller in logs — the first present,
635    /// non-empty string wins. Several servers put no username in access tokens
636    /// (Authelia, Kanidm: only a UUID `sub`), hence a chain ending in `sub`.
637    /// `email` is not in the default ([`DEFAULT_PRINCIPAL_CLAIMS`]) so addresses
638    /// do not land in logs unasked. Used for logging only, never for an
639    /// authorization decision.
640    #[cfg_attr(feature = "serde", serde(default = "default_principal_claims"))]
641    pub principal_claims: Vec<String>,
642    /// JWS algorithms a token may be signed with. Each key in the JWKS is
643    /// additionally limited to the algorithms its own type (and its `alg`, when
644    /// it declares one) can produce. `HS256`/`HS384`/`HS512` and `none` are
645    /// refused at resolve time: a resource server must never verify with a shared
646    /// secret. The default ([`DEFAULT_ALGORITHMS`]) is every asymmetric algorithm
647    /// this build can verify.
648    #[cfg_attr(feature = "serde", serde(default = "default_algorithms"))]
649    pub algorithms: Vec<String>,
650    /// Clock-skew allowance, in seconds, applied to `exp` and `nbf`. Default
651    /// [`DEFAULT_LEEWAY_SECS`]; capped at [`MAX_LEEWAY_SECS`], since a leeway
652    /// comparable to a token's lifetime is a way of disabling expiry.
653    #[cfg_attr(feature = "serde", serde(default = "default_leeway_secs"))]
654    pub leeway_secs: u64,
655    /// Require the JWT header `typ` to be `at+jwt` (RFC 9068 §2.1). Off by
656    /// default because Authentik, Keycloak, Entra ID and Okta emit `JWT` or no
657    /// `typ`. Turn it ON for servers that do emit `at+jwt` (Authelia, Kanidm): it
658    /// is the check that stops an ID token minted for the same client from being
659    /// replayed as an access token. With it off, `at+jwt`, `JWT` and no `typ` pass
660    /// and any other type (`dpop+jwt`, `logout+jwt`...) is still refused.
661    ///
662    /// Off is a deliberate, configurable departure from RFC 9068 §4, under
663    /// which a resource server MUST reject any `typ` other than `at+jwt` or
664    /// `application/at+jwt`; RFC 8725 §3.11 recommends the same explicit
665    /// typing. With it off, a required scope is what keeps ID tokens out.
666    #[cfg_attr(feature = "serde", serde(default))]
667    pub require_at_jwt: bool,
668    /// Accept a configuration with no required scope and `require_at_jwt`
669    /// off. Default false: [`OAuthConfig::resolve`] refuses that combination,
670    /// because nothing in it tells an access token from an OIDC ID token
671    /// minted for the same client (RFC 8725 §3.11–3.12), and on servers that
672    /// stamp the client_id as `aud` (Authentik, Kanidm) the ID token a front
673    /// end got at login would then be a working API credential. Set it only
674    /// when "signed by this issuer for this audience" really is all the
675    /// application needs; the validator still logs a `warn` at startup.
676    #[cfg_attr(feature = "serde", serde(default))]
677    pub allow_unscoped_tokens: bool,
678    /// Accept a plain-`http` `issuer`, `jwks_uri` or `resource` on a
679    /// non-loopback host. Default false: [`OAuthConfig::resolve`] refuses one,
680    /// because signing keys fetched over cleartext can be substituted by anyone
681    /// on the path (RFC 8414 §2 requires `https` for the issuer and its
682    /// `jwks_uri`), and bearer tokens sent to a cleartext resource can be read
683    /// in transit (RFC 9728 §1.2, RFC 6750 §5.3). Loopback hosts (`127.0.0.0/8`,
684    /// `::1`, `localhost`) are always allowed, for tests and local development.
685    ///
686    /// The same rule holds at run time for URLs the configuration does not
687    /// name: a `jwks_uri` discovered from a (plain-`http`) issuer's metadata,
688    /// and every redirect a metadata or JWKS fetch follows, may reach plain
689    /// `http` on a non-loopback host only with this set. An `https` issuer
690    /// never hands out an `http` `jwks_uri`, and a redirect from `https` to
691    /// `http` is never followed, whatever this says.
692    ///
693    /// Set it for an in-cluster address on a private network (an
694    /// `http://idp:9000/...` `jwks_uri`, say) where the path itself is trusted;
695    /// the validator still logs a `warn` for each such URL at startup, and for
696    /// each such discovered URL or redirect when it is used.
697    #[cfg_attr(feature = "serde", serde(default))]
698    pub allow_insecure_http: bool,
699    /// Whether a static token (an API key the application configures
700    /// separately) is still accepted while OAuth is on. Default true: both
701    /// credentials work side by side. Set false to run OAuth-only even if a
702    /// static token is configured (it is then ignored). Read by
703    /// [`crate::static_token_policy`]; the validator itself never looks at it.
704    #[cfg_attr(feature = "serde", serde(default = "default_true"))]
705    pub accept_static_bearer: bool,
706    /// The OAuth clients whose tokens are accepted. A token's client is its
707    /// `client_id` claim (RFC 9068 §2.2), else its `azp` — the first that is a
708    /// non-empty string, exactly as [`crate::AuthorizedToken::client_id`]
709    /// reads it — and it must equal one entry, byte for byte. A token naming
710    /// no client, or another one, is refused (401,
711    /// [`crate::InvalidTokenKind::ClientNotAllowed`]). `client_id` wins when
712    /// both are present: a token whose `client_id` is not listed is refused
713    /// even if its `azp` is, and a `client_id` that is present but empty or
714    /// not a string is refused too, never read past to `azp` (stricter than
715    /// the `AuthorizedToken::client_id` accessor, which skips it).
716    ///
717    /// Empty (the default) checks nothing. Use it where the audience is
718    /// shared: an authorization server that stamps an API identifier as `aud`
719    /// (Auth0, Okta custom authorization servers, Entra ID app ID URIs) gives
720    /// every client of that API a token this resource server would otherwise
721    /// accept. Entries must not be blank.
722    ///
723    /// # Examples
724    ///
725    /// ```
726    /// # use oauth_resource_server::{KeyNaming, OAuthConfig};
727    /// let base = OAuthConfig {
728    ///     enabled: true,
729    ///     issuer: "https://auth.example.com/".into(),
730    ///     audience: "https://api.example.com/".into(),
731    ///     resource: "https://api.example.com/".into(),
732    ///     required_scope: Some("api:read".into()),
733    ///     ..OAuthConfig::default()
734    /// };
735    /// let resolved = OAuthConfig {
736    ///     allowed_client_ids: vec!["web-app".into(), "cli".into()],
737    ///     ..base
738    /// }
739    /// .resolve(KeyNaming::Dotted("oauth"))
740    /// .unwrap()
741    /// .unwrap();
742    /// assert_eq!(resolved.allowed_client_ids, ["web-app", "cli"]);
743    /// ```
744    #[cfg_attr(feature = "serde", serde(default))]
745    pub allowed_client_ids: Vec<String>,
746    /// Refuse a token issued more than this many seconds ago: `now - iat`
747    /// must not exceed it, with [`OAuthConfig::leeway_secs`] of slack. With it
748    /// set, a token must carry `iat` as a NumericDate (a missing one is
749    /// [`crate::InvalidTokenKind::MissingClaim`], a malformed one
750    /// [`MalformedClaim`](crate::InvalidTokenKind::MalformedClaim)), and an
751    /// `iat` later than now plus the leeway is refused as
752    /// [`NotYetValid`](crate::InvalidTokenKind::NotYetValid); too old is
753    /// [`TokenTooOld`](crate::InvalidTokenKind::TokenTooOld). All 401.
754    ///
755    /// `None` (the default) checks nothing, and `iat` stays optional. Bounds a
756    /// token's usable age independently of the `exp` the authorization server
757    /// chose — useful when it issues long-lived tokens. `1..=`
758    /// [`MAX_TOKEN_AGE_SECS`] (30 days); `0` is refused.
759    ///
760    /// # Examples
761    ///
762    /// ```
763    /// # use oauth_resource_server::{KeyNaming, OAuthConfig};
764    /// let base = OAuthConfig {
765    ///     enabled: true,
766    ///     issuer: "https://auth.example.com/".into(),
767    ///     audience: "https://api.example.com/".into(),
768    ///     resource: "https://api.example.com/".into(),
769    ///     required_scope: Some("api:read".into()),
770    ///     ..OAuthConfig::default()
771    /// };
772    /// // Refuse tokens issued more than an hour ago (plus the leeway).
773    /// let one_hour = OAuthConfig { max_token_age_secs: Some(3600), ..base.clone() };
774    /// assert!(one_hour.resolve(KeyNaming::Dotted("oauth")).is_ok());
775    ///
776    /// let zero = OAuthConfig { max_token_age_secs: Some(0), ..base };
777    /// let err = zero.resolve(KeyNaming::Dotted("oauth")).unwrap_err();
778    /// assert!(err.problems[0].contains("oauth.max_token_age_secs"));
779    /// ```
780    // Serialized even when `None`, so a consumer that derives its settings
781    // from the serialized defaults sees every setting.
782    #[cfg_attr(feature = "serde", serde(default))]
783    pub max_token_age_secs: Option<u64>,
784    /// Claims every token must carry with a given value, e.g. a tenant
785    /// (`{"tid": "<tenant id>"}`) or a group (`{"groups": "api-users"}`).
786    /// Each entry is checked against the verified claim of that name:
787    ///
788    /// - absent from the token: refused
789    ///   ([`MissingClaim`](crate::InvalidTokenKind::MissingClaim));
790    /// - equal to the value (JSON equality: type and value, so `"1"` is not
791    ///   `1`, and an integer `1` is not the float `1.0`): passes;
792    /// - an array containing an element equal to the value: passes
793    ///   (membership, for `groups`, `roles` and the like);
794    /// - anything else — a different value, `null`, an object, an array
795    ///   without the value: refused
796    ///   ([`ClaimMismatch`](crate::InvalidTokenKind::ClaimMismatch)).
797    ///
798    /// Every entry must pass. The value must be a string, number or boolean
799    /// (`null`, an array or an object is refused by
800    /// [`OAuthConfig::resolve`]); only top-level claims are matched, never a
801    /// path into a nested object. The name must not be blank, nor one of the
802    /// claims this crate already checks (`iss`, `aud`, `exp`, `nbf`, `iat`,
803    /// `cnf`). Empty (the default) checks nothing. All refusals are 401.
804    ///
805    /// Naming a scope claim (`scope`, `scp`, or any `scope_claims` entry), or
806    /// `azp`/`client_id`, is accepted but logged as a `warn` when the validator
807    /// is built: a scope string is compared as one whole value (use
808    /// `required_scopes`), and one client claim sidesteps
809    /// [`allowed_client_ids`](Self::allowed_client_ids)' precedence. An array
810    /// value is refused today; "any of these values" may be given that meaning
811    /// later, as an additive change.
812    ///
813    /// # Examples
814    ///
815    /// ```
816    /// use serde_json::json;
817    /// # use oauth_resource_server::{KeyNaming, OAuthConfig};
818    /// let base = OAuthConfig {
819    ///     enabled: true,
820    ///     issuer: "https://auth.example.com/".into(),
821    ///     audience: "https://api.example.com/".into(),
822    ///     resource: "https://api.example.com/".into(),
823    ///     required_scope: Some("api:read".into()),
824    ///     ..OAuthConfig::default()
825    /// };
826    /// // One tenant, and membership of one group (`groups` is an array claim).
827    /// let config = OAuthConfig {
828    ///     required_claims: [
829    ///         ("tid".to_string(), json!("00000000-0000-0000-0000-000000000000")),
830    ///         ("groups".to_string(), json!("api-users")),
831    ///     ]
832    ///     .into_iter()
833    ///     .collect(),
834    ///     ..base.clone()
835    /// };
836    /// assert!(config.resolve(KeyNaming::Dotted("oauth")).is_ok());
837    ///
838    /// // A claim this crate already checks, or a non-scalar value, is refused.
839    /// let err = OAuthConfig {
840    ///     required_claims: [("aud".to_string(), json!("x")), ("org".to_string(), json!({}))]
841    ///         .into_iter()
842    ///         .collect(),
843    ///     ..base
844    /// }
845    /// .resolve(KeyNaming::Dotted("oauth"))
846    /// .unwrap_err();
847    /// assert_eq!(err.problems.len(), 2);
848    /// ```
849    ///
850    /// A config file naming one claim twice is refused when it is
851    /// deserialized (a serde error naming the claim), never read as
852    /// "the last one wins".
853    #[cfg_attr(
854        feature = "serde",
855        serde(default, deserialize_with = "required_claims_without_duplicates")
856    )]
857    pub required_claims: BTreeMap<String, Value>,
858}
859
860/// Deserialize [`OAuthConfig::required_claims`] from a map, refusing a
861/// claim named twice (JSON allows duplicate keys, and a map type silently
862/// keeps the last): `{"tid": "good", "tid": "evil"}` is a configuration
863/// mistake or an injection, and either way not a value to pick one side of.
864#[cfg(feature = "serde")]
865fn required_claims_without_duplicates<'de, D>(
866    deserializer: D,
867) -> Result<BTreeMap<String, Value>, D::Error>
868where
869    D: serde::Deserializer<'de>,
870{
871    struct Claims;
872    impl<'de> serde::de::Visitor<'de> for Claims {
873        type Value = BTreeMap<String, Value>;
874        fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
875            f.write_str("a map of claim names to required values")
876        }
877        fn visit_map<A: serde::de::MapAccess<'de>>(
878            self,
879            mut map: A,
880        ) -> Result<Self::Value, A::Error> {
881            let mut claims = BTreeMap::new();
882            while let Some((name, value)) = map.next_entry::<String, Value>()? {
883                if claims.contains_key(&name) {
884                    return Err(serde::de::Error::custom(format!(
885                        "required_claims names {:?} more than once",
886                        crate::token::for_log(&name)
887                    )));
888                }
889                claims.insert(name, value);
890            }
891            Ok(claims)
892        }
893    }
894    deserializer.deserialize_map(Claims)
895}
896
897/// Hand-written so a credential in a URL setting never reaches a log line
898/// through `{:?}`.
899impl fmt::Debug for OAuthConfig {
900    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
901        // Destructured so a new field cannot be left out by accident.
902        let Self {
903            enabled,
904            issuer,
905            jwks_uri,
906            audience,
907            audiences,
908            resource,
909            required_scope,
910            required_scopes,
911            scopes_supported,
912            scope_claims,
913            principal_claims,
914            algorithms,
915            leeway_secs,
916            require_at_jwt,
917            allow_unscoped_tokens,
918            allow_insecure_http,
919            accept_static_bearer,
920            allowed_client_ids,
921            max_token_age_secs,
922            required_claims,
923        } = self;
924        f.debug_struct("OAuthConfig")
925            .field("enabled", enabled)
926            .field("issuer", &debug_url(issuer))
927            .field("jwks_uri", &jwks_uri.as_deref().map(debug_url))
928            .field("audience", audience)
929            .field("audiences", audiences)
930            .field("resource", &debug_url(resource))
931            .field("required_scope", required_scope)
932            .field("required_scopes", required_scopes)
933            .field("scopes_supported", scopes_supported)
934            .field("scope_claims", scope_claims)
935            .field("principal_claims", principal_claims)
936            .field("algorithms", algorithms)
937            .field("leeway_secs", leeway_secs)
938            .field("require_at_jwt", require_at_jwt)
939            .field("allow_unscoped_tokens", allow_unscoped_tokens)
940            .field("allow_insecure_http", allow_insecure_http)
941            .field("accept_static_bearer", accept_static_bearer)
942            .field("allowed_client_ids", allowed_client_ids)
943            .field("max_token_age_secs", max_token_age_secs)
944            .field("required_claims", required_claims)
945            .finish()
946    }
947}
948
949impl Default for OAuthConfig {
950    fn default() -> Self {
951        Self {
952            enabled: false,
953            issuer: String::new(),
954            jwks_uri: None,
955            audience: String::new(),
956            audiences: Vec::new(),
957            resource: String::new(),
958            required_scope: None,
959            required_scopes: Vec::new(),
960            scopes_supported: None,
961            scope_claims: default_scope_claims(),
962            principal_claims: default_principal_claims(),
963            algorithms: default_algorithms(),
964            leeway_secs: default_leeway_secs(),
965            require_at_jwt: false,
966            allow_unscoped_tokens: false,
967            allow_insecure_http: false,
968            accept_static_bearer: true,
969            allowed_client_ids: Vec::new(),
970            max_token_age_secs: None,
971            required_claims: BTreeMap::new(),
972        }
973    }
974}
975
976fn strings(list: &[&str]) -> Vec<String> {
977    list.iter().map(|s| s.to_string()).collect()
978}
979
980fn default_scope_claims() -> Vec<String> {
981    strings(DEFAULT_SCOPE_CLAIMS)
982}
983
984fn default_principal_claims() -> Vec<String> {
985    strings(DEFAULT_PRINCIPAL_CLAIMS)
986}
987
988fn default_algorithms() -> Vec<String> {
989    strings(DEFAULT_ALGORITHMS)
990}
991
992fn default_leeway_secs() -> u64 {
993    DEFAULT_LEEWAY_SECS
994}
995
996#[cfg(feature = "serde")]
997fn default_true() -> bool {
998    true
999}
1000
1001impl OAuthConfig {
1002    /// Validate and resolve: `Ok(None)` when disabled, `Ok(Some(..))` when
1003    /// enabled and usable, `Err` naming every problem it can find at once when
1004    /// enabled and not. `naming` decides how the problems (and, later, the
1005    /// validator's log lines) name each setting.
1006    ///
1007    /// Does no I/O: whether the issuer is reachable and publishes usable keys
1008    /// is found out later, by the validator.
1009    ///
1010    /// # Errors
1011    ///
1012    /// A [`ConfigError`] listing every problem in an enabled config: a blank
1013    /// `issuer` or `resource`, no audience in either `audience` or `audiences`,
1014    /// a URL (`issuer`, `resource` or `jwks_uri`) that is not absolute
1015    /// `http`/`https`, has surrounding whitespace or contains a space, control
1016    /// or non-ASCII character (or, for `issuer` and `resource`, that has a
1017    /// query or a fragment), a plain-`http` URL on a non-loopback host —
1018    /// decided on the URL as parsed, however it is spelled — without
1019    /// [`OAuthConfig::allow_insecure_http`], a blank or multi-word required
1020    /// scope, a required or supported scope that is not an RFC 6749 §3.3
1021    /// scope-token, no required scope with neither `require_at_jwt` nor
1022    /// [`OAuthConfig::allow_unscoped_tokens`] set, an empty `scope_claims`, a
1023    /// blank entry in a list, an algorithm that is HMAC, `none` or unknown, an
1024    /// empty algorithm list, a `leeway_secs` over [`MAX_LEEWAY_SECS`], a blank
1025    /// `allowed_client_ids` entry, a `max_token_age_secs` of `0` or over
1026    /// [`MAX_TOKEN_AGE_SECS`], or a `required_claims` entry with a blank or
1027    /// reserved name or a value that is not a string, number or boolean.
1028    ///
1029    /// # Examples
1030    ///
1031    /// ```
1032    /// use oauth_resource_server::{KeyNaming, OAuthConfig};
1033    ///
1034    /// let config = OAuthConfig {
1035    ///     enabled: true,
1036    ///     issuer: "https://auth.example.com/".into(),
1037    ///     audience: "example-api".into(),
1038    ///     resource: "https://api.example.com".into(),
1039    ///     required_scope: Some("api:read".into()),
1040    ///     scopes_supported: Some(vec!["api:read".into()]),
1041    ///     ..OAuthConfig::default()
1042    /// };
1043    /// let resolved = config.resolve(KeyNaming::Dotted("oauth")).unwrap().unwrap();
1044    /// assert_eq!(resolved.required_scopes, ["api:read"]);
1045    /// assert_eq!(resolved.jwks_uri, None); // discovered from the issuer later
1046    ///
1047    /// // Disabled: nothing is checked.
1048    /// assert_eq!(OAuthConfig::default().resolve(KeyNaming::Dotted("oauth")), Ok(None));
1049    ///
1050    /// // Broken: every problem at once, each named the way the operator wrote it.
1051    /// let err = OAuthConfig {
1052    ///     enabled: true,
1053    ///     required_scope: Some("api:read".into()),
1054    ///     leeway_secs: 3600,
1055    ///     ..OAuthConfig::default()
1056    /// }
1057    /// .resolve(KeyNaming::Env("MYAPP_OAUTH_"))
1058    /// .unwrap_err();
1059    /// assert_eq!(err.problems.len(), 2);
1060    /// assert!(err.problems[0].contains("MYAPP_OAUTH_ISSUER"));
1061    /// assert!(err.problems[1].contains("MYAPP_OAUTH_LEEWAY_SECS"));
1062    /// ```
1063    pub fn resolve(
1064        self,
1065        naming: KeyNaming<'_>,
1066    ) -> Result<Option<ResolvedOAuthConfig>, ConfigError> {
1067        if !self.enabled {
1068            return Ok(None);
1069        }
1070        let key = |field: &str| naming.key(field);
1071        let mut problems: Vec<ConfigProblem> = Vec::new();
1072
1073        let mut blank_fields: Vec<&str> = Vec::new();
1074        for (name, value) in [("issuer", &self.issuer), ("resource", &self.resource)] {
1075            if value.trim().is_empty() {
1076                blank_fields.push(name);
1077            }
1078        }
1079        if self.audience.trim().is_empty() && self.audiences.is_empty() {
1080            blank_fields.push("audience");
1081        }
1082        if !blank_fields.is_empty() {
1083            // `audience` and `audiences` are one requirement: named together.
1084            let blank: Vec<String> = blank_fields
1085                .iter()
1086                .map(|&f| match f {
1087                    "audience" => format!("{} (or {})", key("audience"), key("audiences")),
1088                    _ => key(f),
1089                })
1090                .collect();
1091            let blank_keys: Vec<String> = blank_fields
1092                .iter()
1093                .flat_map(|&f| match f {
1094                    "audience" => vec![key("audience"), key("audiences")],
1095                    _ => vec![key(f)],
1096                })
1097                .collect();
1098            problems.push(ConfigProblem::new(
1099                ProblemKind::MissingRequired,
1100                blank_keys,
1101                format!(
1102                    "these required settings are empty: {}. Set issuer to the authorization \
1103                     server's issuer (byte-exact, including any trailing slash), resource to \
1104                     this server's public URL, and audience to what that server puts in \
1105                     an access token's `aud` — the resource URL if it honours RFC 8707 or \
1106                     lets you configure an audience (e.g. Authelia), or the OAuth client_id \
1107                     if it stamps that (e.g. Authentik, Kanidm)",
1108                    blank.join(", ")
1109                ),
1110            ));
1111        }
1112
1113        let urls = [
1114            ("issuer", Some(&self.issuer), true),
1115            ("resource", Some(&self.resource), true),
1116            ("jwks_uri", self.jwks_uri.as_ref(), false),
1117        ];
1118        for (name, value, identifier) in urls {
1119            let Some(value) = value.filter(|v| !v.trim().is_empty()) else {
1120                continue;
1121            };
1122            match check_url(&key(name), value, identifier) {
1123                Err(e) => {
1124                    problems.push(ConfigProblem::new(ProblemKind::InvalidUrl, [key(name)], e));
1125                }
1126                Ok(()) if !self.allow_insecure_http && plain_http_non_loopback(value) => {
1127                    problems.push(ConfigProblem::new(
1128                        ProblemKind::InsecureHttp,
1129                        [key(name), key("allow_insecure_http")],
1130                        format!(
1131                            "{} uses plain http on a non-loopback host — {}. Use \
1132                             https, or set {} if this address is on a network you trust",
1133                            shown(&key(name), value),
1134                            if name == "resource" {
1135                                "bearer tokens sent to it can be read in transit (RFC 9728 \
1136                                 §1.2 requires https)"
1137                            } else {
1138                                "signing keys fetched over it can be substituted by anyone on \
1139                                 the path (RFC 8414 §2 requires https)"
1140                            },
1141                            key("allow_insecure_http")
1142                        ),
1143                    ));
1144                }
1145                Ok(()) => {}
1146            }
1147        }
1148        if self.audiences.iter().any(|a| a.trim().is_empty()) {
1149            problems.push(ConfigProblem::new(
1150                ProblemKind::EmptyListEntry,
1151                [key("audiences")],
1152                format!("{} contains an empty entry", key("audiences")),
1153            ));
1154        }
1155
1156        if let Some(required_scope) = &self.required_scope {
1157            let k = key("required_scope");
1158            if required_scope.trim().is_empty() {
1159                problems.push(ConfigProblem::new(
1160                    ProblemKind::BlankRequiredScope,
1161                    [k.clone()],
1162                    format!(
1163                        "{k} must not be empty — a blank required scope would let any signed \
1164                         token through unscoped. Use a scope your authorization server \
1165                         actually issues"
1166                    ),
1167                ));
1168            } else if required_scope.split_whitespace().count() != 1 {
1169                problems.push(ConfigProblem::new(
1170                    ProblemKind::MultiWordScope,
1171                    [k.clone()],
1172                    format!(
1173                        "{k} {required_scope:?} must be a single scope (no spaces) — scopes \
1174                         are matched one token at a time"
1175                    ),
1176                ));
1177            } else if !is_scope_token(required_scope.trim()) {
1178                problems.push(ConfigProblem::new(
1179                    ProblemKind::InvalidScopeToken,
1180                    [k.clone()],
1181                    scope_token_problem(&k, required_scope),
1182                ));
1183            }
1184        }
1185        for scope in &self.required_scopes {
1186            let k = key("required_scopes");
1187            if scope.trim().is_empty() {
1188                problems.push(ConfigProblem::new(
1189                    ProblemKind::BlankRequiredScope,
1190                    [k.clone()],
1191                    format!(
1192                        "{k} contains an empty entry — a blank required scope would let a \
1193                         token through without it. Remove the entry or name a scope your \
1194                         authorization server actually issues"
1195                    ),
1196                ));
1197            } else if scope.split_whitespace().count() != 1 {
1198                problems.push(ConfigProblem::new(
1199                    ProblemKind::MultiWordScope,
1200                    [k.clone()],
1201                    format!(
1202                        "{k} entry {scope:?} must be a single scope (no spaces) — scopes are \
1203                         matched one token at a time; list each one as its own entry"
1204                    ),
1205                ));
1206            } else if !is_scope_token(scope.trim()) {
1207                problems.push(ConfigProblem::new(
1208                    ProblemKind::InvalidScopeToken,
1209                    [k.clone()],
1210                    scope_token_problem(&format!("{k} entry"), scope),
1211                ));
1212            }
1213        }
1214        if let Some(supported) = &self.scopes_supported {
1215            let k = key("scopes_supported");
1216            if supported.iter().any(|s| s.trim().is_empty()) {
1217                problems.push(ConfigProblem::new(
1218                    ProblemKind::EmptyListEntry,
1219                    [k.clone()],
1220                    format!("{k} contains an empty entry"),
1221                ));
1222            }
1223            for scope in supported.iter().filter(|s| !s.trim().is_empty()) {
1224                if !is_scope_token(scope.trim()) {
1225                    problems.push(ConfigProblem::new(
1226                        ProblemKind::InvalidScopeToken,
1227                        [k.clone()],
1228                        scope_token_problem(&format!("{k} entry"), scope),
1229                    ));
1230                }
1231            }
1232        }
1233        if self.required_scope.is_none()
1234            && self.required_scopes.is_empty()
1235            && !self.require_at_jwt
1236            && !self.allow_unscoped_tokens
1237        {
1238            problems.push(ConfigProblem::new(
1239                ProblemKind::NoRequiredScope,
1240                [
1241                    key("required_scope"),
1242                    key("required_scopes"),
1243                    key("require_at_jwt"),
1244                    key("allow_unscoped_tokens"),
1245                ],
1246                format!(
1247                    "no required scope is configured ({} and {} are unset) and {} is off — \
1248                     nothing would tell an access token from an OIDC ID token minted for the \
1249                     same client, so any token this issuer signs for the audience would be \
1250                     accepted. Set {} to a scope only access tokens carry, turn on {} if the \
1251                     authorization server emits typ at+jwt, or set {} to accept that",
1252                    key("required_scope"),
1253                    key("required_scopes"),
1254                    key("require_at_jwt"),
1255                    key("required_scope"),
1256                    key("require_at_jwt"),
1257                    key("allow_unscoped_tokens")
1258                ),
1259            ));
1260        }
1261        if self.scope_claims.is_empty() || self.scope_claims.iter().any(|c| c.trim().is_empty()) {
1262            problems.push(ConfigProblem::new(
1263                ProblemKind::EmptyScopeClaims,
1264                [key("scope_claims")],
1265                format!(
1266                    "{} must list at least one non-empty claim name (default: [\"scope\", \
1267                     \"scp\"])",
1268                    key("scope_claims")
1269                ),
1270            ));
1271        }
1272        if self.principal_claims.iter().any(|c| c.trim().is_empty()) {
1273            problems.push(ConfigProblem::new(
1274                ProblemKind::EmptyListEntry,
1275                [key("principal_claims")],
1276                format!("{} contains an empty entry", key("principal_claims")),
1277            ));
1278        }
1279
1280        let mut algorithms = Vec::new();
1281        let mut bad_algorithms = Vec::new();
1282        for name in &self.algorithms {
1283            match parse_algorithm(name) {
1284                Ok(alg) if !algorithms.contains(&alg) => algorithms.push(alg),
1285                Ok(_) => {}
1286                Err(reason) => bad_algorithms.push(reason.to_string()),
1287            }
1288        }
1289        if !bad_algorithms.is_empty() {
1290            problems.push(ConfigProblem::new(
1291                ProblemKind::BadAlgorithm,
1292                [key("algorithms")],
1293                format!(
1294                    "{} has unacceptable entries: {}",
1295                    key("algorithms"),
1296                    bad_algorithms.join("; ")
1297                ),
1298            ));
1299        } else if algorithms.is_empty() {
1300            problems.push(ConfigProblem::new(
1301                ProblemKind::NoAlgorithms,
1302                [key("algorithms")],
1303                format!("{} must list at least one algorithm", key("algorithms")),
1304            ));
1305        }
1306
1307        if self.leeway_secs > MAX_LEEWAY_SECS {
1308            problems.push(ConfigProblem::new(
1309                ProblemKind::LeewayTooLarge,
1310                [key("leeway_secs")],
1311                format!(
1312                    "{} {} is over the {}-second cap — leeway is for clock drift, not for \
1313                     extending token lifetimes",
1314                    key("leeway_secs"),
1315                    self.leeway_secs,
1316                    MAX_LEEWAY_SECS
1317                ),
1318            ));
1319        }
1320
1321        if self.allowed_client_ids.iter().any(|c| c.trim().is_empty()) {
1322            problems.push(ConfigProblem::new(
1323                ProblemKind::EmptyListEntry,
1324                [key("allowed_client_ids")],
1325                format!(
1326                    "{} contains an empty entry — list the OAuth client IDs whose tokens \
1327                     are accepted",
1328                    key("allowed_client_ids")
1329                ),
1330            ));
1331        }
1332        if let Some(age) = self.max_token_age_secs
1333            && !(1..=MAX_TOKEN_AGE_SECS).contains(&age)
1334        {
1335            problems.push(ConfigProblem::new(
1336                ProblemKind::TokenAgeOutOfRange,
1337                [key("max_token_age_secs")],
1338                format!(
1339                    "{} {age} is outside 1..={MAX_TOKEN_AGE_SECS} seconds (30 days) — leave it \
1340                     unset to not bound token age",
1341                    key("max_token_age_secs")
1342                ),
1343            ));
1344        }
1345        for (name, value) in &self.required_claims {
1346            let k = key("required_claims");
1347            let why = if name.trim().is_empty() {
1348                Some("has an entry with a blank claim name".to_string())
1349            } else if RESERVED_REQUIRED_CLAIMS.contains(&name.as_str()) {
1350                Some(format!(
1351                    "names {name:?}, which this crate already checks (iss, aud, exp, nbf, iat, \
1352                     cnf) — use issuer/audience, leeway_secs or max_token_age_secs instead"
1353                ))
1354            } else if !matches!(value, Value::String(_) | Value::Number(_) | Value::Bool(_)) {
1355                // Named by its JSON type, never echoed: an object or array
1356                // could hold anything, a secret included, and this text
1357                // reaches the startup log.
1358                let json_type = match value {
1359                    Value::Null => "null",
1360                    Value::Array(_) => "an array",
1361                    _ => "an object",
1362                };
1363                Some(format!(
1364                    "entry {name:?} requires {json_type}, but a required value must be a \
1365                     string, number or boolean (a token's array claim passes when it contains \
1366                     it)"
1367                ))
1368            } else {
1369                None
1370            };
1371            if let Some(why) = why {
1372                problems.push(ConfigProblem::new(
1373                    ProblemKind::InvalidRequiredClaim,
1374                    [k.clone()],
1375                    format!("{k} {why}"),
1376                ));
1377            }
1378        }
1379
1380        if !problems.is_empty() {
1381            return Err(ConfigError::from_problems(naming, problems));
1382        }
1383
1384        // `required_scope` first, then `required_scopes` in order, trimmed and
1385        // deduplicated — a stable order keeps the 403 challenge's `scope` value
1386        // the same from one start to the next.
1387        let mut required_scopes: Vec<String> = Vec::new();
1388        for scope in self
1389            .required_scope
1390            .iter()
1391            .chain(self.required_scopes.iter())
1392        {
1393            let scope = scope.trim();
1394            if !required_scopes.iter().any(|s| s == scope) {
1395                required_scopes.push(scope.to_string());
1396            }
1397        }
1398
1399        // An omitted `scopes_supported` advertises what is required, so a client
1400        // that asks for exactly the advertised scopes gets a token that passes.
1401        let scopes_supported = match self.scopes_supported {
1402            Some(listed) => listed.iter().map(|s| s.trim().to_string()).collect(),
1403            None => required_scopes.clone(),
1404        };
1405
1406        Ok(Some(ResolvedOAuthConfig {
1407            issuer: self.issuer,
1408            jwks_uri: self.jwks_uri.filter(|u| !u.trim().is_empty()),
1409            audience: self.audience,
1410            audiences: self.audiences,
1411            resource: self.resource,
1412            required_scopes,
1413            scopes_supported,
1414            scope_claims: self.scope_claims,
1415            principal_claims: self.principal_claims,
1416            algorithms,
1417            leeway_secs: self.leeway_secs,
1418            require_at_jwt: self.require_at_jwt,
1419            allow_unscoped_tokens: self.allow_unscoped_tokens,
1420            allow_insecure_http: self.allow_insecure_http,
1421            accept_static_bearer: self.accept_static_bearer,
1422            allowed_client_ids: self.allowed_client_ids,
1423            max_token_age_secs: self.max_token_age_secs,
1424            required_claims: self.required_claims,
1425            resource_name: None,
1426            key_naming: naming.to_buf(),
1427        }))
1428    }
1429}
1430
1431/// Check that a URL setting is an absolute http(s) URL, and (for the two
1432/// identifiers, `issuer` and `resource`) that it carries no fragment or query:
1433/// RFC 8414 §2 forbids both in an issuer, RFC 8707 §2 a fragment in a resource,
1434/// and either one in an identifier that is compared byte-for-byte is a typo
1435/// waiting to reject every token.
1436///
1437/// It also refuses a space, a control character or a non-ASCII character
1438/// anywhere in the value. The URL parser would silently drop an embedded tab or
1439/// newline and percent-encode the rest, but the RAW string is what is stored,
1440/// compared and echoed into every `WWW-Authenticate` challenge — where such a
1441/// character makes the header invalid, and the 401 would go out without one.
1442///
1443/// A spelling the parser repairs (`https:/host`, `http:\\host`) is accepted
1444/// here; `OAuthValidator::build` warns about it (`non_canonical_url`), and the
1445/// plain-http check in `resolve` decides on the parsed URL, so no spelling
1446/// avoids it.
1447fn check_url(key: &str, value: &str, identifier: bool) -> Result<(), String> {
1448    // The value is never echoed raw: it may carry a credential (userinfo, or a
1449    // `jwks_uri` query), and a `ConfigError` is normally logged at startup.
1450    let parsed = reqwest::Url::parse(value.trim()).map_err(|e| {
1451        format!(
1452            "{} is not an absolute URL ({e})",
1453            shown_unparsed(key, value)
1454        )
1455    })?;
1456    if !matches!(parsed.scheme(), "https" | "http") {
1457        return Err(format!("{} must be an http(s) URL", shown(key, value)));
1458    }
1459    if identifier && (parsed.fragment().is_some() || parsed.query().is_some()) {
1460        return Err(format!(
1461            "{} must not contain a query or fragment",
1462            shown(key, value)
1463        ));
1464    }
1465    if value != value.trim() {
1466        return Err(format!(
1467            "{} has leading/trailing whitespace — it is compared byte-for-byte",
1468            shown(key, value)
1469        ));
1470    }
1471    if value.chars().any(|c| !c.is_ascii_graphic()) {
1472        return Err(format!(
1473            "{} contains a space, a control character or a non-ASCII character — write it \
1474             percent-encoded (and an internationalized host in its punycode form)",
1475            shown(key, value)
1476        ));
1477    }
1478    Ok(())
1479}
1480
1481/// `key` and the URL `value` for a problem message, the value through
1482/// [`try_redact_url`] (quoted, userinfo/query/fragment masked, and marked as
1483/// such when that changed it, so a whitespace or non-ASCII diagnosis never
1484/// quotes a value that looks clean). A value that has no host, or hides an
1485/// `@` in its path, falls back to [`shown_unparsed`].
1486fn shown(key: &str, value: &str) -> String {
1487    match try_redact_url(value) {
1488        Some(redacted) if redacted == value => format!("{key} {redacted:?}"),
1489        Some(redacted) => format!("{key} {redacted:?} (shown normalized, credential masked)"),
1490        None => shown_unparsed(key, value),
1491    }
1492}
1493
1494/// `key` and a `value` that is not a URL [`try_redact_url`] can mask: quoted
1495/// (and truncated) only when it cannot hold a credential — no `@` (so no
1496/// userinfo), no `?` or `#` (so no query or fragment), and nothing but visible
1497/// ASCII. Otherwise the setting alone; the message says what is wrong.
1498fn shown_unparsed(key: &str, value: &str) -> String {
1499    let safe = !value.is_empty()
1500        && value
1501            .chars()
1502            .all(|c| c.is_ascii_graphic() && !matches!(c, '@' | '?' | '#'));
1503    if safe {
1504        format!("{key} {:?}", for_log(value))
1505    } else {
1506        key.to_string()
1507    }
1508}
1509
1510/// RFC 6749 §3.3 `scope-token = 1*( %x21 / %x23-5B / %x5D-7E )`: printable
1511/// ASCII other than space, `"` and `\`. RFC 6750 §3 holds the `scope` attribute
1512/// of a challenge to the same set, and RFC 9728 §2 `scopes_supported` to the
1513/// same values.
1514pub(crate) fn is_scope_token(scope: &str) -> bool {
1515    !scope.is_empty()
1516        && scope
1517            .bytes()
1518            .all(|b| b == 0x21 || (0x23..=0x5B).contains(&b) || (0x5D..=0x7E).contains(&b))
1519}
1520
1521fn scope_token_problem(what: &str, scope: &str) -> String {
1522    format!(
1523        "{what} {scope:?} is not a valid scope — a scope is printable ASCII with no space, \
1524         '\"' or '\\' (RFC 6749 §3.3)"
1525    )
1526}
1527
1528/// The validated config an [`crate::OAuthValidator`] is built from.
1529///
1530/// Only [`OAuthConfig::resolve`] produces one with every invariant checked, so
1531/// holding a `ResolvedOAuthConfig` (rather than an `Option` of one) IS the answer
1532/// to "is OAuth on and usable" — nothing downstream re-checks a boolean. Fields
1533/// are public so tests and applications can adjust a resolved value (the
1534/// validator re-checks the invariants whose violation it could not survive: a
1535/// non-empty audience set, a non-empty algorithm list, and `leeway_secs` within
1536/// [`MAX_LEEWAY_SECS`]).
1537///
1538/// `#[non_exhaustive]`: outside this crate one comes from
1539/// [`OAuthConfig::resolve`] (or, in tests, `testing::resolved_config`) and is
1540/// then adjusted field by field, never built with a struct literal — which is
1541/// what lets a new resolved setting be added without a breaking change.
1542///
1543/// `Debug` is hand-written and masks a credential in `issuer`, `jwks_uri` and
1544/// `resource` the way [`OAuthConfig`]'s does.
1545#[derive(Clone, PartialEq, Eq)]
1546#[non_exhaustive]
1547pub struct ResolvedOAuthConfig {
1548    /// See [`OAuthConfig::issuer`]; byte-exact.
1549    pub issuer: String,
1550    /// `None` means "discover from the issuer's metadata". Never blank.
1551    pub jwks_uri: Option<String>,
1552    /// The single audience; may be empty when `audiences` is not. Use
1553    /// [`ResolvedOAuthConfig::accepted_audiences`] for the effective set.
1554    pub audience: String,
1555    /// See [`OAuthConfig::audiences`].
1556    pub audiences: Vec<String>,
1557    /// See [`OAuthConfig::resource`].
1558    pub resource: String,
1559    /// `required_scope` ∪ `required_scopes`: trimmed, deduplicated, in config
1560    /// order (`required_scope` first). A token must carry every one; empty means
1561    /// no scope check at all.
1562    pub required_scopes: Vec<String>,
1563    /// See [`OAuthConfig::scopes_supported`]; `None` there resolves to
1564    /// `required_scopes`. Advertised in the metadata document (omitted from it
1565    /// when empty) and in the 401 challenge.
1566    pub scopes_supported: Vec<String>,
1567    /// See [`OAuthConfig::scope_claims`].
1568    pub scope_claims: Vec<String>,
1569    /// See [`OAuthConfig::principal_claims`].
1570    pub principal_claims: Vec<String>,
1571    /// Parsed and deduplicated. [`Algorithm`] has no HMAC or `none` variant,
1572    /// so this can never hold one.
1573    pub algorithms: Vec<Algorithm>,
1574    /// See [`OAuthConfig::leeway_secs`].
1575    pub leeway_secs: u64,
1576    /// See [`OAuthConfig::require_at_jwt`].
1577    pub require_at_jwt: bool,
1578    /// See [`OAuthConfig::allow_unscoped_tokens`]. Informational once
1579    /// resolved: `resolve` has already applied it.
1580    pub allow_unscoped_tokens: bool,
1581    /// See [`OAuthConfig::allow_insecure_http`]. `resolve` has already applied
1582    /// it to the configured URLs; the validator still reads it, for a
1583    /// discovered `jwks_uri` and for redirects followed while fetching keys.
1584    pub allow_insecure_http: bool,
1585    /// See [`OAuthConfig::accept_static_bearer`].
1586    pub accept_static_bearer: bool,
1587    /// See [`OAuthConfig::allowed_client_ids`]; empty means no client check.
1588    pub allowed_client_ids: Vec<String>,
1589    /// See [`OAuthConfig::max_token_age_secs`]; `None` means no age check.
1590    pub max_token_age_secs: Option<u64>,
1591    /// See [`OAuthConfig::required_claims`]; empty means no claim check.
1592    pub required_claims: BTreeMap<String, Value>,
1593    /// Human-readable name published as `resource_name` in the RFC 9728
1594    /// metadata document; omitted from it when `None`. Not a config key:
1595    /// [`OAuthConfig::resolve`] leaves it `None`, and an application that wants
1596    /// one sets it on the resolved value (it names the application, which the
1597    /// operator has no reason to change).
1598    pub resource_name: Option<String>,
1599    /// How the validator's log lines and errors name settings; what `resolve`
1600    /// was given.
1601    pub key_naming: KeyNamingBuf,
1602}
1603
1604/// Hand-written so a credential in a URL setting never reaches a log line
1605/// through `{:?}`.
1606impl fmt::Debug for ResolvedOAuthConfig {
1607    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1608        // Destructured so a new field cannot be left out by accident.
1609        let Self {
1610            issuer,
1611            jwks_uri,
1612            audience,
1613            audiences,
1614            resource,
1615            required_scopes,
1616            scopes_supported,
1617            scope_claims,
1618            principal_claims,
1619            algorithms,
1620            leeway_secs,
1621            require_at_jwt,
1622            allow_unscoped_tokens,
1623            allow_insecure_http,
1624            accept_static_bearer,
1625            allowed_client_ids,
1626            max_token_age_secs,
1627            required_claims,
1628            resource_name,
1629            key_naming,
1630        } = self;
1631        f.debug_struct("ResolvedOAuthConfig")
1632            .field("issuer", &debug_url(issuer))
1633            .field("jwks_uri", &jwks_uri.as_deref().map(debug_url))
1634            .field("audience", audience)
1635            .field("audiences", audiences)
1636            .field("resource", &debug_url(resource))
1637            .field("required_scopes", required_scopes)
1638            .field("scopes_supported", scopes_supported)
1639            .field("scope_claims", scope_claims)
1640            .field("principal_claims", principal_claims)
1641            .field("algorithms", algorithms)
1642            .field("leeway_secs", leeway_secs)
1643            .field("require_at_jwt", require_at_jwt)
1644            .field("allow_unscoped_tokens", allow_unscoped_tokens)
1645            .field("allow_insecure_http", allow_insecure_http)
1646            .field("accept_static_bearer", accept_static_bearer)
1647            .field("allowed_client_ids", allowed_client_ids)
1648            .field("max_token_age_secs", max_token_age_secs)
1649            .field("required_claims", required_claims)
1650            .field("resource_name", resource_name)
1651            .field("key_naming", key_naming)
1652            .finish()
1653    }
1654}
1655
1656impl ResolvedOAuthConfig {
1657    /// `audience` ∪ `audiences`, blanks dropped, in config order.
1658    pub fn accepted_audiences(&self) -> Vec<String> {
1659        let mut out: Vec<String> = Vec::new();
1660        for a in std::iter::once(&self.audience).chain(self.audiences.iter()) {
1661            if !a.trim().is_empty() && !out.contains(a) {
1662                out.push(a.clone());
1663            }
1664        }
1665        out
1666    }
1667}
1668
1669#[cfg(test)]
1670mod tests {
1671    use super::*;
1672
1673    const WIKI: KeyNaming<'static> = KeyNaming::Dotted("mcp.oauth");
1674
1675    /// An enabled block with every required key set, for the rejection tests to
1676    /// break one key at a time. It opts into an unscoped configuration, so a
1677    /// test that sets no scope is not also refused for that; the unscoped
1678    /// refusal has tests of its own.
1679    fn enabled(edit: impl FnOnce(&mut OAuthConfig)) -> OAuthConfig {
1680        let mut cfg = OAuthConfig {
1681            enabled: true,
1682            issuer: "https://idp.example.test/".into(),
1683            resource: "https://kb.example.test/mcp".into(),
1684            audience: "c".into(),
1685            allow_unscoped_tokens: true,
1686            ..OAuthConfig::default()
1687        };
1688        edit(&mut cfg);
1689        cfg
1690    }
1691
1692    fn resolve_err(cfg: OAuthConfig) -> String {
1693        cfg.resolve(WIKI).unwrap_err().to_string()
1694    }
1695
1696    // ── defaults ─────────────────────────────────────────────────────────────
1697
1698    #[test]
1699    fn oauth_is_disabled_by_default_and_has_no_default_scope() {
1700        let cfg = OAuthConfig::default();
1701        assert!(!cfg.enabled);
1702        // The crate is not MCP-specific: no required scope and no advertised
1703        // scopes unless the application or operator names them.
1704        assert_eq!(cfg.required_scope, None);
1705        assert!(cfg.required_scopes.is_empty());
1706        assert_eq!(cfg.scopes_supported, None);
1707        assert_eq!(cfg.jwks_uri, None);
1708        assert_eq!(cfg.scope_claims, ["scope", "scp"]);
1709        assert_eq!(cfg.principal_claims, ["preferred_username", "sub"]);
1710        assert_eq!(cfg.algorithms, DEFAULT_ALGORITHMS);
1711        assert_eq!(cfg.leeway_secs, 60);
1712        assert!(!cfg.require_at_jwt);
1713        assert!(!cfg.allow_unscoped_tokens);
1714        assert!(!cfg.allow_insecure_http);
1715        assert!(cfg.accept_static_bearer);
1716    }
1717
1718    #[test]
1719    fn disabled_resolves_to_none() {
1720        assert_eq!(OAuthConfig::default().resolve(WIKI), Ok(None));
1721        // Even a broken disabled block: nothing is checked.
1722        let cfg = OAuthConfig {
1723            algorithms: vec!["HS256".into()],
1724            ..OAuthConfig::default()
1725        };
1726        assert_eq!(cfg.resolve(WIKI), Ok(None));
1727    }
1728
1729    #[test]
1730    fn a_minimal_enabled_block_resolves_with_every_default() {
1731        let cfg = OAuthConfig {
1732            enabled: true,
1733            issuer: "https://authentik.example.test/application/o/example-app/".into(),
1734            jwks_uri: Some("https://authentik.example.test/application/o/example-app/jwks/".into()),
1735            audience: "some-client-id".into(),
1736            resource: "https://kb.example.test/mcp".into(),
1737            required_scope: Some("api:read".into()),
1738            ..OAuthConfig::default()
1739        };
1740        let oauth = cfg.resolve(WIKI).unwrap().expect("enabled");
1741        // The trailing slash must survive verbatim: it is compared byte-exactly
1742        // against the `iss` claim, and Authentik's issuer has one.
1743        assert_eq!(
1744            oauth.issuer,
1745            "https://authentik.example.test/application/o/example-app/"
1746        );
1747        assert_eq!(oauth.audience, "some-client-id");
1748        assert_eq!(oauth.resource, "https://kb.example.test/mcp");
1749        assert_eq!(oauth.required_scopes, ["api:read"]);
1750        // An omitted `scopes_supported` advertises the required scopes.
1751        assert_eq!(oauth.scopes_supported, ["api:read"]);
1752        assert_eq!(oauth.accepted_audiences(), ["some-client-id"]);
1753        assert_eq!(oauth.scope_claims, ["scope", "scp"]);
1754        assert_eq!(oauth.principal_claims, ["preferred_username", "sub"]);
1755        assert!(oauth.algorithms.contains(&Algorithm::RS256));
1756        assert_eq!(oauth.leeway_secs, 60);
1757        assert!(!oauth.require_at_jwt);
1758        assert!(!oauth.allow_unscoped_tokens);
1759        assert!(!oauth.allow_insecure_http);
1760        assert!(oauth.accept_static_bearer);
1761        assert_eq!(oauth.resource_name, None);
1762        assert_eq!(oauth.key_naming, KeyNamingBuf::Dotted("mcp.oauth".into()));
1763    }
1764
1765    // ── rejection: every problem at once, each naming its key ────────────────
1766
1767    #[test]
1768    fn enabled_with_blank_required_settings_is_rejected_naming_all_of_them() {
1769        let cfg = OAuthConfig {
1770            enabled: true,
1771            ..OAuthConfig::default()
1772        };
1773        let err = resolve_err(cfg);
1774        for setting in [
1775            "mcp.oauth.issuer",
1776            "mcp.oauth.audience",
1777            "mcp.oauth.resource",
1778        ] {
1779            assert!(err.contains(setting), "{setting} missing from: {err}");
1780        }
1781        // Optional: an absent jwks_uri means "discover it".
1782        assert!(!err.contains("mcp.oauth.jwks_uri"), "{err}");
1783    }
1784
1785    /// The two phrases where mcp-md-wiki's messages are MCP-specific and this
1786    /// crate's are not. mcp-md-wiki#308: mcp-md-wiki keeps its historical text
1787    /// byte-identical by rewriting exactly these in `ConfigError::problems`
1788    /// before display.
1789    ///
1790    /// An early warning, not a contract. Message text is not a stable API (see
1791    /// the README's semver policy), and what actually guarantees mcp-md-wiki's
1792    /// text is its own test pinning its full output, which fails there on the
1793    /// dependency bump if this wording changes. This pin only makes the
1794    /// breakage visible here first: when a test below fails, change the text
1795    /// deliberately and tell mcp-md-wiki, rather than treating the phrase as
1796    /// frozen.
1797    const WIKI_REWRITES: [(&str, &str); 2] = [
1798        ("this server's public URL,", "this server's public MCP URL,"),
1799        (
1800            "unscoped. Use a scope your",
1801            "unscoped. Use \"mcp:read\" (the default) or a scope your",
1802        ),
1803    ];
1804
1805    fn as_wiki_text(problem: &str) -> String {
1806        WIKI_REWRITES
1807            .iter()
1808            .fold(problem.to_string(), |p, (generic, wiki)| {
1809                p.replace(generic, wiki)
1810            })
1811    }
1812
1813    #[test]
1814    fn config_error_display_for_dotted_naming_is_generic_and_maps_to_mcp_md_wiki_text() {
1815        // A required scope, as mcp-md-wiki always supplies one before resolving,
1816        // so the unscoped refusal does not join the one problem pinned here.
1817        let cfg = OAuthConfig {
1818            enabled: true,
1819            required_scope: Some("mcp:read".into()),
1820            ..OAuthConfig::default()
1821        };
1822        // Spelled out in full, not rebuilt from the format strings under test.
1823        let expected = "mcp.oauth.enabled is true but the OAuth config is not usable:\n  - \
1824these required settings are empty: mcp.oauth.issuer, mcp.oauth.resource, mcp.oauth.audience \
1825(or mcp.oauth.audiences). Set issuer to the authorization server's issuer (byte-exact, \
1826including any trailing slash), resource to this server's public URL, and audience to \
1827what that server puts in an access token's `aud` — the resource URL if it honours RFC 8707 \
1828or lets you configure an audience (e.g. Authelia), or the OAuth client_id if it stamps that \
1829(e.g. Authentik, Kanidm)\nFix these, or set mcp.oauth.enabled: false.";
1830        let mut err = cfg.resolve(WIKI).unwrap_err();
1831        assert_eq!(err.to_string(), expected);
1832        assert_eq!(err.problems[0].matches(WIKI_REWRITES[0].0).count(), 1);
1833
1834        // The text mcp-md-wiki printed before mcp-md-wiki#308, recovered by its
1835        // rewrite of the generic phrase.
1836        let wiki_expected = "mcp.oauth.enabled is true but the OAuth config is not usable:\n  - \
1837these required settings are empty: mcp.oauth.issuer, mcp.oauth.resource, mcp.oauth.audience \
1838(or mcp.oauth.audiences). Set issuer to the authorization server's issuer (byte-exact, \
1839including any trailing slash), resource to this server's public MCP URL, and audience to \
1840what that server puts in an access token's `aud` — the resource URL if it honours RFC 8707 \
1841or lets you configure an audience (e.g. Authelia), or the OAuth client_id if it stamps that \
1842(e.g. Authentik, Kanidm)\nFix these, or set mcp.oauth.enabled: false.";
1843        err.problems = err.problems.iter().map(|p| as_wiki_text(p)).collect();
1844        assert_eq!(err.to_string(), wiki_expected);
1845    }
1846
1847    #[test]
1848    fn scope_problem_messages_are_generic_and_map_to_mcp_md_wiki_text() {
1849        let err = enabled(|c| c.required_scope = Some(String::new()))
1850            .resolve(WIKI)
1851            .unwrap_err();
1852        assert_eq!(
1853            err.problems,
1854            [
1855                "mcp.oauth.required_scope must not be empty — a blank required scope would \
1856              let any signed token through unscoped. Use a scope your authorization server \
1857              actually issues"
1858            ]
1859        );
1860        assert_eq!(err.problems[0].matches(WIKI_REWRITES[1].0).count(), 1);
1861        // mcp-md-wiki#308: mcp-md-wiki's pre-extraction text, recovered.
1862        assert_eq!(
1863            as_wiki_text(&err.problems[0]),
1864            "mcp.oauth.required_scope must not be empty — a blank required scope would \
1865              let any signed token through unscoped. Use \"mcp:read\" (the default) or a \
1866              scope your authorization server actually issues"
1867        );
1868        // Nothing MCP-specific leaks into a non-MCP consumer's messages.
1869        let text = OAuthConfig {
1870            enabled: true,
1871            required_scope: Some(String::new()),
1872            ..OAuthConfig::default()
1873        }
1874        .resolve(KeyNaming::Env("APP_OAUTH_"))
1875        .unwrap_err()
1876        .to_string();
1877        assert!(!text.contains("MCP") && !text.contains("mcp"), "{text}");
1878        let err = enabled(|c| c.required_scope = Some("mcp:read mcp:write".into()))
1879            .resolve(WIKI)
1880            .unwrap_err();
1881        assert_eq!(
1882            err.problems,
1883            [
1884                "mcp.oauth.required_scope \"mcp:read mcp:write\" must be a single scope (no \
1885              spaces) — scopes are matched one token at a time"
1886            ]
1887        );
1888        let err = enabled(|c| c.leeway_secs = 3600).resolve(WIKI).unwrap_err();
1889        assert_eq!(
1890            err.to_string(),
1891            "mcp.oauth.enabled is true but the OAuth config is not usable:\n  - \
1892             mcp.oauth.leeway_secs 3600 is over the 300-second cap — leeway is for clock \
1893             drift, not for extending token lifetimes\nFix these, or set mcp.oauth.enabled: \
1894             false."
1895        );
1896    }
1897
1898    #[test]
1899    fn env_naming_uppercases_and_appends_the_field() {
1900        let naming = KeyNaming::Env("MYAPP_OAUTH_");
1901        assert_eq!(naming.key("issuer"), "MYAPP_OAUTH_ISSUER");
1902        assert_eq!(naming.key("required_scopes"), "MYAPP_OAUTH_REQUIRED_SCOPES");
1903        assert_eq!(naming.section(), "MYAPP_OAUTH_*");
1904        assert_eq!(
1905            KeyNaming::Dotted("mcp.oauth").key("issuer"),
1906            "mcp.oauth.issuer"
1907        );
1908        assert_eq!(KeyNaming::Dotted("").key("issuer"), "issuer");
1909        assert_eq!(KeyNaming::Dotted("").section(), "OAuth config");
1910        assert_eq!(KeyNaming::Dotted("mcp.oauth").section(), "mcp.oauth");
1911
1912        let cfg = OAuthConfig {
1913            enabled: true,
1914            required_scope: Some(String::new()),
1915            ..OAuthConfig::default()
1916        };
1917        let err = cfg.resolve(naming).unwrap_err();
1918        let text = err.to_string();
1919        for key in [
1920            "MYAPP_OAUTH_ISSUER",
1921            "MYAPP_OAUTH_RESOURCE",
1922            "MYAPP_OAUTH_AUDIENCE (or MYAPP_OAUTH_AUDIENCES)",
1923            "MYAPP_OAUTH_REQUIRED_SCOPE must not be empty",
1924        ] {
1925            assert!(text.contains(key), "{key} missing from: {text}");
1926        }
1927        assert!(!text.contains("mcp.oauth"), "{text}");
1928        assert!(
1929            text.starts_with(
1930                "OAuth is configured through MYAPP_OAUTH_* but the config is not \
1931                 usable:\n  - "
1932            ),
1933            "{text}"
1934        );
1935        assert!(
1936            text.ends_with("\nFix these, or unset every MYAPP_OAUTH_* variable."),
1937            "{text}"
1938        );
1939        assert_eq!(err.naming(), naming);
1940    }
1941
1942    #[test]
1943    fn accepts_audiences_without_audience_and_an_omitted_or_blank_jwks_uri() {
1944        for jwks_uri in [None, Some(String::new()), Some("  ".to_string())] {
1945            let oauth = enabled(|c| {
1946                c.audience = String::new();
1947                c.audiences = vec!["https://kb.example.test/mcp".into()];
1948                c.jwks_uri = jwks_uri.clone();
1949            })
1950            .resolve(WIKI)
1951            .unwrap()
1952            .unwrap();
1953            assert_eq!(oauth.accepted_audiences(), ["https://kb.example.test/mcp"]);
1954            assert_eq!(oauth.jwks_uri, None, "blank means discover: {jwks_uri:?}");
1955        }
1956    }
1957
1958    #[test]
1959    fn refuses_hmac_and_none_algorithms() {
1960        for alg in ["HS256", "none"] {
1961            let err = resolve_err(enabled(|c| {
1962                c.algorithms = vec!["RS256".into(), alg.into()];
1963            }));
1964            assert!(err.contains("mcp.oauth.algorithms"), "{err}");
1965            assert!(err.contains(alg), "{err}");
1966        }
1967        let err = resolve_err(enabled(|c| c.algorithms = vec![]));
1968        assert!(err.contains("at least one algorithm"), "{err}");
1969    }
1970
1971    #[test]
1972    fn refuses_malformed_urls_naming_the_key() {
1973        for jwks_uri in ["idp.example.test/jwks", "ftp://idp.example.test/jwks"] {
1974            let err = resolve_err(enabled(|c| c.jwks_uri = Some(jwks_uri.into())));
1975            assert!(err.contains("mcp.oauth.jwks_uri"), "{err}");
1976        }
1977        let err = resolve_err(enabled(|c| {
1978            c.issuer = "https://idp.example.test/?x=1".into();
1979            c.resource = "kb.example.test/mcp".into();
1980        }));
1981        assert!(err.contains("mcp.oauth.issuer"), "{err}");
1982        assert!(err.contains("mcp.oauth.resource"), "{err}");
1983        let err = resolve_err(enabled(|c| c.issuer = " https://idp.example.test/".into()));
1984        assert!(err.contains("leading/trailing whitespace"), "{err}");
1985    }
1986
1987    #[test]
1988    fn refuses_bad_scope_and_leeway_settings() {
1989        type Edit = fn(&mut OAuthConfig);
1990        let cases: [(Edit, &str); 5] = [
1991            (
1992                |c| c.required_scope = Some("mcp:read mcp:write".into()),
1993                "single scope",
1994            ),
1995            (|c| c.scope_claims = vec![], "mcp.oauth.scope_claims"),
1996            (
1997                |c| c.principal_claims = vec![String::new()],
1998                "mcp.oauth.principal_claims",
1999            ),
2000            (|c| c.leeway_secs = 3600, "mcp.oauth.leeway_secs"),
2001            (|c| c.audiences = vec![String::new()], "mcp.oauth.audiences"),
2002        ];
2003        for (edit, needle) in cases {
2004            let err = resolve_err(enabled(edit));
2005            assert!(err.contains(needle), "{needle} not in: {err}");
2006        }
2007    }
2008
2009    #[test]
2010    fn a_blank_required_scope_is_rejected_not_treated_as_absent() {
2011        for blank in ["", "   "] {
2012            let err = resolve_err(enabled(|c| c.required_scope = Some(blank.into())));
2013            assert!(
2014                err.contains("mcp.oauth.required_scope"),
2015                "an empty required scope must not silently mean 'no scope': {err}"
2016            );
2017        }
2018    }
2019
2020    #[test]
2021    fn every_problem_is_reported_at_once() {
2022        let err = enabled(|c| {
2023            c.issuer = String::new();
2024            c.required_scope = Some(String::new());
2025            c.required_scopes = vec!["a b".into()];
2026            c.leeway_secs = 9999;
2027            c.algorithms = vec!["HS256".into()];
2028        })
2029        .resolve(WIKI)
2030        .unwrap_err();
2031        assert_eq!(err.problems.len(), 5, "{err}");
2032    }
2033
2034    // ── required_scopes ──────────────────────────────────────────────────────
2035
2036    #[test]
2037    fn required_scopes_are_a_trimmed_deduplicated_order_stable_union() {
2038        let oauth = enabled(|c| {
2039            c.required_scope = Some(" a ".into());
2040            c.required_scopes = vec!["b".into(), "a".into(), " c".into(), "b".into()];
2041        })
2042        .resolve(WIKI)
2043        .unwrap()
2044        .unwrap();
2045        assert_eq!(oauth.required_scopes, ["a", "b", "c"]);
2046
2047        let only_list = enabled(|c| c.required_scopes = vec!["x".into(), "y".into()])
2048            .resolve(WIKI)
2049            .unwrap()
2050            .unwrap();
2051        assert_eq!(only_list.required_scopes, ["x", "y"]);
2052
2053        let only_single = enabled(|c| c.required_scope = Some("mcp:read".into()))
2054            .resolve(WIKI)
2055            .unwrap()
2056            .unwrap();
2057        assert_eq!(only_single.required_scopes, ["mcp:read"]);
2058    }
2059
2060    #[test]
2061    fn an_empty_required_scope_union_is_valid_and_means_no_scope_check() {
2062        let oauth = enabled(|_| {}).resolve(WIKI).unwrap().unwrap();
2063        assert!(oauth.required_scopes.is_empty());
2064    }
2065
2066    #[test]
2067    fn required_scopes_entries_follow_the_single_scope_rules() {
2068        let err = enabled(|c| c.required_scopes = vec!["ok".into(), "  ".into()])
2069            .resolve(WIKI)
2070            .unwrap_err();
2071        assert_eq!(err.problems.len(), 1, "{err}");
2072        assert!(
2073            err.problems[0].starts_with("mcp.oauth.required_scopes contains an empty entry"),
2074            "{err}"
2075        );
2076        let err = enabled(|c| c.required_scopes = vec!["a b".into()])
2077            .resolve(WIKI)
2078            .unwrap_err();
2079        assert!(
2080            err.problems[0]
2081                .starts_with("mcp.oauth.required_scopes entry \"a b\" must be a single scope"),
2082            "{err}"
2083        );
2084    }
2085
2086    // ── serde ────────────────────────────────────────────────────────────────
2087
2088    #[cfg(feature = "serde")]
2089    #[test]
2090    fn round_trips_from_yaml_with_every_default() {
2091        let yaml = "enabled: true
2092issuer: \"https://authentik.example.test/application/o/example-app/\"
2093jwks_uri: \"https://authentik.example.test/application/o/example-app/jwks/\"
2094audience: \"some-client-id\"
2095resource: \"https://kb.example.test/mcp\"
2096";
2097        let parsed: OAuthConfig = serde_yaml_ng::from_str(yaml).unwrap();
2098        assert_eq!(
2099            parsed,
2100            OAuthConfig {
2101                enabled: true,
2102                issuer: "https://authentik.example.test/application/o/example-app/".into(),
2103                jwks_uri: Some(
2104                    "https://authentik.example.test/application/o/example-app/jwks/".into()
2105                ),
2106                audience: "some-client-id".into(),
2107                resource: "https://kb.example.test/mcp".into(),
2108                ..OAuthConfig::default()
2109            }
2110        );
2111        let back: OAuthConfig =
2112            serde_yaml_ng::from_str(&serde_yaml_ng::to_string(&parsed).unwrap()).unwrap();
2113        assert_eq!(back, parsed);
2114        // An empty document is the default config.
2115        let empty: OAuthConfig = serde_yaml_ng::from_str("{}").unwrap();
2116        assert_eq!(empty, OAuthConfig::default());
2117    }
2118
2119    #[cfg(feature = "serde")]
2120    #[test]
2121    fn serde_reads_both_scope_keys_and_refuses_unknown_keys() {
2122        let parsed: OAuthConfig =
2123            serde_yaml_ng::from_str("required_scope: \"a\"\nrequired_scopes: [\"b\", \"c\"]\n")
2124                .unwrap();
2125        assert_eq!(parsed.required_scope.as_deref(), Some("a"));
2126        assert_eq!(parsed.required_scopes, ["b", "c"]);
2127        // An explicit empty string is Some(""), which resolve refuses.
2128        let parsed: OAuthConfig = serde_yaml_ng::from_str("required_scope: \"\"\n").unwrap();
2129        assert_eq!(parsed.required_scope.as_deref(), Some(""));
2130        assert!(serde_yaml_ng::from_str::<OAuthConfig>("bogus: true\n").is_err());
2131        assert!(serde_yaml_ng::from_str::<OAuthConfig>("resource_name: \"x\"\n").is_err());
2132        let parsed: OAuthConfig =
2133            serde_yaml_ng::from_str("allow_unscoped_tokens: true\nallow_insecure_http: true\n")
2134                .unwrap();
2135        assert!(parsed.allow_unscoped_tokens && parsed.allow_insecure_http);
2136    }
2137
2138    #[cfg(feature = "serde")]
2139    #[test]
2140    fn serde_distinguishes_an_omitted_scopes_supported_from_an_explicit_empty_list() {
2141        let omitted: OAuthConfig = serde_yaml_ng::from_str("enabled: true\n").unwrap();
2142        assert_eq!(omitted.scopes_supported, None);
2143        let empty: OAuthConfig = serde_yaml_ng::from_str("scopes_supported: []\n").unwrap();
2144        assert_eq!(empty.scopes_supported, Some(vec![]));
2145        let listed: OAuthConfig =
2146            serde_yaml_ng::from_str("scopes_supported: [\"a\", \"b\"]\n").unwrap();
2147        assert_eq!(listed.scopes_supported, Some(vec!["a".into(), "b".into()]));
2148        // Both survive a round trip: `None` is skipped on output (so it reads back
2149        // as omitted), and an explicit `[]` is written out.
2150        for cfg in [omitted, empty, listed] {
2151            let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
2152            let back: OAuthConfig = serde_yaml_ng::from_str(&yaml).unwrap();
2153            assert_eq!(back, cfg, "{yaml}");
2154        }
2155    }
2156
2157    #[test]
2158    fn an_omitted_scopes_supported_advertises_the_required_scopes_and_an_explicit_list_wins() {
2159        let with_required = |required: Option<&str>, scopes: Option<Vec<String>>| {
2160            enabled(|c| {
2161                c.required_scope = required.map(str::to_string);
2162                c.required_scopes = vec!["b".into(), "a".into()];
2163                c.scopes_supported = scopes;
2164            })
2165            .resolve(WIKI)
2166            .unwrap()
2167            .expect("enabled")
2168            .scopes_supported
2169        };
2170        // Omitted: the required scopes, in their resolved order.
2171        assert_eq!(with_required(Some("a"), None), ["a", "b"]);
2172        assert_eq!(with_required(None, None), ["b", "a"]);
2173        // An explicit list, even an empty one, is kept as written (trimmed).
2174        assert!(with_required(Some("a"), Some(vec![])).is_empty());
2175        assert_eq!(with_required(Some("a"), Some(vec![" c ".into()])), ["c"]);
2176
2177        // Nothing required: an omitted list advertises nothing.
2178        let resolve = |scopes: Option<Vec<String>>| {
2179            enabled(|c| c.scopes_supported = scopes)
2180                .resolve(WIKI)
2181                .unwrap()
2182                .expect("enabled")
2183                .scopes_supported
2184        };
2185        assert!(resolve(None).is_empty());
2186        assert!(resolve(Some(vec![])).is_empty());
2187        assert_eq!(
2188            resolve(Some(vec!["mcp:read".into(), "mcp:write".into()])),
2189            ["mcp:read", "mcp:write"]
2190        );
2191        // The application-default pattern: fill an omitted key, leave an explicit
2192        // empty list alone.
2193        let app_default = |mut c: OAuthConfig| {
2194            c.scopes_supported
2195                .get_or_insert_with(|| vec!["mcp:read".into(), "mcp:write".into()]);
2196            c.resolve(WIKI).unwrap().expect("enabled").scopes_supported
2197        };
2198        assert_eq!(
2199            app_default(enabled(|c| c.scopes_supported = None)),
2200            ["mcp:read", "mcp:write"]
2201        );
2202        assert!(app_default(enabled(|c| c.scopes_supported = Some(vec![]))).is_empty());
2203    }
2204
2205    // ── scope-token syntax (RFC 6749 §3.3) ───────────────────────────────────
2206
2207    #[test]
2208    fn every_required_and_advertised_scope_must_be_a_scope_token() {
2209        for bad in ["a\"b", "a\\b", "caf\u{e9}", "a\u{7f}"] {
2210            let err = enabled(|c| c.required_scope = Some(bad.into()))
2211                .resolve(WIKI)
2212                .unwrap_err();
2213            assert_eq!(err.problems.len(), 1, "{err}");
2214            assert!(
2215                err.problems[0].starts_with("mcp.oauth.required_scope ")
2216                    && err.problems[0].contains("is not a valid scope"),
2217                "{err}"
2218            );
2219            let err = enabled(|c| c.required_scopes = vec!["ok".into(), bad.into()])
2220                .resolve(WIKI)
2221                .unwrap_err();
2222            assert!(
2223                err.problems[0].starts_with("mcp.oauth.required_scopes entry "),
2224                "{err}"
2225            );
2226            let err = enabled(|c| c.scopes_supported = Some(vec![bad.into()]))
2227                .resolve(WIKI)
2228                .unwrap_err();
2229            assert!(
2230                err.problems[0].starts_with("mcp.oauth.scopes_supported entry "),
2231                "{err}"
2232            );
2233        }
2234        // Blank and multi-word advertised entries are refused too.
2235        let err = enabled(|c| c.scopes_supported = Some(vec!["".into(), "two words".into()]))
2236            .resolve(WIKI)
2237            .unwrap_err();
2238        assert_eq!(err.problems.len(), 2, "{err}");
2239        assert_eq!(
2240            err.problems[0],
2241            "mcp.oauth.scopes_supported contains an empty entry"
2242        );
2243        assert!(err.problems[1].contains("\"two words\""), "{err}");
2244        // Every visible-ASCII scope-token character is fine, including the
2245        // `:`, `/`, `.` and `!` real scopes use.
2246        let oauth = enabled(|c| {
2247            c.required_scope =
2248                Some("https://api.example.test/things.read!#$%&'()*+,-./:;<=>?@[]^_`{|}~".into());
2249        })
2250        .resolve(WIKI)
2251        .unwrap()
2252        .unwrap();
2253        assert_eq!(oauth.required_scopes.len(), 1);
2254    }
2255
2256    // ── URL characters, and plain http ───────────────────────────────────────
2257
2258    #[test]
2259    fn a_url_with_a_space_control_or_non_ascii_character_is_refused() {
2260        // `url` would silently drop the tab/newline and percent-encode the rest,
2261        // but the raw string is what reaches every challenge header.
2262        for bad in [
2263            "https://kb.example.test/m\ncp",
2264            "https://kb.example.test/m\tcp",
2265            "https://kb.example.test/m cp",
2266            "https://kb.example.test/caf\u{e9}",
2267        ] {
2268            let err = resolve_err(enabled(|c| c.resource = bad.into()));
2269            assert!(
2270                err.contains("mcp.oauth.resource") && err.contains("percent-encoded"),
2271                "{bad:?}: {err}"
2272            );
2273            let err = resolve_err(enabled(|c| c.issuer = bad.into()));
2274            assert!(err.contains("mcp.oauth.issuer"), "{bad:?}: {err}");
2275            let err = resolve_err(enabled(|c| c.jwks_uri = Some(bad.into())));
2276            assert!(err.contains("mcp.oauth.jwks_uri"), "{bad:?}: {err}");
2277        }
2278        // Percent-encoded is fine, and so is a query on the jwks_uri.
2279        enabled(|c| {
2280            c.resource = "https://kb.example.test/caf%C3%A9".into();
2281            c.jwks_uri = Some("https://idp.example.test/keys?tenant=a".into());
2282        })
2283        .resolve(WIKI)
2284        .unwrap()
2285        .unwrap();
2286    }
2287
2288    #[test]
2289    fn plain_http_off_loopback_is_refused_unless_explicitly_allowed() {
2290        let err = enabled(|c| {
2291            c.issuer = "http://idp.internal.test/app/".into();
2292            c.jwks_uri = Some("http://idp.internal.test/app/jwks/".into());
2293            c.resource = "http://kb.internal.test/mcp".into();
2294        })
2295        .resolve(WIKI)
2296        .unwrap_err();
2297        assert_eq!(err.problems.len(), 3, "{err}");
2298        assert!(
2299            err.problems[0].starts_with(
2300                "mcp.oauth.issuer \"http://idp.internal.test/app/\" uses plain http on a \
2301                 non-loopback host — signing keys"
2302            ),
2303            "{err}"
2304        );
2305        assert!(err.problems[0].contains("RFC 8414 §2"), "{err}");
2306        assert!(err.problems[1].starts_with("mcp.oauth.resource "), "{err}");
2307        assert!(err.problems[1].contains("RFC 9728 §1.2"), "{err}");
2308        assert!(err.problems[2].starts_with("mcp.oauth.jwks_uri "), "{err}");
2309        assert!(
2310            err.problems[2].contains("set mcp.oauth.allow_insecure_http"),
2311            "{err}"
2312        );
2313
2314        // The explicit opt-in accepts all three.
2315        let oauth = enabled(|c| {
2316            c.issuer = "http://idp.internal.test/app/".into();
2317            c.jwks_uri = Some("http://idp.internal.test/app/jwks/".into());
2318            c.resource = "http://kb.internal.test/mcp".into();
2319            c.allow_insecure_http = true;
2320        })
2321        .resolve(WIKI)
2322        .unwrap()
2323        .unwrap();
2324        assert!(oauth.allow_insecure_http);
2325
2326        // Loopback never needs it.
2327        enabled(|c| {
2328            c.issuer = "http://127.0.0.1:9000/app/".into();
2329            c.jwks_uri = Some("http://[::1]:9000/jwks".into());
2330            c.resource = "http://localhost:8001/mcp".into();
2331        })
2332        .resolve(WIKI)
2333        .unwrap()
2334        .unwrap();
2335    }
2336
2337    /// Spellings of a plain-http, non-loopback URL that the URL parser
2338    /// normalizes to `http://idp.example.test/...` — so reqwest would fetch
2339    /// it over cleartext — but that do not literally begin with `http://`.
2340    const NON_CANONICAL_HTTP: [&str; 4] = [
2341        "http:/idp.example.test/jwks",
2342        "http:idp.example.test/jwks",
2343        "HTTP:\\\\idp.example.test\\jwks",
2344        " http://idp.example.test/jwks",
2345    ];
2346
2347    /// `enabled()` with `name` (`issuer`, `jwks_uri` or `resource`) set to `url`.
2348    fn with_url(name: &str, url: &str, allow_insecure_http: bool) -> OAuthConfig {
2349        enabled(|c| {
2350            c.allow_insecure_http = allow_insecure_http;
2351            match name {
2352                "issuer" => c.issuer = url.into(),
2353                "jwks_uri" => c.jwks_uri = Some(url.into()),
2354                "resource" => c.resource = url.into(),
2355                other => panic!("no URL setting {other}"),
2356            }
2357        })
2358    }
2359
2360    #[test]
2361    fn non_canonical_plain_http_spellings_are_refused_for_every_url_setting() {
2362        for spelling in NON_CANONICAL_HTTP {
2363            for name in ["issuer", "jwks_uri", "resource"] {
2364                let err = with_url(name, spelling, false)
2365                    .resolve(WIKI)
2366                    .expect_err(&format!("{name} = {spelling:?} must be refused"));
2367                assert_eq!(err.problems.len(), 1, "{name} = {spelling:?}: {err}");
2368                assert!(
2369                    err.problems[0].starts_with(&format!("mcp.oauth.{name} ")),
2370                    "{name} = {spelling:?}: {err}"
2371                );
2372                // Surrounding whitespace was always refused on its own; every
2373                // other spelling is refused as the cleartext URL it is.
2374                let expected = if spelling.starts_with(' ') {
2375                    ProblemKind::InvalidUrl
2376                } else {
2377                    ProblemKind::InsecureHttp
2378                };
2379                assert_eq!(
2380                    err.problem_details()[0].kind(),
2381                    expected,
2382                    "{name} = {spelling:?}: {err}"
2383                );
2384            }
2385        }
2386    }
2387
2388    #[test]
2389    fn non_canonical_spellings_that_are_not_a_cleartext_hole_resolve_with_a_warning() {
2390        // With the opt-in, the plain-http spellings; without it, loopback and
2391        // https ones. Each resolves as 0.1.2 resolved it, and the validator's
2392        // startup warnings name the setting and give the canonical form.
2393        let cases: [(&str, bool, &str); 8] = [
2394            (
2395                "http:/idp.example.test/jwks",
2396                true,
2397                "http://idp.example.test/jwks",
2398            ),
2399            (
2400                "http:idp.example.test/jwks",
2401                true,
2402                "http://idp.example.test/jwks",
2403            ),
2404            (
2405                "HTTP:\\\\idp.example.test\\jwks",
2406                true,
2407                "http://idp.example.test/jwks",
2408            ),
2409            (
2410                "http:/localhost:9000/jwks",
2411                false,
2412                "http://localhost:9000/jwks",
2413            ),
2414            (
2415                "https:/idp.example.test/jwks",
2416                false,
2417                "https://idp.example.test/jwks",
2418            ),
2419            (
2420                "https:idp.example.test/jwks",
2421                false,
2422                "https://idp.example.test/jwks",
2423            ),
2424            (
2425                "https://idp.example.test\\jwks",
2426                false,
2427                "https://idp.example.test/jwks",
2428            ),
2429            (
2430                "https:///idp.example.test/jwks",
2431                false,
2432                "https://idp.example.test/jwks",
2433            ),
2434        ];
2435        for (spelling, opt_in, canonical) in cases {
2436            for name in ["issuer", "jwks_uri", "resource"] {
2437                let resolved = with_url(name, spelling, opt_in)
2438                    .resolve(WIKI)
2439                    .unwrap_or_else(|e| panic!("{name} = {spelling:?}: {e}"))
2440                    .unwrap();
2441                let warnings = crate::validator::non_canonical_warnings(&resolved);
2442                assert_eq!(warnings.len(), 1, "{name} = {spelling:?}: {warnings:?}");
2443                assert!(
2444                    warnings[0].starts_with(&format!(
2445                        "mcp.oauth.{name} {spelling:?} is not canonically spelled — it is \
2446                         read as {canonical:?}; "
2447                    )),
2448                    "{name} = {spelling:?}: {warnings:?}"
2449                );
2450            }
2451        }
2452        // A canonically spelled config warns about nothing.
2453        let resolved = enabled(|c| c.jwks_uri = Some("https://idp.example.test/jwks".into()))
2454            .resolve(WIKI)
2455            .unwrap()
2456            .unwrap();
2457        assert!(crate::validator::non_canonical_warnings(&resolved).is_empty());
2458    }
2459
2460    #[test]
2461    fn canonical_url_spellings_behave_as_before() {
2462        // Canonical plain http off loopback: refused without the opt-in and
2463        // admitted with it — an upper-case scheme included.
2464        for url in [
2465            "http://idp.example.test/jwks",
2466            "HTTP://idp.example.test/jwks",
2467        ] {
2468            for name in ["issuer", "jwks_uri", "resource"] {
2469                let err = with_url(name, url, false).resolve(WIKI).unwrap_err();
2470                assert_eq!(err.problems.len(), 1, "{name} = {url:?}: {err}");
2471                assert!(
2472                    err.problems[0].contains("uses plain http on a non-loopback host"),
2473                    "{name} = {url:?}: {err}"
2474                );
2475                with_url(name, url, true)
2476                    .resolve(WIKI)
2477                    .unwrap_or_else(|e| panic!("{name} = {url:?}: {e}"))
2478                    .unwrap();
2479            }
2480        }
2481        // Loopback and https need no opt-in; a URL with no path, a default
2482        // port or an upper-case host is not "non-canonical".
2483        for url in [
2484            "http://localhost/jwks",
2485            "http://127.0.0.1:9000/jwks",
2486            "http://[::1]:9000/jwks",
2487            "https://idp.example.test/jwks",
2488            "https://IDP.example.test:443",
2489        ] {
2490            for name in ["issuer", "jwks_uri", "resource"] {
2491                with_url(name, url, false)
2492                    .resolve(WIKI)
2493                    .unwrap_or_else(|e| panic!("{name} = {url:?}: {e}"))
2494                    .unwrap();
2495            }
2496        }
2497    }
2498
2499    // ── the unscoped posture ─────────────────────────────────────────────────
2500
2501    #[test]
2502    fn no_required_scope_and_no_typ_check_is_refused_unless_explicitly_allowed() {
2503        let bare = |edit: fn(&mut OAuthConfig)| {
2504            let mut cfg = enabled(|c| c.allow_unscoped_tokens = false);
2505            edit(&mut cfg);
2506            cfg.resolve(WIKI)
2507        };
2508        let err = bare(|_| {}).unwrap_err();
2509        assert_eq!(err.problems.len(), 1, "{err}");
2510        assert_eq!(
2511            err.problems[0],
2512            "no required scope is configured (mcp.oauth.required_scope and \
2513             mcp.oauth.required_scopes are unset) and mcp.oauth.require_at_jwt is off — \
2514             nothing would tell an access token from an OIDC ID token minted for the same \
2515             client, so any token this issuer signs for the audience would be accepted. Set \
2516             mcp.oauth.required_scope to a scope only access tokens carry, turn on \
2517             mcp.oauth.require_at_jwt if the authorization server emits typ at+jwt, or set \
2518             mcp.oauth.allow_unscoped_tokens to accept that"
2519        );
2520        // Any one of the three is enough.
2521        assert!(bare(|c| c.required_scope = Some("a".into())).is_ok());
2522        assert!(bare(|c| c.required_scopes = vec!["a".into()]).is_ok());
2523        assert!(bare(|c| c.require_at_jwt = true).is_ok());
2524        let allowed = bare(|c| c.allow_unscoped_tokens = true).unwrap().unwrap();
2525        assert!(allowed.required_scopes.is_empty());
2526        assert!(allowed.allow_unscoped_tokens);
2527        // A blank scope is its own problem, not "unscoped" as well.
2528        let err = bare(|c| c.required_scope = Some(String::new())).unwrap_err();
2529        assert_eq!(err.problems.len(), 1, "{err}");
2530        // Named per the key naming.
2531        let err = OAuthConfig {
2532            enabled: true,
2533            ..OAuthConfig::default()
2534        }
2535        .resolve(KeyNaming::Env("APP_OAUTH_"))
2536        .unwrap_err();
2537        assert!(
2538            err.problems
2539                .iter()
2540                .any(|p| p.contains("set APP_OAUTH_ALLOW_UNSCOPED_TOKENS")),
2541            "{err}"
2542        );
2543    }
2544
2545    // ── structured problems ──────────────────────────────────────────────────
2546
2547    /// A field's name spelled by hand for `naming`, independent of
2548    /// `KeyNaming::key`, so a wrong key in a problem cannot cancel out.
2549    fn spelled(naming: KeyNaming<'_>, field: &str) -> String {
2550        match naming {
2551            KeyNaming::Dotted(p) => format!("{p}.{field}"),
2552            KeyNaming::Env(p) => format!("{p}{}", field.to_uppercase()),
2553        }
2554    }
2555
2556    type Edit = fn(&mut OAuthConfig);
2557
2558    /// One config per kind `resolve` can produce, with the fields the problem
2559    /// must name.
2560    fn one_problem_per_kind() -> Vec<(ProblemKind, Edit, &'static [&'static str])> {
2561        vec![
2562            (
2563                ProblemKind::MissingRequired,
2564                |c| c.issuer.clear(),
2565                &["issuer"],
2566            ),
2567            (
2568                ProblemKind::MissingRequired,
2569                |c| {
2570                    c.audience.clear();
2571                    c.audiences.clear();
2572                },
2573                &["audience", "audiences"],
2574            ),
2575            (
2576                ProblemKind::InvalidUrl,
2577                |c| c.jwks_uri = Some("not a url".into()),
2578                &["jwks_uri"],
2579            ),
2580            (
2581                ProblemKind::InsecureHttp,
2582                |c| c.resource = "http://kb.example.test/mcp".into(),
2583                &["resource", "allow_insecure_http"],
2584            ),
2585            (
2586                ProblemKind::BlankRequiredScope,
2587                |c| c.required_scope = Some(" ".into()),
2588                &["required_scope"],
2589            ),
2590            (
2591                ProblemKind::BlankRequiredScope,
2592                |c| c.required_scopes = vec!["".into()],
2593                &["required_scopes"],
2594            ),
2595            (
2596                ProblemKind::MultiWordScope,
2597                |c| c.required_scope = Some("a b".into()),
2598                &["required_scope"],
2599            ),
2600            (
2601                ProblemKind::MultiWordScope,
2602                |c| c.required_scopes = vec!["a b".into()],
2603                &["required_scopes"],
2604            ),
2605            (
2606                ProblemKind::InvalidScopeToken,
2607                |c| c.required_scope = Some("a\"b".into()),
2608                &["required_scope"],
2609            ),
2610            (
2611                ProblemKind::InvalidScopeToken,
2612                |c| c.required_scopes = vec!["a\\b".into()],
2613                &["required_scopes"],
2614            ),
2615            (
2616                ProblemKind::InvalidScopeToken,
2617                |c| c.scopes_supported = Some(vec!["a\"b".into()]),
2618                &["scopes_supported"],
2619            ),
2620            (
2621                ProblemKind::EmptyListEntry,
2622                |c| c.audiences = vec!["".into()],
2623                &["audiences"],
2624            ),
2625            (
2626                ProblemKind::EmptyListEntry,
2627                |c| c.scopes_supported = Some(vec!["".into()]),
2628                &["scopes_supported"],
2629            ),
2630            (
2631                ProblemKind::EmptyListEntry,
2632                |c| c.principal_claims = vec!["".into()],
2633                &["principal_claims"],
2634            ),
2635            (
2636                ProblemKind::NoRequiredScope,
2637                |c| c.allow_unscoped_tokens = false,
2638                &[
2639                    "required_scope",
2640                    "required_scopes",
2641                    "require_at_jwt",
2642                    "allow_unscoped_tokens",
2643                ],
2644            ),
2645            (
2646                ProblemKind::EmptyScopeClaims,
2647                |c| c.scope_claims.clear(),
2648                &["scope_claims"],
2649            ),
2650            (
2651                ProblemKind::BadAlgorithm,
2652                |c| c.algorithms = vec!["HS256".into()],
2653                &["algorithms"],
2654            ),
2655            (
2656                ProblemKind::NoAlgorithms,
2657                |c| c.algorithms.clear(),
2658                &["algorithms"],
2659            ),
2660            (
2661                ProblemKind::LeewayTooLarge,
2662                |c| c.leeway_secs = MAX_LEEWAY_SECS + 1,
2663                &["leeway_secs"],
2664            ),
2665            (
2666                ProblemKind::EmptyListEntry,
2667                |c| c.allowed_client_ids = vec!["client-a".into(), " ".into()],
2668                &["allowed_client_ids"],
2669            ),
2670            (
2671                ProblemKind::TokenAgeOutOfRange,
2672                |c| c.max_token_age_secs = Some(0),
2673                &["max_token_age_secs"],
2674            ),
2675            (
2676                ProblemKind::TokenAgeOutOfRange,
2677                |c| c.max_token_age_secs = Some(MAX_TOKEN_AGE_SECS + 1),
2678                &["max_token_age_secs"],
2679            ),
2680            (
2681                ProblemKind::InvalidRequiredClaim,
2682                |c| {
2683                    c.required_claims.insert(" ".into(), "x".into());
2684                },
2685                &["required_claims"],
2686            ),
2687            (
2688                ProblemKind::InvalidRequiredClaim,
2689                |c| {
2690                    c.required_claims.insert("aud".into(), "x".into());
2691                },
2692                &["required_claims"],
2693            ),
2694            (
2695                ProblemKind::InvalidRequiredClaim,
2696                |c| {
2697                    c.required_claims.insert("tid".into(), Value::Null);
2698                },
2699                &["required_claims"],
2700            ),
2701            (
2702                ProblemKind::InvalidRequiredClaim,
2703                |c| {
2704                    c.required_claims
2705                        .insert("groups".into(), serde_json::json!(["a"]));
2706                },
2707                &["required_claims"],
2708            ),
2709            (
2710                ProblemKind::InvalidRequiredClaim,
2711                |c| {
2712                    c.required_claims
2713                        .insert("org".into(), serde_json::json!({"id": 1}));
2714                },
2715                &["required_claims"],
2716            ),
2717        ]
2718    }
2719
2720    #[test]
2721    fn every_kind_resolve_can_produce_is_produced_and_names_its_settings() {
2722        let mut seen = std::collections::HashSet::new();
2723        for naming in [WIKI, KeyNaming::Env("APP_OAUTH_")] {
2724            for (kind, edit, fields) in one_problem_per_kind() {
2725                let err = enabled(edit).resolve(naming).unwrap_err();
2726                let details = err.problem_details();
2727                assert_eq!(details.len(), 1, "{kind:?}: {err}");
2728                let p = &details[0];
2729                assert_eq!(p.kind(), kind, "{err}");
2730                let want: Vec<String> = fields.iter().map(|f| spelled(naming, f)).collect();
2731                assert_eq!(p.keys(), want.as_slice(), "{kind:?} under {naming:?}");
2732                assert_eq!(p.message(), err.problems[0]);
2733                assert_eq!(p.to_string(), p.message());
2734                seen.insert(kind);
2735            }
2736        }
2737        // Every kind but the env loader's two and the catch-all is covered.
2738        for kind in [
2739            ProblemKind::MissingRequired,
2740            ProblemKind::InvalidUrl,
2741            ProblemKind::InsecureHttp,
2742            ProblemKind::BlankRequiredScope,
2743            ProblemKind::MultiWordScope,
2744            ProblemKind::InvalidScopeToken,
2745            ProblemKind::EmptyListEntry,
2746            ProblemKind::NoRequiredScope,
2747            ProblemKind::EmptyScopeClaims,
2748            ProblemKind::BadAlgorithm,
2749            ProblemKind::NoAlgorithms,
2750            ProblemKind::LeewayTooLarge,
2751            ProblemKind::TokenAgeOutOfRange,
2752            ProblemKind::InvalidRequiredClaim,
2753        ] {
2754            assert!(seen.contains(&kind), "{kind:?} is never produced");
2755        }
2756        // Exhaustive over today's kinds: a new one must be classified here.
2757        for kind in seen {
2758            match kind {
2759                ProblemKind::EnvLoad | ProblemKind::EnvParse | ProblemKind::Other => {
2760                    panic!("resolve produced {kind:?}")
2761                }
2762                _ => {}
2763            }
2764        }
2765    }
2766
2767    #[test]
2768    fn a_missing_required_problem_names_every_blank_setting() {
2769        let err = enabled(|c| {
2770            c.issuer.clear();
2771            c.resource.clear();
2772            c.audience.clear();
2773        })
2774        .resolve(KeyNaming::Env("APP_OAUTH_"))
2775        .unwrap_err();
2776        assert_eq!(
2777            err.problem_details()[0].keys(),
2778            [
2779                "APP_OAUTH_ISSUER",
2780                "APP_OAUTH_RESOURCE",
2781                "APP_OAUTH_AUDIENCE",
2782                "APP_OAUTH_AUDIENCES"
2783            ]
2784        );
2785    }
2786
2787    #[test]
2788    fn problems_and_problem_details_agree_across_a_multi_problem_config() {
2789        let cfg = OAuthConfig {
2790            enabled: true,
2791            leeway_secs: MAX_LEEWAY_SECS + 1,
2792            required_scope: Some("a b".into()),
2793            algorithms: vec!["none".into()],
2794            principal_claims: vec![" ".into()],
2795            ..OAuthConfig::default()
2796        };
2797        for naming in [WIKI, KeyNaming::Env("APP_OAUTH_")] {
2798            let err = cfg.clone().resolve(naming).unwrap_err();
2799            let details = err.problem_details();
2800            assert_eq!(details.len(), 5, "{err}");
2801            let texts: Vec<&str> = details.iter().map(ConfigProblem::message).collect();
2802            assert_eq!(texts, err.problems);
2803            let kinds: Vec<ProblemKind> = details.iter().map(ConfigProblem::kind).collect();
2804            assert_eq!(
2805                kinds,
2806                [
2807                    ProblemKind::MissingRequired,
2808                    ProblemKind::MultiWordScope,
2809                    ProblemKind::EmptyListEntry,
2810                    ProblemKind::BadAlgorithm,
2811                    ProblemKind::LeewayTooLarge,
2812                ]
2813            );
2814        }
2815    }
2816
2817    #[test]
2818    fn config_error_new_is_unchanged_and_wraps_strings_as_other() {
2819        let err = ConfigError::new(WIKI, vec!["first".to_string(), "second".to_string()]);
2820        assert_eq!(err.problems, ["first", "second"]);
2821        let details = err.problem_details();
2822        assert_eq!(details.len(), 2);
2823        assert!(details.iter().all(|p| p.kind() == ProblemKind::Other));
2824        assert!(details.iter().all(|p| p.keys().is_empty()));
2825        assert_eq!(details[1].message(), "second");
2826        assert_eq!(
2827            err.to_string(),
2828            "mcp.oauth.enabled is true but the OAuth config is not usable:\n  - first\n  - second\n\
2829             Fix these, or set mcp.oauth.enabled: false."
2830        );
2831        // Inference through `collect()` keeps working (an existing caller's shape).
2832        let collected = ConfigError::new(WIKI, ["a", "b"].iter().map(|s| s.to_string()).collect());
2833        assert_eq!(collected.problems, ["a", "b"]);
2834    }
2835
2836    #[test]
2837    fn from_problems_and_from_string_mix_structured_and_plain_problems() {
2838        let p = ConfigProblem::from("app problem".to_string());
2839        assert_eq!(p.kind(), ProblemKind::Other);
2840        assert!(p.keys().is_empty());
2841        assert_eq!(p.message(), "app problem");
2842        assert_eq!(p.to_string(), "app problem");
2843
2844        let own = ConfigProblem::new(ProblemKind::InvalidUrl, ["my.key"], "my.key is bad");
2845        assert_eq!(own.keys(), ["my.key"]);
2846        let err = ConfigError::from_problems(WIKI, [own.clone(), p.clone()]);
2847        assert_eq!(err.problems, ["my.key is bad", "app problem"]);
2848        assert_eq!(err.problem_details(), [own, p]);
2849        assert!(
2850            err.to_string()
2851                .contains("\n  - my.key is bad\n  - app problem\n")
2852        );
2853        assert_eq!(err.naming(), WIKI);
2854    }
2855
2856    #[test]
2857    fn config_error_equality_ignores_the_structured_details() {
2858        let err = enabled(|c| c.leeway_secs = MAX_LEEWAY_SECS + 1)
2859            .resolve(WIKI)
2860            .unwrap_err();
2861        // 0.1.2 semantics: an error rebuilt from the strings is equal.
2862        let rebuilt = ConfigError::new(WIKI, err.problems.clone());
2863        assert_eq!(rebuilt, err);
2864        assert_ne!(rebuilt.problem_details(), err.problem_details());
2865        // Different problems or naming are not equal.
2866        assert_ne!(ConfigError::new(WIKI, vec!["other".into()]), err);
2867        assert_ne!(
2868            ConfigError::new(KeyNaming::Env("APP_OAUTH_"), err.problems.clone()),
2869            err
2870        );
2871    }
2872
2873    #[test]
2874    fn editing_the_public_problems_field_does_not_touch_problem_details() {
2875        let mut err = enabled(|c| c.leeway_secs = MAX_LEEWAY_SECS + 1)
2876            .resolve(WIKI)
2877            .unwrap_err();
2878        let before = err.problem_details().to_vec();
2879        err.problems.push("extra".into());
2880        assert_eq!(err.problem_details(), before);
2881    }
2882
2883    #[test]
2884    fn problem_kind_labels_are_stable_and_distinct() {
2885        let all = [
2886            (ProblemKind::MissingRequired, "missing_required"),
2887            (ProblemKind::InvalidUrl, "invalid_url"),
2888            (ProblemKind::InsecureHttp, "insecure_http"),
2889            (ProblemKind::BlankRequiredScope, "blank_required_scope"),
2890            (ProblemKind::MultiWordScope, "multi_word_scope"),
2891            (ProblemKind::InvalidScopeToken, "invalid_scope_token"),
2892            (ProblemKind::EmptyListEntry, "empty_list_entry"),
2893            (ProblemKind::NoRequiredScope, "no_required_scope"),
2894            (ProblemKind::EmptyScopeClaims, "empty_scope_claims"),
2895            (ProblemKind::BadAlgorithm, "bad_algorithm"),
2896            (ProblemKind::NoAlgorithms, "no_algorithms"),
2897            (ProblemKind::LeewayTooLarge, "leeway_too_large"),
2898            (ProblemKind::TokenAgeOutOfRange, "token_age_out_of_range"),
2899            (ProblemKind::InvalidRequiredClaim, "invalid_required_claim"),
2900            (ProblemKind::EnvLoad, "env_load"),
2901            (ProblemKind::EnvParse, "env_parse"),
2902            (ProblemKind::Other, "other"),
2903        ];
2904        for (kind, label) in all {
2905            assert_eq!(kind.as_str(), label);
2906            assert_eq!(kind.to_string(), label);
2907        }
2908        let labels: std::collections::HashSet<_> = all.iter().map(|(_, l)| *l).collect();
2909        assert_eq!(labels.len(), all.len());
2910    }
2911
2912    /// A non-scalar `required_claims` value is named by its JSON type in the
2913    /// problem text, never echoed: it could hold anything, a secret included,
2914    /// and the text reaches the startup log.
2915    #[test]
2916    fn a_non_scalar_required_claim_is_described_never_echoed() {
2917        for (value, shown) in [
2918            (serde_json::json!({"nested": "s3cret-object"}), "an object"),
2919            (serde_json::json!(["s3cret-array"]), "an array"),
2920            (Value::Null, "null"),
2921        ] {
2922            let mut cfg = OAuthConfig {
2923                enabled: true,
2924                issuer: "https://idp.example.test/".into(),
2925                audience: "client-a".into(),
2926                resource: "https://kb.example.test/".into(),
2927                required_scope: Some("api:read".into()),
2928                ..OAuthConfig::default()
2929            };
2930            cfg.required_claims.insert("tid".into(), value.clone());
2931            let err = cfg.resolve(KeyNaming::Dotted("oauth")).unwrap_err();
2932            assert_eq!(
2933                err.problem_details()[0].kind(),
2934                ProblemKind::InvalidRequiredClaim
2935            );
2936            let text = err.to_string();
2937            assert!(text.contains(&format!("requires {shown}")), "{text}");
2938            assert!(!text.contains("s3cret"), "{value}: {text}");
2939        }
2940    }
2941
2942    /// A config file naming one required claim twice is refused while it is
2943    /// deserialized, never read as the last value.
2944    #[cfg(feature = "serde")]
2945    #[test]
2946    fn a_config_file_naming_a_required_claim_twice_is_refused() {
2947        let err = serde_json::from_str::<OAuthConfig>(
2948            r#"{"required_claims": {"tid": "good", "tid": "evil"}}"#,
2949        )
2950        .unwrap_err()
2951        .to_string();
2952        assert!(err.contains("\"tid\" more than once"), "{err}");
2953        assert!(!err.contains("evil"), "{err}");
2954        let ok: OAuthConfig =
2955            serde_json::from_str(r#"{"required_claims": {"tid": "good", "level": 2}}"#).unwrap();
2956        assert_eq!(ok.required_claims.len(), 2);
2957    }
2958
2959    #[cfg(feature = "serde")]
2960    #[test]
2961    fn the_claim_policy_settings_deserialize_and_default_to_off() {
2962        let yaml = "\
2963enabled: true
2964allowed_client_ids: [\"client-a\", \"client-b\"]
2965max_token_age_secs: 3600
2966required_claims:
2967  tid: \"tenant-1\"
2968  level: 2
2969  mfa: true
2970";
2971        let parsed: OAuthConfig = serde_yaml_ng::from_str(yaml).unwrap();
2972        assert_eq!(parsed.allowed_client_ids, ["client-a", "client-b"]);
2973        assert_eq!(parsed.max_token_age_secs, Some(3600));
2974        assert_eq!(parsed.required_claims["tid"], serde_json::json!("tenant-1"));
2975        assert_eq!(parsed.required_claims["level"], serde_json::json!(2));
2976        assert_eq!(parsed.required_claims["mfa"], serde_json::json!(true));
2977        let back: OAuthConfig =
2978            serde_yaml_ng::from_str(&serde_yaml_ng::to_string(&parsed).unwrap()).unwrap();
2979        assert_eq!(back, parsed);
2980
2981        // Omitted: exactly the defaults, which check nothing.
2982        let empty: OAuthConfig = serde_yaml_ng::from_str("{}").unwrap();
2983        assert_eq!(empty, OAuthConfig::default());
2984        assert!(empty.allowed_client_ids.is_empty());
2985        assert_eq!(empty.max_token_age_secs, None);
2986        assert!(empty.required_claims.is_empty());
2987        // `deny_unknown_fields` still refuses a misspelled one.
2988        assert!(serde_yaml_ng::from_str::<OAuthConfig>("allowed_clients: [\"a\"]\n").is_err());
2989        assert!(serde_yaml_ng::from_str::<OAuthConfig>("max_token_age: 5\n").is_err());
2990    }
2991
2992    #[test]
2993    fn the_claim_policy_settings_resolve_unchanged_and_default_off() {
2994        let resolved = enabled(|_| {}).resolve(WIKI).unwrap().unwrap();
2995        assert!(resolved.allowed_client_ids.is_empty());
2996        assert_eq!(resolved.max_token_age_secs, None);
2997        assert!(resolved.required_claims.is_empty());
2998
2999        let resolved = enabled(|c| {
3000            c.allowed_client_ids = vec!["client-a".into()];
3001            c.max_token_age_secs = Some(MAX_TOKEN_AGE_SECS);
3002            c.required_claims
3003                .insert("groups".into(), serde_json::json!("api-users"));
3004            c.required_claims
3005                .insert("level".into(), serde_json::json!(2));
3006            c.required_claims
3007                .insert("mfa".into(), serde_json::json!(false));
3008        })
3009        .resolve(WIKI)
3010        .unwrap()
3011        .unwrap();
3012        assert_eq!(resolved.allowed_client_ids, ["client-a"]);
3013        assert_eq!(resolved.max_token_age_secs, Some(MAX_TOKEN_AGE_SECS));
3014        assert_eq!(resolved.required_claims.len(), 3);
3015        let one = enabled(|c| c.max_token_age_secs = Some(1)).resolve(WIKI);
3016        assert!(one.unwrap().is_some());
3017
3018        // Every reserved claim is refused, each named, all at once.
3019        let err = enabled(|c| {
3020            for name in RESERVED_REQUIRED_CLAIMS {
3021                c.required_claims.insert((*name).into(), "x".into());
3022            }
3023        })
3024        .resolve(KeyNaming::Env("APP_OAUTH_"))
3025        .unwrap_err();
3026        assert_eq!(err.problems.len(), RESERVED_REQUIRED_CLAIMS.len(), "{err}");
3027        for p in err.problem_details() {
3028            assert_eq!(p.kind(), ProblemKind::InvalidRequiredClaim);
3029            assert_eq!(p.keys(), ["APP_OAUTH_REQUIRED_CLAIMS"]);
3030        }
3031    }
3032
3033    #[test]
3034    fn no_problem_message_carries_a_run_of_spaces() {
3035        // A dropped `\` at the end of a wrapped string literal leaves the next
3036        // line's indentation inside the message.
3037        for naming in [WIKI, KeyNaming::Env("APP_OAUTH_")] {
3038            for (kind, edit, _) in one_problem_per_kind() {
3039                let err = enabled(edit).resolve(naming).unwrap_err();
3040                for problem in &err.problems {
3041                    assert!(!problem.contains("  "), "{kind:?}: {problem:?}");
3042                }
3043            }
3044        }
3045    }
3046
3047    #[cfg(feature = "serde")]
3048    #[test]
3049    fn unset_claim_policy_settings_are_still_serialized() {
3050        // Consumers derive their list of settings from the serialized
3051        // defaults, so a new setting must appear even when it is off.
3052        let yaml = serde_yaml_ng::to_string(&OAuthConfig::default()).unwrap();
3053        for key in [
3054            "allowed_client_ids",
3055            "max_token_age_secs",
3056            "required_claims",
3057        ] {
3058            assert!(yaml.contains(key), "{key} missing from {yaml}");
3059        }
3060    }
3061}