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::fmt;
12
13use crate::algorithms::{Algorithm, DEFAULT_ALGORITHMS, parse_algorithm};
14use crate::validator::plain_http_non_loopback;
15
16/// Default [`OAuthConfig::scope_claims`]. `scope` is RFC 9068 §2.2.3's
17/// space-delimited string (Authentik, Kanidm, Keycloak); `scp` is what Authelia
18/// (array), Okta (array), Ory Hydra (array or string) and Entra ID (string) emit
19/// instead. Reading both by default is what makes an Authelia token pass without
20/// per-provider config, and it cannot widen access for a token that carries only
21/// `scope` (every Authentik token) because a claim the token does not have
22/// contributes nothing.
23pub const DEFAULT_SCOPE_CLAIMS: &[&str] = &["scope", "scp"];
24
25/// Default [`OAuthConfig::principal_claims`]. `email` is left out on purpose so a
26/// default deployment does not write addresses into its logs; an operator who
27/// wants it adds it.
28pub const DEFAULT_PRINCIPAL_CLAIMS: &[&str] = &["preferred_username", "sub"];
29
30/// Default [`OAuthConfig::leeway_secs`]: the clock-skew allowance, in seconds.
31pub const DEFAULT_LEEWAY_SECS: u64 = 60;
32
33/// Ceiling on [`OAuthConfig::leeway_secs`]. Leeway is for clock drift; a value
34/// large enough to matter against a 5–15 minute token lifetime (Kanidm issues
35/// 900 s tokens) is a way of switching `exp` off, which config must not be able
36/// to do.
37pub const MAX_LEEWAY_SECS: u64 = 300;
38
39/// How problem messages name a setting, so they match how the operator wrote it.
40///
41/// - `Dotted("mcp.oauth")` names the issuer `mcp.oauth.issuer` — a key in a YAML
42///   (or other serde) config nested under that path.
43/// - `Env("MYAPP_OAUTH_")` names it `MYAPP_OAUTH_ISSUER` — the field name
44///   uppercased and appended to the prefix.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46#[non_exhaustive]
47pub enum KeyNaming<'a> {
48    /// A dotted path prefix; a field is named `<prefix>.<field>`.
49    Dotted(&'a str),
50    /// An environment-variable prefix; a field is named `<PREFIX><FIELD>`.
51    Env(&'a str),
52}
53
54impl KeyNaming<'_> {
55    /// The name of setting `field` (a lower-case [`OAuthConfig`] field name).
56    pub fn key(&self, field: &str) -> String {
57        match self {
58            KeyNaming::Dotted("") => field.to_string(),
59            KeyNaming::Dotted(prefix) => format!("{prefix}.{field}"),
60            KeyNaming::Env(prefix) => format!("{prefix}{}", field.to_ascii_uppercase()),
61        }
62    }
63
64    /// The name of the whole block: the dotted prefix itself, or `PREFIX*` for
65    /// environment variables. An empty dotted prefix (the OAuth fields at the
66    /// root of the config) has no path to name, so the block is called
67    /// `OAuth config` rather than rendering as an empty string.
68    pub fn section(&self) -> String {
69        match self {
70            KeyNaming::Dotted("") => "OAuth config".to_string(),
71            KeyNaming::Dotted(prefix) => prefix.to_string(),
72            KeyNaming::Env(prefix) => format!("{prefix}*"),
73        }
74    }
75
76    /// The owned form, for storing past the borrow.
77    pub fn to_buf(&self) -> KeyNamingBuf {
78        match self {
79            KeyNaming::Dotted(p) => KeyNamingBuf::Dotted((*p).to_string()),
80            KeyNaming::Env(p) => KeyNamingBuf::Env((*p).to_string()),
81        }
82    }
83}
84
85/// Owned [`KeyNaming`], carried on [`ResolvedOAuthConfig`] and [`ConfigError`] so
86/// that log lines and errors produced after resolution (a token naming an
87/// algorithm outside the allowlist, a discovery document for another issuer) name
88/// settings the same way the config did.
89#[derive(Debug, Clone, PartialEq, Eq)]
90#[non_exhaustive]
91pub enum KeyNamingBuf {
92    /// See [`KeyNaming::Dotted`].
93    Dotted(String),
94    /// See [`KeyNaming::Env`].
95    Env(String),
96}
97
98impl KeyNamingBuf {
99    /// Borrow as a [`KeyNaming`].
100    pub fn as_naming(&self) -> KeyNaming<'_> {
101        match self {
102            KeyNamingBuf::Dotted(p) => KeyNaming::Dotted(p),
103            KeyNamingBuf::Env(p) => KeyNaming::Env(p),
104        }
105    }
106
107    /// Shorthand for `self.as_naming().key(field)`.
108    pub fn key(&self, field: &str) -> String {
109        self.as_naming().key(field)
110    }
111
112    /// Shorthand for `self.as_naming().section()`.
113    pub fn section(&self) -> String {
114        self.as_naming().section()
115    }
116}
117
118/// An enabled config that cannot be used, with every problem found.
119///
120/// `Display` renders all of them under one header naming the block the way
121/// [`KeyNaming`] spells it; for `Dotted("mcp.oauth")` that is
122///
123/// ```text
124/// mcp.oauth.enabled is true but the OAuth config is not usable:
125///   - <problem>
126///   - <problem>
127/// Fix these, or set mcp.oauth.enabled: false.
128/// ```
129#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
130pub struct ConfigError {
131    /// One human-readable sentence per problem, each naming its setting.
132    pub problems: Vec<String>,
133    naming: KeyNamingBuf,
134}
135
136impl ConfigError {
137    /// An error listing `problems`, whose header names settings per `naming`.
138    /// For loaders that add their own problems (parse errors, say) alongside the
139    /// ones [`OAuthConfig::resolve`] finds.
140    ///
141    /// `problems` must not be empty: an error with nothing to fix would display
142    /// as a header over one blank bullet. Debug builds assert it.
143    pub fn new(naming: KeyNaming<'_>, problems: Vec<String>) -> Self {
144        debug_assert!(
145            !problems.is_empty(),
146            "ConfigError::new called with no problems"
147        );
148        Self {
149            problems,
150            naming: naming.to_buf(),
151        }
152    }
153
154    /// How this error names settings.
155    pub fn naming(&self) -> KeyNaming<'_> {
156        self.naming.as_naming()
157    }
158}
159
160impl fmt::Display for ConfigError {
161    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
162        let list = self.problems.join("\n  - ");
163        match &self.naming {
164            KeyNamingBuf::Dotted(_) => {
165                let enabled = self.naming.key("enabled");
166                write!(
167                    f,
168                    "{enabled} is true but the OAuth config is not usable:\n  - {list}\n\
169                     Fix these, or set {enabled}: false."
170                )
171            }
172            KeyNamingBuf::Env(_) => {
173                let section = self.naming.section();
174                write!(
175                    f,
176                    "OAuth is configured through {section} but the config is not usable:\n  \
177                     - {list}\nFix these, or unset every {section} variable."
178                )
179            }
180        }
181    }
182}
183
184/// The OAuth resource-server settings, as written by an operator — unvalidated.
185///
186/// Turns the process into an OAuth 2.0 *resource server* (RFC 9728, RFC 9068,
187/// RFC 6750): it verifies JWT access tokens minted by a separate authorization
188/// server and never issues, refreshes or introspects anything itself. Call
189/// [`OAuthConfig::resolve`] to validate it into a [`ResolvedOAuthConfig`].
190///
191/// Provider-agnostic by construction: every provider-specific difference
192/// (audience value, scope claim name and shape, signing algorithm, `typ`,
193/// username claim) is a field below rather than a code path.
194///
195/// Every field is optional in serde input (feature `serde`); unknown keys are
196/// refused. **Nest this in your own config; do not `#[serde(flatten)]` it** —
197/// flattening silently defeats the unknown-key check (a general serde
198/// limitation, not specific to this crate); see [Embedding `OAuthConfig` in
199/// your own
200/// config](https://github.com/St0nefish/oauth-resource-server#embedding-oauthconfig-in-your-own-config)
201/// in the README. Nothing here hot-reloads: a changed value takes effect only
202/// when a new validator is built from it, which in practice means a restart.
203///
204/// Deliberately not `#[non_exhaustive]`, so applications can write
205/// `OAuthConfig { enabled: true, ..OAuthConfig::default() }` — a
206/// functional-record update that keeps compiling even after a field is
207/// added. What breaks instead is an exhaustive struct literal or
208/// destructuring pattern that names every field, which is possible only
209/// because every field here is public and the struct carries no
210/// `#[non_exhaustive]`; adding a field is still a breaking change under
211/// `0.x` (a new minor release, which Cargo treats as incompatible), just not
212/// for the functional-record-update form above.
213#[derive(Debug, Clone, PartialEq, Eq)]
214#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
215#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
216pub struct OAuthConfig {
217    /// Master switch. False (the default) means [`OAuthConfig::resolve`] returns
218    /// `Ok(None)`: no JWT validation happens at all and nothing else here is
219    /// checked.
220    #[cfg_attr(feature = "serde", serde(default))]
221    pub enabled: bool,
222    /// The authorization server's issuer identifier, compared BYTE-EXACTLY
223    /// against each token's `iss` claim and echoed verbatim in the
224    /// protected-resource metadata's `authorization_servers`. Copy it from the
225    /// AS's own discovery document including any trailing slash — Authentik's
226    /// issuer ends in one, and a token minted with `.../app/` will not match
227    /// `.../app`. Required; an absolute URL with no query or fragment, and no
228    /// space, control or non-ASCII character.
229    ///
230    /// `https`, as RFC 8414 §2 requires of an issuer: the signing keys are
231    /// found through it, and keys fetched over cleartext can be substituted by
232    /// anyone on the path. Plain `http` is accepted only for a loopback host,
233    /// or with [`OAuthConfig::allow_insecure_http`].
234    #[cfg_attr(feature = "serde", serde(default))]
235    pub issuer: String,
236    /// Where to fetch the signing keys (JWKS). Optional: absent or blank, it is
237    /// discovered from the issuer's own metadata (OpenID Connect Discovery, then
238    /// RFC 8414), and the discovered document's `issuer` must equal `issuer`
239    /// byte-for-byte or it is refused. Setting it explicitly skips discovery.
240    /// Fetched at startup and hourly in the background, and on an unknown `kid`
241    /// at most once a minute. The same URL rules as `issuer` apply, except
242    /// that a query is allowed; RFC 8414 §2 requires `https` for it too.
243    #[cfg_attr(
244        feature = "serde",
245        serde(default, skip_serializing_if = "Option::is_none")
246    )]
247    pub jwks_uri: Option<String>,
248    /// A value each token's `aud` claim must contain (string or array, RFC 7519
249    /// §4.1.3). Unioned with `audiences`; at least one of the two must be set,
250    /// and there is deliberately no default, because the right value depends on
251    /// the authorization server and a wrong guess either rejects everything or
252    /// accepts tokens meant for another service:
253    ///
254    /// - servers that honour RFC 8707 or let you configure an access-token
255    ///   audience (Authelia with a client `audience`) put the RESOURCE URL there
256    ///   — use the same value as `resource`;
257    /// - servers that ignore RFC 8707 and stamp the OAuth CLIENT ID (Authentik,
258    ///   Kanidm) need the client_id here.
259    ///
260    /// A client_id audience departs from RFC 9068 §4 (the `aud` must identify
261    /// this resource server) and from MCP's requirement that a server accept
262    /// only tokens issued for it as audience (RFC 8707 §2): every token that
263    /// client obtains from the authorization server, for any resource, carries
264    /// the same `aud`. It is sound only when that OAuth client is dedicated to
265    /// this one resource server and shared with no other API. Prefer a
266    /// resource-URL audience wherever the authorization server supports one.
267    #[cfg_attr(feature = "serde", serde(default))]
268    pub audience: String,
269    /// Additional accepted audiences. A token passes the audience check if its
270    /// `aud` contains ANY configured value. Useful while migrating from a
271    /// client_id audience to a resource-URL audience.
272    #[cfg_attr(feature = "serde", serde(default))]
273    pub audiences: Vec<String>,
274    /// This resource server's canonical identifier, published as `resource` in
275    /// the protected-resource metadata and used to derive the metadata URL
276    /// advertised in `WWW-Authenticate` (RFC 9728 §3). The public URL of the
277    /// protected endpoint, e.g. `https://api.example.com/v1`; required, with
278    /// the same URL rules as `issuer`.
279    ///
280    /// `https`, as RFC 9728 §1.2 requires of a resource identifier: it is the
281    /// URL clients send their bearer tokens to (RFC 6750 §5.3). Plain `http` is
282    /// accepted only for a loopback host, or with
283    /// [`OAuthConfig::allow_insecure_http`].
284    ///
285    /// Not implicitly compared against `aud` — list it in `audience`/`audiences`
286    /// when the authorization server stamps it there.
287    #[cfg_attr(feature = "serde", serde(default))]
288    pub resource: String,
289    /// A scope every token must carry. A valid token missing it gets 403
290    /// `insufficient_scope`, not 401. A single RFC 6749 §3.3 scope-token —
291    /// printable ASCII with no space, `"` or `\` — matched exactly and
292    /// case-sensitively. Unioned with `required_scopes`.
293    ///
294    /// No default. With neither this nor `required_scopes` set, no scope is
295    /// checked, and [`OAuthConfig::resolve`] then insists on
296    /// `require_at_jwt` or [`OAuthConfig::allow_unscoped_tokens`]. An
297    /// explicitly empty value is an error rather than "no scope", so a typo
298    /// cannot silently drop the check.
299    ///
300    /// The check is an exact all-of match with no scope hierarchy: a token
301    /// holding only `api:write` does not satisfy `api:read`, whatever the
302    /// authorization server means by it. Require a scope every accepted token
303    /// carries, and make finer, hierarchy-aware decisions in the application
304    /// with [`crate::AuthorizedToken::has_scope`].
305    #[cfg_attr(
306        feature = "serde",
307        serde(default, skip_serializing_if = "Option::is_none")
308    )]
309    pub required_scope: Option<String>,
310    /// Further scopes every token must carry — ALL of them, together with
311    /// `required_scope`. Each entry follows the same rules as `required_scope`.
312    /// Empty (the default) adds nothing.
313    #[cfg_attr(feature = "serde", serde(default))]
314    pub required_scopes: Vec<String>,
315    /// Advertised in the metadata document's `scopes_supported` and the 401
316    /// challenge's `scope` so a client knows what to ask for. Purely declarative —
317    /// enforcement is `required_scope`/`required_scopes`. Each entry is a
318    /// scope-token, as for `required_scope`.
319    ///
320    /// `None` (the default, and what an omitted key deserializes to) resolves to
321    /// the required scopes (`required_scope`, then `required_scopes`), so a
322    /// client that asks for exactly what is advertised gets a token that
323    /// passes. `Some(vec![])` resolves to an empty list: the metadata document
324    /// then omits `scopes_supported` (RFC 9728 §3.2), and the 401 challenge
325    /// names the required scopes instead. The two are kept distinct so an
326    /// application can supply its own default for an omitted key without
327    /// overriding an operator's explicit empty list — e.g.
328    /// `cfg.scopes_supported.get_or_insert_with(|| vec!["api:read".into()])`
329    /// before [`OAuthConfig::resolve`].
330    #[cfg_attr(
331        feature = "serde",
332        serde(default, skip_serializing_if = "Option::is_none")
333    )]
334    pub scopes_supported: Option<Vec<String>>,
335    /// Which claims hold the token's scopes. Every listed claim is read in every
336    /// shape — a space-delimited string or an array of strings — and the results
337    /// are unioned. The default ([`DEFAULT_SCOPE_CLAIMS`]) reads RFC 9068's
338    /// `scope` AND the `scp` that Authelia, Okta, Ory Hydra and Entra ID use
339    /// instead; reading a claim a token does not carry changes nothing.
340    #[cfg_attr(feature = "serde", serde(default = "default_scope_claims"))]
341    pub scope_claims: Vec<String>,
342    /// Claims tried in order to name the caller in logs — the first present,
343    /// non-empty string wins. Several servers put no username in access tokens
344    /// (Authelia, Kanidm: only a UUID `sub`), hence a chain ending in `sub`.
345    /// `email` is not in the default ([`DEFAULT_PRINCIPAL_CLAIMS`]) so addresses
346    /// do not land in logs unasked. Used for logging only, never for an
347    /// authorization decision.
348    #[cfg_attr(feature = "serde", serde(default = "default_principal_claims"))]
349    pub principal_claims: Vec<String>,
350    /// JWS algorithms a token may be signed with. Each key in the JWKS is
351    /// additionally limited to the algorithms its own type (and its `alg`, when
352    /// it declares one) can produce. `HS256`/`HS384`/`HS512` and `none` are
353    /// refused at resolve time: a resource server must never verify with a shared
354    /// secret. The default ([`DEFAULT_ALGORITHMS`]) is every asymmetric algorithm
355    /// this build can verify.
356    #[cfg_attr(feature = "serde", serde(default = "default_algorithms"))]
357    pub algorithms: Vec<String>,
358    /// Clock-skew allowance, in seconds, applied to `exp` and `nbf`. Default
359    /// [`DEFAULT_LEEWAY_SECS`]; capped at [`MAX_LEEWAY_SECS`], since a leeway
360    /// comparable to a token's lifetime is a way of disabling expiry.
361    #[cfg_attr(feature = "serde", serde(default = "default_leeway_secs"))]
362    pub leeway_secs: u64,
363    /// Require the JWT header `typ` to be `at+jwt` (RFC 9068 §2.1). Off by
364    /// default because Authentik, Keycloak, Entra ID and Okta emit `JWT` or no
365    /// `typ`. Turn it ON for servers that do emit `at+jwt` (Authelia, Kanidm): it
366    /// is the check that stops an ID token minted for the same client from being
367    /// replayed as an access token. With it off, `at+jwt`, `JWT` and no `typ` pass
368    /// and any other type (`dpop+jwt`, `logout+jwt`...) is still refused.
369    ///
370    /// Off is a deliberate, configurable departure from RFC 9068 §4, under
371    /// which a resource server MUST reject any `typ` other than `at+jwt` or
372    /// `application/at+jwt`; RFC 8725 §3.11 recommends the same explicit
373    /// typing. With it off, a required scope is what keeps ID tokens out.
374    #[cfg_attr(feature = "serde", serde(default))]
375    pub require_at_jwt: bool,
376    /// Accept a configuration with no required scope and `require_at_jwt`
377    /// off. Default false: [`OAuthConfig::resolve`] refuses that combination,
378    /// because nothing in it tells an access token from an OIDC ID token
379    /// minted for the same client (RFC 8725 §3.11–3.12), and on servers that
380    /// stamp the client_id as `aud` (Authentik, Kanidm) the ID token a front
381    /// end got at login would then be a working API credential. Set it only
382    /// when "signed by this issuer for this audience" really is all the
383    /// application needs; the validator still logs a `warn` at startup.
384    #[cfg_attr(feature = "serde", serde(default))]
385    pub allow_unscoped_tokens: bool,
386    /// Accept a plain-`http` `issuer`, `jwks_uri` or `resource` on a
387    /// non-loopback host. Default false: [`OAuthConfig::resolve`] refuses one,
388    /// because signing keys fetched over cleartext can be substituted by anyone
389    /// on the path (RFC 8414 §2 requires `https` for the issuer and its
390    /// `jwks_uri`), and bearer tokens sent to a cleartext resource can be read
391    /// in transit (RFC 9728 §1.2, RFC 6750 §5.3). Loopback hosts (`127.0.0.0/8`,
392    /// `::1`, `localhost`) are always allowed, for tests and local development.
393    ///
394    /// The same rule holds at run time for URLs the configuration does not
395    /// name: a `jwks_uri` discovered from a (plain-`http`) issuer's metadata,
396    /// and every redirect a metadata or JWKS fetch follows, may reach plain
397    /// `http` on a non-loopback host only with this set. An `https` issuer
398    /// never hands out an `http` `jwks_uri`, and a redirect from `https` to
399    /// `http` is never followed, whatever this says.
400    ///
401    /// Set it for an in-cluster address on a private network (an
402    /// `http://idp:9000/...` `jwks_uri`, say) where the path itself is trusted;
403    /// the validator still logs a `warn` for each such URL at startup, and for
404    /// each such discovered URL or redirect when it is used.
405    #[cfg_attr(feature = "serde", serde(default))]
406    pub allow_insecure_http: bool,
407    /// Whether a static token (an API key the application configures
408    /// separately) is still accepted while OAuth is on. Default true: both
409    /// credentials work side by side. Set false to run OAuth-only even if a
410    /// static token is configured (it is then ignored). Read by
411    /// [`crate::static_token_policy`]; the validator itself never looks at it.
412    #[cfg_attr(feature = "serde", serde(default = "default_true"))]
413    pub accept_static_bearer: bool,
414}
415
416impl Default for OAuthConfig {
417    fn default() -> Self {
418        Self {
419            enabled: false,
420            issuer: String::new(),
421            jwks_uri: None,
422            audience: String::new(),
423            audiences: Vec::new(),
424            resource: String::new(),
425            required_scope: None,
426            required_scopes: Vec::new(),
427            scopes_supported: None,
428            scope_claims: default_scope_claims(),
429            principal_claims: default_principal_claims(),
430            algorithms: default_algorithms(),
431            leeway_secs: default_leeway_secs(),
432            require_at_jwt: false,
433            allow_unscoped_tokens: false,
434            allow_insecure_http: false,
435            accept_static_bearer: true,
436        }
437    }
438}
439
440fn strings(list: &[&str]) -> Vec<String> {
441    list.iter().map(|s| s.to_string()).collect()
442}
443
444fn default_scope_claims() -> Vec<String> {
445    strings(DEFAULT_SCOPE_CLAIMS)
446}
447
448fn default_principal_claims() -> Vec<String> {
449    strings(DEFAULT_PRINCIPAL_CLAIMS)
450}
451
452fn default_algorithms() -> Vec<String> {
453    strings(DEFAULT_ALGORITHMS)
454}
455
456fn default_leeway_secs() -> u64 {
457    DEFAULT_LEEWAY_SECS
458}
459
460#[cfg(feature = "serde")]
461fn default_true() -> bool {
462    true
463}
464
465impl OAuthConfig {
466    /// Validate and resolve: `Ok(None)` when disabled, `Ok(Some(..))` when
467    /// enabled and usable, `Err` naming every problem it can find at once when
468    /// enabled and not. `naming` decides how the problems (and, later, the
469    /// validator's log lines) name each setting.
470    ///
471    /// Does no I/O: whether the issuer is reachable and publishes usable keys
472    /// is found out later, by the validator.
473    ///
474    /// # Errors
475    ///
476    /// A [`ConfigError`] listing every problem in an enabled config: a blank
477    /// `issuer` or `resource`, no audience in either `audience` or `audiences`,
478    /// a URL (`issuer`, `resource` or `jwks_uri`) that is not absolute
479    /// `http`/`https`, has surrounding whitespace or contains a space, control
480    /// or non-ASCII character (or, for `issuer` and `resource`, that has a
481    /// query or a fragment), a plain-`http` URL on a non-loopback host without
482    /// [`OAuthConfig::allow_insecure_http`], a blank or multi-word required
483    /// scope, a required or supported scope that is not an RFC 6749 §3.3
484    /// scope-token, no required scope with neither `require_at_jwt` nor
485    /// [`OAuthConfig::allow_unscoped_tokens`] set, an empty `scope_claims`, a
486    /// blank entry in a list, an algorithm that is HMAC, `none` or unknown, an
487    /// empty algorithm list, or a `leeway_secs` over [`MAX_LEEWAY_SECS`].
488    ///
489    /// # Examples
490    ///
491    /// ```
492    /// use oauth_resource_server::{KeyNaming, OAuthConfig};
493    ///
494    /// let config = OAuthConfig {
495    ///     enabled: true,
496    ///     issuer: "https://auth.example.com/".into(),
497    ///     audience: "example-api".into(),
498    ///     resource: "https://api.example.com".into(),
499    ///     required_scope: Some("api:read".into()),
500    ///     scopes_supported: Some(vec!["api:read".into()]),
501    ///     ..OAuthConfig::default()
502    /// };
503    /// let resolved = config.resolve(KeyNaming::Dotted("oauth")).unwrap().unwrap();
504    /// assert_eq!(resolved.required_scopes, ["api:read"]);
505    /// assert_eq!(resolved.jwks_uri, None); // discovered from the issuer later
506    ///
507    /// // Disabled: nothing is checked.
508    /// assert_eq!(OAuthConfig::default().resolve(KeyNaming::Dotted("oauth")), Ok(None));
509    ///
510    /// // Broken: every problem at once, each named the way the operator wrote it.
511    /// let err = OAuthConfig {
512    ///     enabled: true,
513    ///     required_scope: Some("api:read".into()),
514    ///     leeway_secs: 3600,
515    ///     ..OAuthConfig::default()
516    /// }
517    /// .resolve(KeyNaming::Env("MYAPP_OAUTH_"))
518    /// .unwrap_err();
519    /// assert_eq!(err.problems.len(), 2);
520    /// assert!(err.problems[0].contains("MYAPP_OAUTH_ISSUER"));
521    /// assert!(err.problems[1].contains("MYAPP_OAUTH_LEEWAY_SECS"));
522    /// ```
523    pub fn resolve(
524        self,
525        naming: KeyNaming<'_>,
526    ) -> Result<Option<ResolvedOAuthConfig>, ConfigError> {
527        if !self.enabled {
528            return Ok(None);
529        }
530        let key = |field: &str| naming.key(field);
531        let mut problems: Vec<String> = Vec::new();
532
533        let mut blank = Vec::new();
534        for (name, value) in [("issuer", &self.issuer), ("resource", &self.resource)] {
535            if value.trim().is_empty() {
536                blank.push(key(name));
537            }
538        }
539        if self.audience.trim().is_empty() && self.audiences.is_empty() {
540            blank.push(format!("{} (or {})", key("audience"), key("audiences")));
541        }
542        if !blank.is_empty() {
543            problems.push(format!(
544                "these required settings are empty: {}. Set issuer to the authorization \
545                 server's issuer (byte-exact, including any trailing slash), resource to \
546                 this server's public URL, and audience to what that server puts in \
547                 an access token's `aud` — the resource URL if it honours RFC 8707 or \
548                 lets you configure an audience (e.g. Authelia), or the OAuth client_id \
549                 if it stamps that (e.g. Authentik, Kanidm)",
550                blank.join(", ")
551            ));
552        }
553
554        let urls = [
555            ("issuer", Some(&self.issuer), true),
556            ("resource", Some(&self.resource), true),
557            ("jwks_uri", self.jwks_uri.as_ref(), false),
558        ];
559        for (name, value, identifier) in urls {
560            let Some(value) = value.filter(|v| !v.trim().is_empty()) else {
561                continue;
562            };
563            match check_url(&key(name), value, identifier) {
564                Err(e) => problems.push(e),
565                Ok(()) if !self.allow_insecure_http && plain_http_non_loopback(value) => {
566                    problems.push(format!(
567                        "{} {value:?} uses plain http on a non-loopback host — {}. Use https, \
568                         or set {} if this address is on a network you trust",
569                        key(name),
570                        if name == "resource" {
571                            "bearer tokens sent to it can be read in transit (RFC 9728 §1.2 \
572                             requires https)"
573                        } else {
574                            "signing keys fetched over it can be substituted by anyone on the \
575                             path (RFC 8414 §2 requires https)"
576                        },
577                        key("allow_insecure_http")
578                    ));
579                }
580                Ok(()) => {}
581            }
582        }
583        if self.audiences.iter().any(|a| a.trim().is_empty()) {
584            problems.push(format!("{} contains an empty entry", key("audiences")));
585        }
586
587        if let Some(required_scope) = &self.required_scope {
588            if required_scope.trim().is_empty() {
589                problems.push(format!(
590                    "{} must not be empty — a blank required scope would let any signed \
591                     token through unscoped. Use a scope your authorization server \
592                     actually issues",
593                    key("required_scope")
594                ));
595            } else if required_scope.split_whitespace().count() != 1 {
596                problems.push(format!(
597                    "{} {:?} must be a single scope (no spaces) — scopes are matched one \
598                     token at a time",
599                    key("required_scope"),
600                    required_scope
601                ));
602            } else if !is_scope_token(required_scope.trim()) {
603                problems.push(scope_token_problem(&key("required_scope"), required_scope));
604            }
605        }
606        for scope in &self.required_scopes {
607            if scope.trim().is_empty() {
608                problems.push(format!(
609                    "{} contains an empty entry — a blank required scope would let a \
610                     token through without it. Remove the entry or name a scope your \
611                     authorization server actually issues",
612                    key("required_scopes")
613                ));
614            } else if scope.split_whitespace().count() != 1 {
615                problems.push(format!(
616                    "{} entry {:?} must be a single scope (no spaces) — scopes are matched \
617                     one token at a time; list each one as its own entry",
618                    key("required_scopes"),
619                    scope
620                ));
621            } else if !is_scope_token(scope.trim()) {
622                problems.push(scope_token_problem(
623                    &format!("{} entry", key("required_scopes")),
624                    scope,
625                ));
626            }
627        }
628        if let Some(supported) = &self.scopes_supported {
629            if supported.iter().any(|s| s.trim().is_empty()) {
630                problems.push(format!(
631                    "{} contains an empty entry",
632                    key("scopes_supported")
633                ));
634            }
635            for scope in supported.iter().filter(|s| !s.trim().is_empty()) {
636                if !is_scope_token(scope.trim()) {
637                    problems.push(scope_token_problem(
638                        &format!("{} entry", key("scopes_supported")),
639                        scope,
640                    ));
641                }
642            }
643        }
644        if self.required_scope.is_none()
645            && self.required_scopes.is_empty()
646            && !self.require_at_jwt
647            && !self.allow_unscoped_tokens
648        {
649            problems.push(format!(
650                "no required scope is configured ({} and {} are unset) and {} is off — \
651                 nothing would tell an access token from an OIDC ID token minted for the \
652                 same client, so any token this issuer signs for the audience would be \
653                 accepted. Set {} to a scope only access tokens carry, turn on {} if the \
654                 authorization server emits typ at+jwt, or set {} to accept that",
655                key("required_scope"),
656                key("required_scopes"),
657                key("require_at_jwt"),
658                key("required_scope"),
659                key("require_at_jwt"),
660                key("allow_unscoped_tokens")
661            ));
662        }
663        if self.scope_claims.is_empty() || self.scope_claims.iter().any(|c| c.trim().is_empty()) {
664            problems.push(format!(
665                "{} must list at least one non-empty claim name (default: [\"scope\", \
666                 \"scp\"])",
667                key("scope_claims")
668            ));
669        }
670        if self.principal_claims.iter().any(|c| c.trim().is_empty()) {
671            problems.push(format!(
672                "{} contains an empty entry",
673                key("principal_claims")
674            ));
675        }
676
677        let mut algorithms = Vec::new();
678        let mut bad_algorithms = Vec::new();
679        for name in &self.algorithms {
680            match parse_algorithm(name) {
681                Ok(alg) if !algorithms.contains(&alg) => algorithms.push(alg),
682                Ok(_) => {}
683                Err(reason) => bad_algorithms.push(reason.to_string()),
684            }
685        }
686        if !bad_algorithms.is_empty() {
687            problems.push(format!(
688                "{} has unacceptable entries: {}",
689                key("algorithms"),
690                bad_algorithms.join("; ")
691            ));
692        } else if algorithms.is_empty() {
693            problems.push(format!(
694                "{} must list at least one algorithm",
695                key("algorithms")
696            ));
697        }
698
699        if self.leeway_secs > MAX_LEEWAY_SECS {
700            problems.push(format!(
701                "{} {} is over the {}-second cap — leeway is for clock drift, not for \
702                 extending token lifetimes",
703                key("leeway_secs"),
704                self.leeway_secs,
705                MAX_LEEWAY_SECS
706            ));
707        }
708
709        if !problems.is_empty() {
710            return Err(ConfigError::new(naming, problems));
711        }
712
713        // `required_scope` first, then `required_scopes` in order, trimmed and
714        // deduplicated — a stable order keeps the 403 challenge's `scope` value
715        // the same from one start to the next.
716        let mut required_scopes: Vec<String> = Vec::new();
717        for scope in self
718            .required_scope
719            .iter()
720            .chain(self.required_scopes.iter())
721        {
722            let scope = scope.trim();
723            if !required_scopes.iter().any(|s| s == scope) {
724                required_scopes.push(scope.to_string());
725            }
726        }
727
728        // An omitted `scopes_supported` advertises what is required, so a client
729        // that asks for exactly the advertised scopes gets a token that passes.
730        let scopes_supported = match self.scopes_supported {
731            Some(listed) => listed.iter().map(|s| s.trim().to_string()).collect(),
732            None => required_scopes.clone(),
733        };
734
735        Ok(Some(ResolvedOAuthConfig {
736            issuer: self.issuer,
737            jwks_uri: self.jwks_uri.filter(|u| !u.trim().is_empty()),
738            audience: self.audience,
739            audiences: self.audiences,
740            resource: self.resource,
741            required_scopes,
742            scopes_supported,
743            scope_claims: self.scope_claims,
744            principal_claims: self.principal_claims,
745            algorithms,
746            leeway_secs: self.leeway_secs,
747            require_at_jwt: self.require_at_jwt,
748            allow_unscoped_tokens: self.allow_unscoped_tokens,
749            allow_insecure_http: self.allow_insecure_http,
750            accept_static_bearer: self.accept_static_bearer,
751            resource_name: None,
752            key_naming: naming.to_buf(),
753        }))
754    }
755}
756
757/// Check that a URL setting is an absolute http(s) URL, and (for the two
758/// identifiers, `issuer` and `resource`) that it carries no fragment or query:
759/// RFC 8414 §2 forbids both in an issuer, RFC 8707 §2 a fragment in a resource,
760/// and either one in an identifier that is compared byte-for-byte is a typo
761/// waiting to reject every token.
762///
763/// It also refuses a space, a control character or a non-ASCII character
764/// anywhere in the value. The URL parser would silently drop an embedded tab or
765/// newline and percent-encode the rest, but the RAW string is what is stored,
766/// compared and echoed into every `WWW-Authenticate` challenge — where such a
767/// character makes the header invalid, and the 401 would go out without one.
768fn check_url(key: &str, value: &str, identifier: bool) -> Result<(), String> {
769    let parsed = reqwest::Url::parse(value.trim())
770        .map_err(|e| format!("{key} {value:?} is not an absolute URL ({e})"))?;
771    if !matches!(parsed.scheme(), "https" | "http") {
772        return Err(format!("{key} {value:?} must be an http(s) URL"));
773    }
774    if identifier && (parsed.fragment().is_some() || parsed.query().is_some()) {
775        return Err(format!(
776            "{key} {value:?} must not contain a query or fragment"
777        ));
778    }
779    if value != value.trim() {
780        return Err(format!(
781            "{key} {value:?} has leading/trailing whitespace — it is compared byte-for-byte"
782        ));
783    }
784    if value.chars().any(|c| !c.is_ascii_graphic()) {
785        return Err(format!(
786            "{key} {value:?} contains a space, a control character or a non-ASCII \
787             character — write it percent-encoded (and an internationalized host in its \
788             punycode form)"
789        ));
790    }
791    Ok(())
792}
793
794/// RFC 6749 §3.3 `scope-token = 1*( %x21 / %x23-5B / %x5D-7E )`: printable
795/// ASCII other than space, `"` and `\`. RFC 6750 §3 holds the `scope` attribute
796/// of a challenge to the same set, and RFC 9728 §2 `scopes_supported` to the
797/// same values.
798fn is_scope_token(scope: &str) -> bool {
799    !scope.is_empty()
800        && scope
801            .bytes()
802            .all(|b| b == 0x21 || (0x23..=0x5B).contains(&b) || (0x5D..=0x7E).contains(&b))
803}
804
805fn scope_token_problem(what: &str, scope: &str) -> String {
806    format!(
807        "{what} {scope:?} is not a valid scope — a scope is printable ASCII with no space, \
808         '\"' or '\\' (RFC 6749 §3.3)"
809    )
810}
811
812/// The validated config an [`crate::OAuthValidator`] is built from.
813///
814/// Only [`OAuthConfig::resolve`] produces one with every invariant checked, so
815/// holding a `ResolvedOAuthConfig` (rather than an `Option` of one) IS the answer
816/// to "is OAuth on and usable" — nothing downstream re-checks a boolean. Fields
817/// are public so tests and applications can adjust a resolved value (the
818/// validator re-checks the invariants whose violation it could not survive: a
819/// non-empty audience set, a non-empty algorithm list, and `leeway_secs` within
820/// [`MAX_LEEWAY_SECS`]).
821///
822/// `#[non_exhaustive]`: outside this crate one comes from
823/// [`OAuthConfig::resolve`] (or, in tests, `testing::resolved_config`) and is
824/// then adjusted field by field, never built with a struct literal — which is
825/// what lets a new resolved setting be added without a breaking change.
826#[derive(Debug, Clone, PartialEq, Eq)]
827#[non_exhaustive]
828pub struct ResolvedOAuthConfig {
829    /// See [`OAuthConfig::issuer`]; byte-exact.
830    pub issuer: String,
831    /// `None` means "discover from the issuer's metadata". Never blank.
832    pub jwks_uri: Option<String>,
833    /// The single audience; may be empty when `audiences` is not. Use
834    /// [`ResolvedOAuthConfig::accepted_audiences`] for the effective set.
835    pub audience: String,
836    /// See [`OAuthConfig::audiences`].
837    pub audiences: Vec<String>,
838    /// See [`OAuthConfig::resource`].
839    pub resource: String,
840    /// `required_scope` ∪ `required_scopes`: trimmed, deduplicated, in config
841    /// order (`required_scope` first). A token must carry every one; empty means
842    /// no scope check at all.
843    pub required_scopes: Vec<String>,
844    /// See [`OAuthConfig::scopes_supported`]; `None` there resolves to
845    /// `required_scopes`. Advertised in the metadata document (omitted from it
846    /// when empty) and in the 401 challenge.
847    pub scopes_supported: Vec<String>,
848    /// See [`OAuthConfig::scope_claims`].
849    pub scope_claims: Vec<String>,
850    /// See [`OAuthConfig::principal_claims`].
851    pub principal_claims: Vec<String>,
852    /// Parsed and deduplicated. [`Algorithm`] has no HMAC or `none` variant,
853    /// so this can never hold one.
854    pub algorithms: Vec<Algorithm>,
855    /// See [`OAuthConfig::leeway_secs`].
856    pub leeway_secs: u64,
857    /// See [`OAuthConfig::require_at_jwt`].
858    pub require_at_jwt: bool,
859    /// See [`OAuthConfig::allow_unscoped_tokens`]. Informational once
860    /// resolved: `resolve` has already applied it.
861    pub allow_unscoped_tokens: bool,
862    /// See [`OAuthConfig::allow_insecure_http`]. `resolve` has already applied
863    /// it to the configured URLs; the validator still reads it, for a
864    /// discovered `jwks_uri` and for redirects followed while fetching keys.
865    pub allow_insecure_http: bool,
866    /// See [`OAuthConfig::accept_static_bearer`].
867    pub accept_static_bearer: bool,
868    /// Human-readable name published as `resource_name` in the RFC 9728
869    /// metadata document; omitted from it when `None`. Not a config key:
870    /// [`OAuthConfig::resolve`] leaves it `None`, and an application that wants
871    /// one sets it on the resolved value (it names the application, which the
872    /// operator has no reason to change).
873    pub resource_name: Option<String>,
874    /// How the validator's log lines and errors name settings; what `resolve`
875    /// was given.
876    pub key_naming: KeyNamingBuf,
877}
878
879impl ResolvedOAuthConfig {
880    /// `audience` ∪ `audiences`, blanks dropped, in config order.
881    pub fn accepted_audiences(&self) -> Vec<String> {
882        let mut out: Vec<String> = Vec::new();
883        for a in std::iter::once(&self.audience).chain(self.audiences.iter()) {
884            if !a.trim().is_empty() && !out.contains(a) {
885                out.push(a.clone());
886            }
887        }
888        out
889    }
890}
891
892#[cfg(test)]
893mod tests {
894    use super::*;
895
896    const WIKI: KeyNaming<'static> = KeyNaming::Dotted("mcp.oauth");
897
898    /// An enabled block with every required key set, for the rejection tests to
899    /// break one key at a time. It opts into an unscoped configuration, so a
900    /// test that sets no scope is not also refused for that; the unscoped
901    /// refusal has tests of its own.
902    fn enabled(edit: impl FnOnce(&mut OAuthConfig)) -> OAuthConfig {
903        let mut cfg = OAuthConfig {
904            enabled: true,
905            issuer: "https://idp.example.test/".into(),
906            resource: "https://kb.example.test/mcp".into(),
907            audience: "c".into(),
908            allow_unscoped_tokens: true,
909            ..OAuthConfig::default()
910        };
911        edit(&mut cfg);
912        cfg
913    }
914
915    fn resolve_err(cfg: OAuthConfig) -> String {
916        cfg.resolve(WIKI).unwrap_err().to_string()
917    }
918
919    // ── defaults ─────────────────────────────────────────────────────────────
920
921    #[test]
922    fn oauth_is_disabled_by_default_and_has_no_default_scope() {
923        let cfg = OAuthConfig::default();
924        assert!(!cfg.enabled);
925        // The crate is not MCP-specific: no required scope and no advertised
926        // scopes unless the application or operator names them.
927        assert_eq!(cfg.required_scope, None);
928        assert!(cfg.required_scopes.is_empty());
929        assert_eq!(cfg.scopes_supported, None);
930        assert_eq!(cfg.jwks_uri, None);
931        assert_eq!(cfg.scope_claims, ["scope", "scp"]);
932        assert_eq!(cfg.principal_claims, ["preferred_username", "sub"]);
933        assert_eq!(cfg.algorithms, DEFAULT_ALGORITHMS);
934        assert_eq!(cfg.leeway_secs, 60);
935        assert!(!cfg.require_at_jwt);
936        assert!(!cfg.allow_unscoped_tokens);
937        assert!(!cfg.allow_insecure_http);
938        assert!(cfg.accept_static_bearer);
939    }
940
941    #[test]
942    fn disabled_resolves_to_none() {
943        assert_eq!(OAuthConfig::default().resolve(WIKI), Ok(None));
944        // Even a broken disabled block: nothing is checked.
945        let cfg = OAuthConfig {
946            algorithms: vec!["HS256".into()],
947            ..OAuthConfig::default()
948        };
949        assert_eq!(cfg.resolve(WIKI), Ok(None));
950    }
951
952    #[test]
953    fn a_minimal_enabled_block_resolves_with_every_default() {
954        let cfg = OAuthConfig {
955            enabled: true,
956            issuer: "https://authentik.example.test/application/o/example-app/".into(),
957            jwks_uri: Some("https://authentik.example.test/application/o/example-app/jwks/".into()),
958            audience: "some-client-id".into(),
959            resource: "https://kb.example.test/mcp".into(),
960            required_scope: Some("api:read".into()),
961            ..OAuthConfig::default()
962        };
963        let oauth = cfg.resolve(WIKI).unwrap().expect("enabled");
964        // The trailing slash must survive verbatim: it is compared byte-exactly
965        // against the `iss` claim, and Authentik's issuer has one.
966        assert_eq!(
967            oauth.issuer,
968            "https://authentik.example.test/application/o/example-app/"
969        );
970        assert_eq!(oauth.audience, "some-client-id");
971        assert_eq!(oauth.resource, "https://kb.example.test/mcp");
972        assert_eq!(oauth.required_scopes, ["api:read"]);
973        // An omitted `scopes_supported` advertises the required scopes.
974        assert_eq!(oauth.scopes_supported, ["api:read"]);
975        assert_eq!(oauth.accepted_audiences(), ["some-client-id"]);
976        assert_eq!(oauth.scope_claims, ["scope", "scp"]);
977        assert_eq!(oauth.principal_claims, ["preferred_username", "sub"]);
978        assert!(oauth.algorithms.contains(&Algorithm::RS256));
979        assert_eq!(oauth.leeway_secs, 60);
980        assert!(!oauth.require_at_jwt);
981        assert!(!oauth.allow_unscoped_tokens);
982        assert!(!oauth.allow_insecure_http);
983        assert!(oauth.accept_static_bearer);
984        assert_eq!(oauth.resource_name, None);
985        assert_eq!(oauth.key_naming, KeyNamingBuf::Dotted("mcp.oauth".into()));
986    }
987
988    // ── rejection: every problem at once, each naming its key ────────────────
989
990    #[test]
991    fn enabled_with_blank_required_settings_is_rejected_naming_all_of_them() {
992        let cfg = OAuthConfig {
993            enabled: true,
994            ..OAuthConfig::default()
995        };
996        let err = resolve_err(cfg);
997        for setting in [
998            "mcp.oauth.issuer",
999            "mcp.oauth.audience",
1000            "mcp.oauth.resource",
1001        ] {
1002            assert!(err.contains(setting), "{setting} missing from: {err}");
1003        }
1004        // Optional: an absent jwks_uri means "discover it".
1005        assert!(!err.contains("mcp.oauth.jwks_uri"), "{err}");
1006    }
1007
1008    /// The two phrases where mcp-md-wiki's messages are MCP-specific and this
1009    /// crate's are not. mcp-md-wiki#308: mcp-md-wiki keeps its historical text
1010    /// byte-identical by rewriting exactly these in `ConfigError::problems`
1011    /// before display.
1012    ///
1013    /// An early warning, not a contract. Message text is not a stable API (see
1014    /// the README's semver policy), and what actually guarantees mcp-md-wiki's
1015    /// text is its own test pinning its full output, which fails there on the
1016    /// dependency bump if this wording changes. This pin only makes the
1017    /// breakage visible here first: when a test below fails, change the text
1018    /// deliberately and tell mcp-md-wiki, rather than treating the phrase as
1019    /// frozen.
1020    const WIKI_REWRITES: [(&str, &str); 2] = [
1021        ("this server's public URL,", "this server's public MCP URL,"),
1022        (
1023            "unscoped. Use a scope your",
1024            "unscoped. Use \"mcp:read\" (the default) or a scope your",
1025        ),
1026    ];
1027
1028    fn as_wiki_text(problem: &str) -> String {
1029        WIKI_REWRITES
1030            .iter()
1031            .fold(problem.to_string(), |p, (generic, wiki)| {
1032                p.replace(generic, wiki)
1033            })
1034    }
1035
1036    #[test]
1037    fn config_error_display_for_dotted_naming_is_generic_and_maps_to_mcp_md_wiki_text() {
1038        // A required scope, as mcp-md-wiki always supplies one before resolving,
1039        // so the unscoped refusal does not join the one problem pinned here.
1040        let cfg = OAuthConfig {
1041            enabled: true,
1042            required_scope: Some("mcp:read".into()),
1043            ..OAuthConfig::default()
1044        };
1045        // Spelled out in full, not rebuilt from the format strings under test.
1046        let expected = "mcp.oauth.enabled is true but the OAuth config is not usable:\n  - \
1047these required settings are empty: mcp.oauth.issuer, mcp.oauth.resource, mcp.oauth.audience \
1048(or mcp.oauth.audiences). Set issuer to the authorization server's issuer (byte-exact, \
1049including any trailing slash), resource to this server's public URL, and audience to \
1050what that server puts in an access token's `aud` — the resource URL if it honours RFC 8707 \
1051or lets you configure an audience (e.g. Authelia), or the OAuth client_id if it stamps that \
1052(e.g. Authentik, Kanidm)\nFix these, or set mcp.oauth.enabled: false.";
1053        let mut err = cfg.resolve(WIKI).unwrap_err();
1054        assert_eq!(err.to_string(), expected);
1055        assert_eq!(err.problems[0].matches(WIKI_REWRITES[0].0).count(), 1);
1056
1057        // The text mcp-md-wiki printed before mcp-md-wiki#308, recovered by its
1058        // rewrite of the generic phrase.
1059        let wiki_expected = "mcp.oauth.enabled is true but the OAuth config is not usable:\n  - \
1060these required settings are empty: mcp.oauth.issuer, mcp.oauth.resource, mcp.oauth.audience \
1061(or mcp.oauth.audiences). Set issuer to the authorization server's issuer (byte-exact, \
1062including any trailing slash), resource to this server's public MCP URL, and audience to \
1063what that server puts in an access token's `aud` — the resource URL if it honours RFC 8707 \
1064or lets you configure an audience (e.g. Authelia), or the OAuth client_id if it stamps that \
1065(e.g. Authentik, Kanidm)\nFix these, or set mcp.oauth.enabled: false.";
1066        err.problems = err.problems.iter().map(|p| as_wiki_text(p)).collect();
1067        assert_eq!(err.to_string(), wiki_expected);
1068    }
1069
1070    #[test]
1071    fn scope_problem_messages_are_generic_and_map_to_mcp_md_wiki_text() {
1072        let err = enabled(|c| c.required_scope = Some(String::new()))
1073            .resolve(WIKI)
1074            .unwrap_err();
1075        assert_eq!(
1076            err.problems,
1077            [
1078                "mcp.oauth.required_scope must not be empty — a blank required scope would \
1079              let any signed token through unscoped. Use a scope your authorization server \
1080              actually issues"
1081            ]
1082        );
1083        assert_eq!(err.problems[0].matches(WIKI_REWRITES[1].0).count(), 1);
1084        // mcp-md-wiki#308: mcp-md-wiki's pre-extraction text, recovered.
1085        assert_eq!(
1086            as_wiki_text(&err.problems[0]),
1087            "mcp.oauth.required_scope must not be empty — a blank required scope would \
1088              let any signed token through unscoped. Use \"mcp:read\" (the default) or a \
1089              scope your authorization server actually issues"
1090        );
1091        // Nothing MCP-specific leaks into a non-MCP consumer's messages.
1092        let text = OAuthConfig {
1093            enabled: true,
1094            required_scope: Some(String::new()),
1095            ..OAuthConfig::default()
1096        }
1097        .resolve(KeyNaming::Env("APP_OAUTH_"))
1098        .unwrap_err()
1099        .to_string();
1100        assert!(!text.contains("MCP") && !text.contains("mcp"), "{text}");
1101        let err = enabled(|c| c.required_scope = Some("mcp:read mcp:write".into()))
1102            .resolve(WIKI)
1103            .unwrap_err();
1104        assert_eq!(
1105            err.problems,
1106            [
1107                "mcp.oauth.required_scope \"mcp:read mcp:write\" must be a single scope (no \
1108              spaces) — scopes are matched one token at a time"
1109            ]
1110        );
1111        let err = enabled(|c| c.leeway_secs = 3600).resolve(WIKI).unwrap_err();
1112        assert_eq!(
1113            err.to_string(),
1114            "mcp.oauth.enabled is true but the OAuth config is not usable:\n  - \
1115             mcp.oauth.leeway_secs 3600 is over the 300-second cap — leeway is for clock \
1116             drift, not for extending token lifetimes\nFix these, or set mcp.oauth.enabled: \
1117             false."
1118        );
1119    }
1120
1121    #[test]
1122    fn env_naming_uppercases_and_appends_the_field() {
1123        let naming = KeyNaming::Env("MYAPP_OAUTH_");
1124        assert_eq!(naming.key("issuer"), "MYAPP_OAUTH_ISSUER");
1125        assert_eq!(naming.key("required_scopes"), "MYAPP_OAUTH_REQUIRED_SCOPES");
1126        assert_eq!(naming.section(), "MYAPP_OAUTH_*");
1127        assert_eq!(
1128            KeyNaming::Dotted("mcp.oauth").key("issuer"),
1129            "mcp.oauth.issuer"
1130        );
1131        assert_eq!(KeyNaming::Dotted("").key("issuer"), "issuer");
1132        assert_eq!(KeyNaming::Dotted("").section(), "OAuth config");
1133        assert_eq!(KeyNaming::Dotted("mcp.oauth").section(), "mcp.oauth");
1134
1135        let cfg = OAuthConfig {
1136            enabled: true,
1137            required_scope: Some(String::new()),
1138            ..OAuthConfig::default()
1139        };
1140        let err = cfg.resolve(naming).unwrap_err();
1141        let text = err.to_string();
1142        for key in [
1143            "MYAPP_OAUTH_ISSUER",
1144            "MYAPP_OAUTH_RESOURCE",
1145            "MYAPP_OAUTH_AUDIENCE (or MYAPP_OAUTH_AUDIENCES)",
1146            "MYAPP_OAUTH_REQUIRED_SCOPE must not be empty",
1147        ] {
1148            assert!(text.contains(key), "{key} missing from: {text}");
1149        }
1150        assert!(!text.contains("mcp.oauth"), "{text}");
1151        assert!(
1152            text.starts_with(
1153                "OAuth is configured through MYAPP_OAUTH_* but the config is not \
1154                 usable:\n  - "
1155            ),
1156            "{text}"
1157        );
1158        assert!(
1159            text.ends_with("\nFix these, or unset every MYAPP_OAUTH_* variable."),
1160            "{text}"
1161        );
1162        assert_eq!(err.naming(), naming);
1163    }
1164
1165    #[test]
1166    fn accepts_audiences_without_audience_and_an_omitted_or_blank_jwks_uri() {
1167        for jwks_uri in [None, Some(String::new()), Some("  ".to_string())] {
1168            let oauth = enabled(|c| {
1169                c.audience = String::new();
1170                c.audiences = vec!["https://kb.example.test/mcp".into()];
1171                c.jwks_uri = jwks_uri.clone();
1172            })
1173            .resolve(WIKI)
1174            .unwrap()
1175            .unwrap();
1176            assert_eq!(oauth.accepted_audiences(), ["https://kb.example.test/mcp"]);
1177            assert_eq!(oauth.jwks_uri, None, "blank means discover: {jwks_uri:?}");
1178        }
1179    }
1180
1181    #[test]
1182    fn refuses_hmac_and_none_algorithms() {
1183        for alg in ["HS256", "none"] {
1184            let err = resolve_err(enabled(|c| {
1185                c.algorithms = vec!["RS256".into(), alg.into()];
1186            }));
1187            assert!(err.contains("mcp.oauth.algorithms"), "{err}");
1188            assert!(err.contains(alg), "{err}");
1189        }
1190        let err = resolve_err(enabled(|c| c.algorithms = vec![]));
1191        assert!(err.contains("at least one algorithm"), "{err}");
1192    }
1193
1194    #[test]
1195    fn refuses_malformed_urls_naming_the_key() {
1196        for jwks_uri in ["idp.example.test/jwks", "ftp://idp.example.test/jwks"] {
1197            let err = resolve_err(enabled(|c| c.jwks_uri = Some(jwks_uri.into())));
1198            assert!(err.contains("mcp.oauth.jwks_uri"), "{err}");
1199        }
1200        let err = resolve_err(enabled(|c| {
1201            c.issuer = "https://idp.example.test/?x=1".into();
1202            c.resource = "kb.example.test/mcp".into();
1203        }));
1204        assert!(err.contains("mcp.oauth.issuer"), "{err}");
1205        assert!(err.contains("mcp.oauth.resource"), "{err}");
1206        let err = resolve_err(enabled(|c| c.issuer = " https://idp.example.test/".into()));
1207        assert!(err.contains("leading/trailing whitespace"), "{err}");
1208    }
1209
1210    #[test]
1211    fn refuses_bad_scope_and_leeway_settings() {
1212        type Edit = fn(&mut OAuthConfig);
1213        let cases: [(Edit, &str); 5] = [
1214            (
1215                |c| c.required_scope = Some("mcp:read mcp:write".into()),
1216                "single scope",
1217            ),
1218            (|c| c.scope_claims = vec![], "mcp.oauth.scope_claims"),
1219            (
1220                |c| c.principal_claims = vec![String::new()],
1221                "mcp.oauth.principal_claims",
1222            ),
1223            (|c| c.leeway_secs = 3600, "mcp.oauth.leeway_secs"),
1224            (|c| c.audiences = vec![String::new()], "mcp.oauth.audiences"),
1225        ];
1226        for (edit, needle) in cases {
1227            let err = resolve_err(enabled(edit));
1228            assert!(err.contains(needle), "{needle} not in: {err}");
1229        }
1230    }
1231
1232    #[test]
1233    fn a_blank_required_scope_is_rejected_not_treated_as_absent() {
1234        for blank in ["", "   "] {
1235            let err = resolve_err(enabled(|c| c.required_scope = Some(blank.into())));
1236            assert!(
1237                err.contains("mcp.oauth.required_scope"),
1238                "an empty required scope must not silently mean 'no scope': {err}"
1239            );
1240        }
1241    }
1242
1243    #[test]
1244    fn every_problem_is_reported_at_once() {
1245        let err = enabled(|c| {
1246            c.issuer = String::new();
1247            c.required_scope = Some(String::new());
1248            c.required_scopes = vec!["a b".into()];
1249            c.leeway_secs = 9999;
1250            c.algorithms = vec!["HS256".into()];
1251        })
1252        .resolve(WIKI)
1253        .unwrap_err();
1254        assert_eq!(err.problems.len(), 5, "{err}");
1255    }
1256
1257    // ── required_scopes ──────────────────────────────────────────────────────
1258
1259    #[test]
1260    fn required_scopes_are_a_trimmed_deduplicated_order_stable_union() {
1261        let oauth = enabled(|c| {
1262            c.required_scope = Some(" a ".into());
1263            c.required_scopes = vec!["b".into(), "a".into(), " c".into(), "b".into()];
1264        })
1265        .resolve(WIKI)
1266        .unwrap()
1267        .unwrap();
1268        assert_eq!(oauth.required_scopes, ["a", "b", "c"]);
1269
1270        let only_list = enabled(|c| c.required_scopes = vec!["x".into(), "y".into()])
1271            .resolve(WIKI)
1272            .unwrap()
1273            .unwrap();
1274        assert_eq!(only_list.required_scopes, ["x", "y"]);
1275
1276        let only_single = enabled(|c| c.required_scope = Some("mcp:read".into()))
1277            .resolve(WIKI)
1278            .unwrap()
1279            .unwrap();
1280        assert_eq!(only_single.required_scopes, ["mcp:read"]);
1281    }
1282
1283    #[test]
1284    fn an_empty_required_scope_union_is_valid_and_means_no_scope_check() {
1285        let oauth = enabled(|_| {}).resolve(WIKI).unwrap().unwrap();
1286        assert!(oauth.required_scopes.is_empty());
1287    }
1288
1289    #[test]
1290    fn required_scopes_entries_follow_the_single_scope_rules() {
1291        let err = enabled(|c| c.required_scopes = vec!["ok".into(), "  ".into()])
1292            .resolve(WIKI)
1293            .unwrap_err();
1294        assert_eq!(err.problems.len(), 1, "{err}");
1295        assert!(
1296            err.problems[0].starts_with("mcp.oauth.required_scopes contains an empty entry"),
1297            "{err}"
1298        );
1299        let err = enabled(|c| c.required_scopes = vec!["a b".into()])
1300            .resolve(WIKI)
1301            .unwrap_err();
1302        assert!(
1303            err.problems[0]
1304                .starts_with("mcp.oauth.required_scopes entry \"a b\" must be a single scope"),
1305            "{err}"
1306        );
1307    }
1308
1309    // ── serde ────────────────────────────────────────────────────────────────
1310
1311    #[cfg(feature = "serde")]
1312    #[test]
1313    fn round_trips_from_yaml_with_every_default() {
1314        let yaml = "enabled: true
1315issuer: \"https://authentik.example.test/application/o/example-app/\"
1316jwks_uri: \"https://authentik.example.test/application/o/example-app/jwks/\"
1317audience: \"some-client-id\"
1318resource: \"https://kb.example.test/mcp\"
1319";
1320        let parsed: OAuthConfig = serde_yaml_ng::from_str(yaml).unwrap();
1321        assert_eq!(
1322            parsed,
1323            OAuthConfig {
1324                enabled: true,
1325                issuer: "https://authentik.example.test/application/o/example-app/".into(),
1326                jwks_uri: Some(
1327                    "https://authentik.example.test/application/o/example-app/jwks/".into()
1328                ),
1329                audience: "some-client-id".into(),
1330                resource: "https://kb.example.test/mcp".into(),
1331                ..OAuthConfig::default()
1332            }
1333        );
1334        let back: OAuthConfig =
1335            serde_yaml_ng::from_str(&serde_yaml_ng::to_string(&parsed).unwrap()).unwrap();
1336        assert_eq!(back, parsed);
1337        // An empty document is the default config.
1338        let empty: OAuthConfig = serde_yaml_ng::from_str("{}").unwrap();
1339        assert_eq!(empty, OAuthConfig::default());
1340    }
1341
1342    #[cfg(feature = "serde")]
1343    #[test]
1344    fn serde_reads_both_scope_keys_and_refuses_unknown_keys() {
1345        let parsed: OAuthConfig =
1346            serde_yaml_ng::from_str("required_scope: \"a\"\nrequired_scopes: [\"b\", \"c\"]\n")
1347                .unwrap();
1348        assert_eq!(parsed.required_scope.as_deref(), Some("a"));
1349        assert_eq!(parsed.required_scopes, ["b", "c"]);
1350        // An explicit empty string is Some(""), which resolve refuses.
1351        let parsed: OAuthConfig = serde_yaml_ng::from_str("required_scope: \"\"\n").unwrap();
1352        assert_eq!(parsed.required_scope.as_deref(), Some(""));
1353        assert!(serde_yaml_ng::from_str::<OAuthConfig>("bogus: true\n").is_err());
1354        assert!(serde_yaml_ng::from_str::<OAuthConfig>("resource_name: \"x\"\n").is_err());
1355        let parsed: OAuthConfig =
1356            serde_yaml_ng::from_str("allow_unscoped_tokens: true\nallow_insecure_http: true\n")
1357                .unwrap();
1358        assert!(parsed.allow_unscoped_tokens && parsed.allow_insecure_http);
1359    }
1360
1361    #[cfg(feature = "serde")]
1362    #[test]
1363    fn serde_distinguishes_an_omitted_scopes_supported_from_an_explicit_empty_list() {
1364        let omitted: OAuthConfig = serde_yaml_ng::from_str("enabled: true\n").unwrap();
1365        assert_eq!(omitted.scopes_supported, None);
1366        let empty: OAuthConfig = serde_yaml_ng::from_str("scopes_supported: []\n").unwrap();
1367        assert_eq!(empty.scopes_supported, Some(vec![]));
1368        let listed: OAuthConfig =
1369            serde_yaml_ng::from_str("scopes_supported: [\"a\", \"b\"]\n").unwrap();
1370        assert_eq!(listed.scopes_supported, Some(vec!["a".into(), "b".into()]));
1371        // Both survive a round trip: `None` is skipped on output (so it reads back
1372        // as omitted), and an explicit `[]` is written out.
1373        for cfg in [omitted, empty, listed] {
1374            let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
1375            let back: OAuthConfig = serde_yaml_ng::from_str(&yaml).unwrap();
1376            assert_eq!(back, cfg, "{yaml}");
1377        }
1378    }
1379
1380    #[test]
1381    fn an_omitted_scopes_supported_advertises_the_required_scopes_and_an_explicit_list_wins() {
1382        let with_required = |required: Option<&str>, scopes: Option<Vec<String>>| {
1383            enabled(|c| {
1384                c.required_scope = required.map(str::to_string);
1385                c.required_scopes = vec!["b".into(), "a".into()];
1386                c.scopes_supported = scopes;
1387            })
1388            .resolve(WIKI)
1389            .unwrap()
1390            .expect("enabled")
1391            .scopes_supported
1392        };
1393        // Omitted: the required scopes, in their resolved order.
1394        assert_eq!(with_required(Some("a"), None), ["a", "b"]);
1395        assert_eq!(with_required(None, None), ["b", "a"]);
1396        // An explicit list, even an empty one, is kept as written (trimmed).
1397        assert!(with_required(Some("a"), Some(vec![])).is_empty());
1398        assert_eq!(with_required(Some("a"), Some(vec![" c ".into()])), ["c"]);
1399
1400        // Nothing required: an omitted list advertises nothing.
1401        let resolve = |scopes: Option<Vec<String>>| {
1402            enabled(|c| c.scopes_supported = scopes)
1403                .resolve(WIKI)
1404                .unwrap()
1405                .expect("enabled")
1406                .scopes_supported
1407        };
1408        assert!(resolve(None).is_empty());
1409        assert!(resolve(Some(vec![])).is_empty());
1410        assert_eq!(
1411            resolve(Some(vec!["mcp:read".into(), "mcp:write".into()])),
1412            ["mcp:read", "mcp:write"]
1413        );
1414        // The application-default pattern: fill an omitted key, leave an explicit
1415        // empty list alone.
1416        let app_default = |mut c: OAuthConfig| {
1417            c.scopes_supported
1418                .get_or_insert_with(|| vec!["mcp:read".into(), "mcp:write".into()]);
1419            c.resolve(WIKI).unwrap().expect("enabled").scopes_supported
1420        };
1421        assert_eq!(
1422            app_default(enabled(|c| c.scopes_supported = None)),
1423            ["mcp:read", "mcp:write"]
1424        );
1425        assert!(app_default(enabled(|c| c.scopes_supported = Some(vec![]))).is_empty());
1426    }
1427
1428    // ── scope-token syntax (RFC 6749 §3.3) ───────────────────────────────────
1429
1430    #[test]
1431    fn every_required_and_advertised_scope_must_be_a_scope_token() {
1432        for bad in ["a\"b", "a\\b", "caf\u{e9}", "a\u{7f}"] {
1433            let err = enabled(|c| c.required_scope = Some(bad.into()))
1434                .resolve(WIKI)
1435                .unwrap_err();
1436            assert_eq!(err.problems.len(), 1, "{err}");
1437            assert!(
1438                err.problems[0].starts_with("mcp.oauth.required_scope ")
1439                    && err.problems[0].contains("is not a valid scope"),
1440                "{err}"
1441            );
1442            let err = enabled(|c| c.required_scopes = vec!["ok".into(), bad.into()])
1443                .resolve(WIKI)
1444                .unwrap_err();
1445            assert!(
1446                err.problems[0].starts_with("mcp.oauth.required_scopes entry "),
1447                "{err}"
1448            );
1449            let err = enabled(|c| c.scopes_supported = Some(vec![bad.into()]))
1450                .resolve(WIKI)
1451                .unwrap_err();
1452            assert!(
1453                err.problems[0].starts_with("mcp.oauth.scopes_supported entry "),
1454                "{err}"
1455            );
1456        }
1457        // Blank and multi-word advertised entries are refused too.
1458        let err = enabled(|c| c.scopes_supported = Some(vec!["".into(), "two words".into()]))
1459            .resolve(WIKI)
1460            .unwrap_err();
1461        assert_eq!(err.problems.len(), 2, "{err}");
1462        assert_eq!(
1463            err.problems[0],
1464            "mcp.oauth.scopes_supported contains an empty entry"
1465        );
1466        assert!(err.problems[1].contains("\"two words\""), "{err}");
1467        // Every visible-ASCII scope-token character is fine, including the
1468        // `:`, `/`, `.` and `!` real scopes use.
1469        let oauth = enabled(|c| {
1470            c.required_scope =
1471                Some("https://api.example.test/things.read!#$%&'()*+,-./:;<=>?@[]^_`{|}~".into());
1472        })
1473        .resolve(WIKI)
1474        .unwrap()
1475        .unwrap();
1476        assert_eq!(oauth.required_scopes.len(), 1);
1477    }
1478
1479    // ── URL characters, and plain http ───────────────────────────────────────
1480
1481    #[test]
1482    fn a_url_with_a_space_control_or_non_ascii_character_is_refused() {
1483        // `url` would silently drop the tab/newline and percent-encode the rest,
1484        // but the raw string is what reaches every challenge header.
1485        for bad in [
1486            "https://kb.example.test/m\ncp",
1487            "https://kb.example.test/m\tcp",
1488            "https://kb.example.test/m cp",
1489            "https://kb.example.test/caf\u{e9}",
1490        ] {
1491            let err = resolve_err(enabled(|c| c.resource = bad.into()));
1492            assert!(
1493                err.contains("mcp.oauth.resource") && err.contains("percent-encoded"),
1494                "{bad:?}: {err}"
1495            );
1496            let err = resolve_err(enabled(|c| c.issuer = bad.into()));
1497            assert!(err.contains("mcp.oauth.issuer"), "{bad:?}: {err}");
1498            let err = resolve_err(enabled(|c| c.jwks_uri = Some(bad.into())));
1499            assert!(err.contains("mcp.oauth.jwks_uri"), "{bad:?}: {err}");
1500        }
1501        // Percent-encoded is fine, and so is a query on the jwks_uri.
1502        enabled(|c| {
1503            c.resource = "https://kb.example.test/caf%C3%A9".into();
1504            c.jwks_uri = Some("https://idp.example.test/keys?tenant=a".into());
1505        })
1506        .resolve(WIKI)
1507        .unwrap()
1508        .unwrap();
1509    }
1510
1511    #[test]
1512    fn plain_http_off_loopback_is_refused_unless_explicitly_allowed() {
1513        let err = enabled(|c| {
1514            c.issuer = "http://idp.internal.test/app/".into();
1515            c.jwks_uri = Some("http://idp.internal.test/app/jwks/".into());
1516            c.resource = "http://kb.internal.test/mcp".into();
1517        })
1518        .resolve(WIKI)
1519        .unwrap_err();
1520        assert_eq!(err.problems.len(), 3, "{err}");
1521        assert!(
1522            err.problems[0].starts_with(
1523                "mcp.oauth.issuer \"http://idp.internal.test/app/\" uses plain http on a \
1524                 non-loopback host — signing keys"
1525            ),
1526            "{err}"
1527        );
1528        assert!(err.problems[0].contains("RFC 8414 §2"), "{err}");
1529        assert!(err.problems[1].starts_with("mcp.oauth.resource "), "{err}");
1530        assert!(err.problems[1].contains("RFC 9728 §1.2"), "{err}");
1531        assert!(err.problems[2].starts_with("mcp.oauth.jwks_uri "), "{err}");
1532        assert!(
1533            err.problems[2].contains("set mcp.oauth.allow_insecure_http"),
1534            "{err}"
1535        );
1536
1537        // The explicit opt-in accepts all three.
1538        let oauth = enabled(|c| {
1539            c.issuer = "http://idp.internal.test/app/".into();
1540            c.jwks_uri = Some("http://idp.internal.test/app/jwks/".into());
1541            c.resource = "http://kb.internal.test/mcp".into();
1542            c.allow_insecure_http = true;
1543        })
1544        .resolve(WIKI)
1545        .unwrap()
1546        .unwrap();
1547        assert!(oauth.allow_insecure_http);
1548
1549        // Loopback never needs it.
1550        enabled(|c| {
1551            c.issuer = "http://127.0.0.1:9000/app/".into();
1552            c.jwks_uri = Some("http://[::1]:9000/jwks".into());
1553            c.resource = "http://localhost:8001/mcp".into();
1554        })
1555        .resolve(WIKI)
1556        .unwrap()
1557        .unwrap();
1558    }
1559
1560    // ── the unscoped posture ─────────────────────────────────────────────────
1561
1562    #[test]
1563    fn no_required_scope_and_no_typ_check_is_refused_unless_explicitly_allowed() {
1564        let bare = |edit: fn(&mut OAuthConfig)| {
1565            let mut cfg = enabled(|c| c.allow_unscoped_tokens = false);
1566            edit(&mut cfg);
1567            cfg.resolve(WIKI)
1568        };
1569        let err = bare(|_| {}).unwrap_err();
1570        assert_eq!(err.problems.len(), 1, "{err}");
1571        assert_eq!(
1572            err.problems[0],
1573            "no required scope is configured (mcp.oauth.required_scope and \
1574             mcp.oauth.required_scopes are unset) and mcp.oauth.require_at_jwt is off — \
1575             nothing would tell an access token from an OIDC ID token minted for the same \
1576             client, so any token this issuer signs for the audience would be accepted. Set \
1577             mcp.oauth.required_scope to a scope only access tokens carry, turn on \
1578             mcp.oauth.require_at_jwt if the authorization server emits typ at+jwt, or set \
1579             mcp.oauth.allow_unscoped_tokens to accept that"
1580        );
1581        // Any one of the three is enough.
1582        assert!(bare(|c| c.required_scope = Some("a".into())).is_ok());
1583        assert!(bare(|c| c.required_scopes = vec!["a".into()]).is_ok());
1584        assert!(bare(|c| c.require_at_jwt = true).is_ok());
1585        let allowed = bare(|c| c.allow_unscoped_tokens = true).unwrap().unwrap();
1586        assert!(allowed.required_scopes.is_empty());
1587        assert!(allowed.allow_unscoped_tokens);
1588        // A blank scope is its own problem, not "unscoped" as well.
1589        let err = bare(|c| c.required_scope = Some(String::new())).unwrap_err();
1590        assert_eq!(err.problems.len(), 1, "{err}");
1591        // Named per the key naming.
1592        let err = OAuthConfig {
1593            enabled: true,
1594            ..OAuthConfig::default()
1595        }
1596        .resolve(KeyNaming::Env("APP_OAUTH_"))
1597        .unwrap_err();
1598        assert!(
1599            err.problems
1600                .iter()
1601                .any(|p| p.contains("set APP_OAUTH_ALLOW_UNSCOPED_TOKENS")),
1602            "{err}"
1603        );
1604    }
1605}