Skip to main content

oauth_resource_server/
env.rs

1//! Loading secrets and the OAuth config from environment variables (with
2//! `VAR_FILE` support).
3//!
4//! [`secret_from_env`] reads one value, accepting either `VAR` directly or a
5//! path in `VAR_FILE` (the shape Docker Compose `secrets:` mounts use).
6//! [`static_tokens_from_env`] reads a static API key and, during a rotation,
7//! its replacement from `<VAR>_NEXT`, into a [`StaticTokens`] set.
8//! [`oauth_config_from_env`] builds a whole [`OAuthConfig`] the same way, one
9//! field per `<PREFIX><FIELD>` variable, and [`OAuthConfig::resolve`]s it.
10//!
11//! Every function here has a `_lookup` twin that takes the variable lookup and
12//! file reader as arguments instead of calling `std::env::var` and reading
13//! the file directly, so tests can exercise every case without mutating the
14//! process environment — `std::env::set_var` is `unsafe` as of the 2024
15//! edition.
16//!
17//! A `_FILE` is read only when it is a regular file, and at most
18//! [`MAX_SECRET_FILE_BYTES`] (64 KiB) of it: `X_FILE=/dev/zero` or a
19//! directory is refused at once ([`EnvError::NotAFile`],
20//! [`EnvError::FileTooLarge`]) rather than exhausting memory or blocking
21//! startup, as a FIFO nobody writes to would.
22
23use std::io::{self, Read};
24
25use zeroize::Zeroizing;
26
27use crate::authenticate::StaticTokens;
28use crate::config::{
29    ConfigError, ConfigProblem, KeyNaming, OAuthConfig, ProblemKind, ResolvedOAuthConfig,
30};
31
32/// A problem loading a secret from the environment.
33///
34/// `Debug` and `Display` never include a secret's *value* — only variable
35/// names and file paths, neither of which is secret itself.
36#[derive(Debug, thiserror::Error)]
37#[non_exhaustive]
38pub enum EnvError {
39    /// `var` and `<var>_FILE` were both set. Ambiguous on purpose: silently
40    /// preferring one would hide the other from an operator who set both by
41    /// mistake.
42    #[error("{var} and {var}_FILE are both set (file: {path}) — set exactly one, not both")]
43    #[non_exhaustive]
44    BothSet {
45        /// The plain variable name (already fully qualified, e.g. with its
46        /// `oauth_config_from_env` prefix applied).
47        var: String,
48        /// The path named by `<var>_FILE`, not the file's contents.
49        path: String,
50    },
51    /// `<var>_FILE` named a file that could not be read.
52    ///
53    /// `Display` names the variable and path only; the I/O failure is the
54    /// error's [`source`](std::error::Error::source), not part of its text, so
55    /// a reporter that walks the source chain (`anyhow`, for one) prints the
56    /// cause once rather than twice. [`oauth_config_from_env`] appends it to
57    /// the problem text itself.
58    #[error("{var}_FILE={path}: failed to read secret file")]
59    #[non_exhaustive]
60    ReadFailed {
61        /// The plain variable name.
62        var: String,
63        /// The path that failed to read.
64        path: String,
65        /// The underlying I/O failure.
66        #[source]
67        source: io::Error,
68    },
69    /// `<var>_FILE` names something other than a regular file — a directory,
70    /// a FIFO, a device such as `/dev/zero` — which is refused before it is
71    /// opened: reading one could block startup forever or never end.
72    #[error("{var}_FILE={path}: not a regular file")]
73    #[non_exhaustive]
74    NotAFile {
75        /// The plain variable name.
76        var: String,
77        /// The path named by `<var>_FILE`.
78        path: String,
79    },
80    /// The file named by `<var>_FILE` is larger than
81    /// [`MAX_SECRET_FILE_BYTES`] (64 KiB). No secret or config value is
82    /// anywhere near that size; such a file is the wrong file. At most one
83    /// byte past the limit is read.
84    #[error("{var}_FILE={path}: secret file is over the 65536-byte limit")]
85    #[non_exhaustive]
86    FileTooLarge {
87        /// The plain variable name.
88        var: String,
89        /// The path named by `<var>_FILE`.
90        path: String,
91    },
92    /// The file named by `<var>_FILE` was read successfully but was empty (or
93    /// all whitespace). Unlike a blank `VAR`, this is an error rather than
94    /// "unset": a secrets-mount file that exists but is empty is far more
95    /// likely a provisioning mistake than an intentional absence.
96    #[error("{var}_FILE={path}: secret file is empty")]
97    #[non_exhaustive]
98    EmptyFile {
99        /// The plain variable name.
100        var: String,
101        /// The path that was empty.
102        path: String,
103    },
104    /// [`static_tokens_from_env`]: `<var>_NEXT` (or `<var>_NEXT_FILE`) is set
105    /// but `<var>` (and `<var>_FILE`) is not. A next key with no current one
106    /// is a half-done rotation — promote the next key into `<var>` — and is
107    /// refused rather than read as the only key.
108    #[error("{var}_NEXT is set but {var} is not: set the current key in {var} (or {var}_FILE)")]
109    #[non_exhaustive]
110    NextWithoutCurrent {
111        /// The plain (current-key) variable name.
112        var: String,
113    },
114    /// Several variables failed to load at once (from
115    /// [`static_tokens_from_env`], the current and the next key both), so
116    /// every problem is reported in one run. `Display` joins theirs with
117    /// `"; "`.
118    #[error("{}", join_errors(errors))]
119    #[non_exhaustive]
120    Several {
121        /// Every failure, in the order the variables were read.
122        errors: Vec<EnvError>,
123    },
124}
125
126/// The first claim name the top-level JSON object `json` (already known to
127/// parse as one) holds more than once, read as `(name, value)` pairs.
128fn duplicated_claim(json: &str) -> Option<String> {
129    let pairs: Vec<(String, serde::de::IgnoredAny)> = serde_json::from_str::<Pairs>(json).ok()?.0;
130    let mut seen = std::collections::HashSet::new();
131    pairs
132        .into_iter()
133        .map(|(name, _)| name)
134        .find(|name| !seen.insert(name.clone()))
135}
136
137/// A JSON object read as its `(key, value)` pairs in order, duplicates kept.
138struct Pairs(Vec<(String, serde::de::IgnoredAny)>);
139
140impl<'de> serde::Deserialize<'de> for Pairs {
141    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
142        struct Visit;
143        impl<'de> serde::de::Visitor<'de> for Visit {
144            type Value = Pairs;
145            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146                f.write_str("a JSON object")
147            }
148            fn visit_map<A: serde::de::MapAccess<'de>>(
149                self,
150                mut map: A,
151            ) -> Result<Pairs, A::Error> {
152                let mut pairs = Vec::new();
153                while let Some(pair) = map.next_entry()? {
154                    pairs.push(pair);
155                }
156                Ok(Pairs(pairs))
157            }
158        }
159        deserializer.deserialize_map(Visit)
160    }
161}
162
163/// The largest file a `<VAR>_FILE` variable may name: 64 KiB. A bigger one
164/// is [`EnvError::FileTooLarge`], with at most one byte past this read.
165pub const MAX_SECRET_FILE_BYTES: usize = 64 * 1024;
166
167/// Why [`read_secret_file`] refused a file before or while reading it,
168/// carried inside the `io::Error` so the `_lookup` functions' reader
169/// signature stays `Fn(&str) -> io::Result<String>`.
170#[derive(Debug, thiserror::Error)]
171enum FileRefused {
172    #[error("not a regular file")]
173    NotAFile,
174    #[error("over the size limit")]
175    TooLarge,
176}
177
178/// The file reader every `_env` function uses: a regular file only (checked
179/// on the path before opening — opening a FIFO blocks until a writer comes —
180/// and again on the open file), at most [`MAX_SECRET_FILE_BYTES`] + 1 bytes
181/// of it, UTF-8. The bytes read are wiped once copied into the `String`.
182fn read_secret_file(path: &str) -> io::Result<String> {
183    let refused = |why| io::Error::new(io::ErrorKind::InvalidInput, why);
184    if !std::fs::metadata(path)?.is_file() {
185        return Err(refused(FileRefused::NotAFile));
186    }
187    let file = std::fs::File::open(path)?;
188    if !file.metadata()?.is_file() {
189        return Err(refused(FileRefused::NotAFile));
190    }
191    let mut bytes = Zeroizing::new(Vec::new());
192    file.take(MAX_SECRET_FILE_BYTES as u64 + 1)
193        .read_to_end(&mut bytes)?;
194    if bytes.len() > MAX_SECRET_FILE_BYTES {
195        return Err(refused(FileRefused::TooLarge));
196    }
197    std::str::from_utf8(&bytes).map(str::to_owned).map_err(|_| {
198        io::Error::new(
199            io::ErrorKind::InvalidData,
200            "stream did not contain valid UTF-8",
201        )
202    })
203}
204
205/// The [`EnvError`] for a failed `<var>_FILE` read: [`EnvError::NotAFile`]
206/// or [`EnvError::FileTooLarge`] for [`read_secret_file`]'s own refusals,
207/// [`EnvError::ReadFailed`] for anything else.
208fn file_error(var: &str, path: String, source: io::Error) -> EnvError {
209    let var = var.to_string();
210    match source
211        .get_ref()
212        .and_then(|e| e.downcast_ref::<FileRefused>())
213    {
214        Some(FileRefused::NotAFile) => EnvError::NotAFile { var, path },
215        Some(FileRefused::TooLarge) => EnvError::FileTooLarge { var, path },
216        None => EnvError::ReadFailed { var, path, source },
217    }
218}
219
220fn join_errors(errors: &[EnvError]) -> String {
221    // Each with its source chain: `Several` has no single `source` to carry
222    // a `ReadFailed`'s I/O cause.
223    errors.iter().map(error_text).collect::<Vec<_>>().join("; ")
224}
225
226/// The label [`static_tokens_from_env`] gives the key read from `<VAR>`.
227pub const CURRENT_KEY_LABEL: &str = "current";
228/// The label [`static_tokens_from_env`] gives the key read from `<VAR>_NEXT`.
229pub const NEXT_KEY_LABEL: &str = "next";
230
231/// Read a secret from `var`, falling back to the file named by `<var>_FILE`.
232///
233/// Docker Compose `secrets:` mounts a file rather than setting a variable, so
234/// each secret accepts both forms. Setting **both** is rejected rather than
235/// silently preferring one, so a misconfigured deployment is told which value
236/// would have won.
237///
238/// Values are trimmed. This matters for the file form (a secret file written
239/// by any ordinary means ends with a trailing newline, which would otherwise
240/// become part of the value) and is applied to the direct form too so the two
241/// paths cannot disagree. `VAR` empty, or empty after trimming, is treated as
242/// unset (`Ok(None)`) — but a `VAR_FILE` that reads successfully and comes out
243/// empty after trimming is an error ([`EnvError::EmptyFile`]), since a secrets
244/// file existing but empty is a provisioning mistake, not an absence.
245///
246/// This is [`secret_from_lookup`] wired to the real process environment and
247/// filesystem; use that directly to test callers without mutating either. A
248/// variable whose value is not valid Unicode reads as unset.
249///
250/// # Errors
251///
252/// - [`EnvError::BothSet`] when `var` and `<var>_FILE` are both set.
253/// - [`EnvError::NotAFile`] when `<var>_FILE` names something other than a
254///   regular file (a directory, a FIFO, `/dev/zero`).
255/// - [`EnvError::FileTooLarge`] when that file is over
256///   [`MAX_SECRET_FILE_BYTES`].
257/// - [`EnvError::ReadFailed`] when the file named by `<var>_FILE` cannot be
258///   read.
259/// - [`EnvError::EmptyFile`] when that file is empty after trimming.
260///
261/// # Security
262///
263/// The value is returned, never logged, and no error includes it: errors name
264/// the variable and the file path only.
265///
266/// # Examples
267///
268/// ```no_run
269/// use oauth_resource_server::env::secret_from_env;
270///
271/// // MYAPP_API_KEY=..., or MYAPP_API_KEY_FILE=/run/secrets/api_key
272/// match secret_from_env("MYAPP_API_KEY") {
273///     Ok(Some(_key)) => println!("static API key configured"),
274///     Ok(None) => println!("no static API key"),
275///     Err(e) => eprintln!("MYAPP_API_KEY: {e}"),
276/// }
277/// ```
278pub fn secret_from_env(var: &str) -> Result<Option<String>, EnvError> {
279    secret_from_lookup(var, |v| std::env::var(v).ok(), read_secret_file)
280}
281
282/// [`secret_from_env`] with the variable lookup and file reader injected, so
283/// tests can exercise every case (both set, an unreadable file, an empty
284/// file, trimming, absence) without calling the `unsafe`
285/// `std::env::set_var` or touching the real filesystem.
286///
287/// `lookup` returns a variable's value (or `None` when unset); `read_file`
288/// reads the file at a path (what it returns is held to
289/// [`MAX_SECRET_FILE_BYTES`] as a real file is). `read_file` is called only when `<var>_FILE` is
290/// set and `var` is not.
291///
292/// # Errors
293///
294/// As [`secret_from_env`].
295///
296/// # Examples
297///
298/// ```
299/// use std::collections::HashMap;
300/// use std::io;
301///
302/// use oauth_resource_server::env::{EnvError, secret_from_lookup};
303///
304/// let vars = HashMap::from([("MYAPP_API_KEY_FILE", "/run/secrets/api_key")]);
305/// let lookup = |name: &str| vars.get(name).map(|v| v.to_string());
306/// let read_file = |path: &str| match path {
307///     "/run/secrets/api_key" => Ok("s3cret\n".to_string()),
308///     _ => Err(io::Error::from(io::ErrorKind::NotFound)),
309/// };
310///
311/// // The file form, trimmed.
312/// let key = secret_from_lookup("MYAPP_API_KEY", &lookup, &read_file).unwrap();
313/// assert_eq!(key.as_deref(), Some("s3cret"));
314///
315/// // Unset entirely.
316/// assert_eq!(secret_from_lookup("MYAPP_OTHER", &lookup, &read_file).unwrap(), None);
317///
318/// // Both forms set: an error, not a silent preference.
319/// let both = HashMap::from([("K", "a"), ("K_FILE", "/x")]);
320/// let err = secret_from_lookup("K", |n| both.get(n).map(|v| v.to_string()), &read_file);
321/// assert!(matches!(err, Err(EnvError::BothSet { .. })));
322/// ```
323pub fn secret_from_lookup(
324    var: &str,
325    lookup: impl Fn(&str) -> Option<String>,
326    read_file: impl Fn(&str) -> io::Result<String>,
327) -> Result<Option<String>, EnvError> {
328    // Moved out of the wiping buffer, not copied: the returned `String` is
329    // the caller's, as it always has been.
330    Ok(secret_zeroizing(var, lookup, read_file)?.map(|mut v| std::mem::take(&mut *v)))
331}
332
333/// [`secret_from_lookup`], keeping the value in a `Zeroizing` buffer. Every
334/// intermediate copy it makes (the untrimmed variable or file contents, a
335/// value dropped because both forms are set) is wiped when dropped.
336fn secret_zeroizing(
337    var: &str,
338    lookup: impl Fn(&str) -> Option<String>,
339    read_file: impl Fn(&str) -> io::Result<String>,
340) -> Result<Option<Zeroizing<String>>, EnvError> {
341    let file_var = format!("{var}_FILE");
342    let direct = lookup(var)
343        .map(Zeroizing::new)
344        .filter(|s| !s.trim().is_empty());
345    let path = lookup(&file_var).filter(|s| !s.trim().is_empty());
346
347    match (direct, path) {
348        (Some(_), Some(path)) => Err(EnvError::BothSet {
349            var: var.to_string(),
350            path,
351        }),
352        (Some(v), None) => Ok(Some(Zeroizing::new(v.trim().to_string()))),
353        (None, Some(path)) => {
354            let raw = Zeroizing::new(
355                read_file(&path).map_err(|source| file_error(var, path.clone(), source))?,
356            );
357            // An injected reader is held to the same cap as the real one.
358            if raw.len() > MAX_SECRET_FILE_BYTES {
359                return Err(EnvError::FileTooLarge {
360                    var: var.to_string(),
361                    path,
362                });
363            }
364            let value = Zeroizing::new(raw.trim().to_string());
365            if value.is_empty() {
366                return Err(EnvError::EmptyFile {
367                    var: var.to_string(),
368                    path,
369                });
370            }
371            Ok(Some(value))
372        }
373        (None, None) => Ok(None),
374    }
375}
376
377/// Read a static API key from `var` and, while one is being rotated in, its
378/// replacement from `<var>_NEXT`, into a [`StaticTokens`] set labeled
379/// [`CURRENT_KEY_LABEL`] (`"current"`) and [`NEXT_KEY_LABEL`] (`"next"`).
380///
381/// Each of the two is read exactly as [`secret_from_env`] reads one secret —
382/// `<var>` or `<var>_FILE`, `<var>_NEXT` or `<var>_NEXT_FILE`; both forms of
383/// one set is an error, values are trimmed, a blank variable is unset, and a
384/// `_FILE` that reads empty is an error. Then:
385///
386/// | `<var>` | `<var>_NEXT` | Result |
387/// |---|---|---|
388/// | unset | unset | `Ok(None)` |
389/// | set | unset | one entry, `"current"` |
390/// | set | set, different | two entries, `"current"` and `"next"` |
391/// | set | set, the same value | one entry, `"current"` (the promotion step of a rotation) |
392/// | unset | set | [`EnvError::NextWithoutCurrent`] |
393///
394/// Zero-downtime rotation, one restart per step: (1) set `<var>_NEXT` to the
395/// new key — both keys are accepted; (2) move every client to the new key;
396/// (3) set `<var>` to the new key and unset `<var>_NEXT` (a restart in
397/// between, with both equal, is fine). The README's "Rotating a static API
398/// key" section walks through it, including how to honour
399/// `accept_static_bearer` with [`crate::static_token_policy`].
400///
401/// Only these two variables: no whitespace-separated list. A list could not
402/// carry labels, so a handler or an audit log could not tell which key was
403/// used, and it would read a secret containing whitespace differently from
404/// [`secret_from_env`]. An application with one key per client builds its
405/// own labeled set with [`StaticTokens::with`].
406///
407/// This is [`static_tokens_from_lookup`] wired to the real process
408/// environment and filesystem.
409///
410/// # Errors
411///
412/// Any [`secret_from_env`] error for either key (when both fail,
413/// [`EnvError::Several`] with both), or [`EnvError::NextWithoutCurrent`].
414///
415/// # Security
416///
417/// No error includes a secret — only variable names and file paths — and the
418/// returned set's `Debug` prints labels only.
419///
420/// # Examples
421///
422/// ```no_run
423/// use oauth_resource_server::env::static_tokens_from_env;
424///
425/// // MYAPP_API_KEY=... (or _FILE), plus MYAPP_API_KEY_NEXT=... during a rotation
426/// match static_tokens_from_env("MYAPP_API_KEY") {
427///     Ok(Some(tokens)) => println!("static API keys: {:?}", tokens.labels().collect::<Vec<_>>()),
428///     Ok(None) => println!("no static API key"),
429///     Err(e) => eprintln!("{e}"),
430/// }
431/// ```
432pub fn static_tokens_from_env(var: &str) -> Result<Option<StaticTokens>, EnvError> {
433    static_tokens_from_lookup(var, |v| std::env::var(v).ok(), read_secret_file)
434}
435
436/// [`static_tokens_from_env`] with the variable lookup and file reader
437/// injected, as [`secret_from_lookup`] takes them, so tests never call the
438/// `unsafe` `std::env::set_var`.
439///
440/// # Errors
441///
442/// As [`static_tokens_from_env`].
443///
444/// # Examples
445///
446/// ```
447/// use std::collections::HashMap;
448/// use std::io;
449///
450/// use oauth_resource_server::env::{EnvError, static_tokens_from_lookup};
451///
452/// let vars = HashMap::from([
453///     ("MYAPP_API_KEY", "example-key-old"),
454///     ("MYAPP_API_KEY_NEXT", "example-key-new"),
455/// ]);
456/// let lookup = |name: &str| vars.get(name).map(|v| v.to_string());
457/// let no_files = |_: &str| Err(io::Error::from(io::ErrorKind::NotFound));
458///
459/// let tokens = static_tokens_from_lookup("MYAPP_API_KEY", &lookup, no_files)
460///     .unwrap()
461///     .unwrap();
462/// assert_eq!(tokens.labels().collect::<Vec<_>>(), [Some("current"), Some("next")]);
463///
464/// // A next key alone is a half-done rotation.
465/// let only_next = HashMap::from([("K_NEXT", "example-key-new")]);
466/// assert!(matches!(
467///     static_tokens_from_lookup("K", |n| only_next.get(n).map(|v| v.to_string()), no_files),
468///     Err(EnvError::NextWithoutCurrent { .. })
469/// ));
470/// ```
471pub fn static_tokens_from_lookup(
472    var: &str,
473    lookup: impl Fn(&str) -> Option<String>,
474    read_file: impl Fn(&str) -> io::Result<String>,
475) -> Result<Option<StaticTokens>, EnvError> {
476    // Every copy here stays in a `Zeroizing` buffer, including a `next`
477    // dropped for equalling `current`, or a value dropped with an error.
478    let current = secret_zeroizing(var, &lookup, &read_file);
479    let next = secret_zeroizing(&format!("{var}_NEXT"), &lookup, &read_file);
480    let (current, next) = match (current, next) {
481        (Ok(current), Ok(next)) => (current, next),
482        (Err(a), Err(b)) => return Err(EnvError::Several { errors: vec![a, b] }),
483        (Err(e), Ok(_)) | (Ok(_), Err(e)) => return Err(e),
484    };
485    match (current, next) {
486        (None, None) => Ok(None),
487        (None, Some(_)) => Err(EnvError::NextWithoutCurrent {
488            var: var.to_string(),
489        }),
490        (Some(current), next) => {
491            let mut tokens = StaticTokens::new();
492            // Both are trimmed and non-empty (`secret_from_lookup`), and the
493            // labels are fixed and valid: nothing `StaticTokens::with` checks
494            // can fail except a repeated secret, which is folded into one
495            // entry here instead (see the table above).
496            tokens.push_checked(CURRENT_KEY_LABEL, current);
497            if let Some(next) = next
498                && !tokens.contains(&next)
499            {
500                tokens.push_checked(NEXT_KEY_LABEL, next);
501            }
502            Ok(Some(tokens))
503        }
504    }
505}
506
507/// Split a whitespace-separated list value (`required_scopes`,
508/// `scopes_supported`, `scope_claims`, `principal_claims`, `algorithms`,
509/// `audiences`, `allowed_client_ids`) into its entries.
510fn split_list(value: &str) -> Vec<String> {
511    value.split_whitespace().map(str::to_string).collect()
512}
513
514/// Parse a strictly-spelled boolean: exactly `"true"` or `"false"` (already
515/// trimmed by [`secret_from_lookup`]), nothing looser — no `1`/`0`, no
516/// case-insensitivity. A typo should fail loudly rather than silently reading
517/// as `false`.
518fn parse_strict_bool(value: &str) -> Result<bool, ()> {
519    match value {
520        "true" => Ok(true),
521        "false" => Ok(false),
522        _ => Err(()),
523    }
524}
525
526/// The problem text for a `bool` variable that is neither `"true"` nor
527/// `"false"`. The value is echoed back: every `bool` setting here is a plain
528/// switch, never secret.
529fn bool_problem(naming: KeyNaming<'_>, field: &str, value: &str) -> ConfigProblem {
530    ConfigProblem::new(
531        ProblemKind::EnvParse,
532        [naming.key(field)],
533        format!(
534            "{} {value:?} must be \"true\" or \"false\"",
535            naming.key(field)
536        ),
537    )
538}
539
540/// The problem text for a failed load: the error's `Display` followed by its
541/// source chain, `": "`-separated. A [`ConfigError`] problem is a flat string
542/// with no source chain of its own, so [`EnvError::ReadFailed`]'s I/O cause —
543/// deliberately left out of its `Display` — is appended here instead.
544fn env_problem(err: &EnvError) -> ConfigProblem {
545    ConfigProblem::new(ProblemKind::EnvLoad, error_keys(err), error_text(err))
546}
547
548/// An error's `Display` followed by its source chain, `": "`-separated.
549fn error_text(err: &EnvError) -> String {
550    let mut text = err.to_string();
551    let mut source = std::error::Error::source(err);
552    while let Some(cause) = source {
553        text.push_str(": ");
554        text.push_str(&cause.to_string());
555        source = cause.source();
556    }
557    text
558}
559
560/// The variables the operator has to look at: both when both are set,
561/// otherwise the `_FILE` whose file could not be used. (The last two
562/// variants come only from [`static_tokens_from_env`], never from the config
563/// loader; they are named here so the match stays exhaustive.)
564fn error_keys(err: &EnvError) -> Vec<String> {
565    match err {
566        EnvError::BothSet { var, .. } => vec![var.clone(), format!("{var}_FILE")],
567        EnvError::ReadFailed { var, .. }
568        | EnvError::EmptyFile { var, .. }
569        | EnvError::NotAFile { var, .. }
570        | EnvError::FileTooLarge { var, .. } => {
571            vec![format!("{var}_FILE")]
572        }
573        EnvError::NextWithoutCurrent { var } => vec![var.clone(), format!("{var}_NEXT")],
574        EnvError::Several { errors } => errors.iter().flat_map(error_keys).collect(),
575    }
576}
577
578/// Record a failed load as a problem and read it as unset, so one bad
579/// variable never stops the rest from being checked.
580fn take(
581    result: Result<Option<String>, EnvError>,
582    problems: &mut Vec<ConfigProblem>,
583) -> Option<String> {
584    result.unwrap_or_else(|e| {
585        problems.push(env_problem(&e));
586        None
587    })
588}
589
590/// The variables that decide whether OAuth is configured at all when
591/// `<PREFIX>ENABLED` is unset: `issuer`, `jwks_uri`, `audience`, `audiences`,
592/// `resource`. A named struct rather than a list of names, so destructuring
593/// it makes the compiler check that every one is applied to the config.
594///
595/// `jwks_uri` counts because it alone is enough to mean "configure OAuth",
596/// even though [`OAuthConfig::resolve`] does not itself require it (an absent
597/// `jwks_uri` means "discover it from `issuer`").
598struct IdentifyingVars {
599    issuer: Result<Option<String>, EnvError>,
600    jwks_uri: Result<Option<String>, EnvError>,
601    audience: Result<Option<String>, EnvError>,
602    audiences: Result<Option<String>, EnvError>,
603    resource: Result<Option<String>, EnvError>,
604}
605
606impl IdentifyingVars {
607    /// Whether any of them was set. "Set" means either a non-empty value
608    /// loaded, or loading it failed (both forms set, or an unreadable/empty
609    /// `_FILE`): an operator who typo'd `ISSUER_FILE`'s path did try to
610    /// configure OAuth, and must be told why it did not load, not silently
611    /// land on "OAuth disabled".
612    fn any_set(&self) -> bool {
613        [
614            &self.issuer,
615            &self.jwks_uri,
616            &self.audience,
617            &self.audiences,
618            &self.resource,
619        ]
620        .into_iter()
621        .any(|r| !matches!(r, Ok(None)))
622    }
623}
624
625/// Load an [`OAuthConfig`] from environment variables named
626/// `<PREFIX><FIELD_UPPER>` (e.g. `MYAPP_OAUTH_ISSUER`) and
627/// [`OAuthConfig::resolve`] it.
628///
629/// This is [`unresolved_oauth_config_from_env`] followed straight by
630/// [`EnvOAuthConfig::resolve`]. An application that needs its own defaults
631/// (a default required scope, say) applies them between those two calls
632/// instead; see [`EnvOAuthConfig`].
633///
634/// Whether OAuth is on:
635///
636/// - `<PREFIX>ENABLED=false` returns `Ok(None)` without reading any other
637///   variable — the switch for turning OAuth off while the rest of its
638///   settings stay in place, like `enabled: false` in a config file.
639/// - `<PREFIX>ENABLED=true` turns it on regardless of the others, so a
640///   missing setting is reported rather than silently leaving OAuth off.
641/// - `<PREFIX>ENABLED` unset infers it: `Ok(None)` when none of the
642///   identifying variables — `ISSUER`, `JWKS_URI`, `AUDIENCE`, `AUDIENCES`,
643///   `RESOURCE` — is set (no variable outside those and `ENABLED` is read),
644///   and on when any of them is, so a partial set fails naming what is
645///   missing.
646/// - `<PREFIX>ENABLED` set to anything else, or failing to load, is a problem
647///   reported alongside the rest, with OAuth treated as on so every other
648///   problem is found in the same run.
649///
650/// Once on, every field is read (each through [`secret_from_lookup`], so
651/// `<FIELD>_FILE` works too), list-valued fields are split on whitespace,
652/// `bool` fields are parsed strictly (`"true"`/`"false"` only), integer fields
653/// are parsed as decimal (`MAX_TOKEN_AGE_SECS` set to a number means
654/// `Some(number)`), `<PREFIX>REQUIRED_CLAIMS` is one JSON object
655/// (`{"tid": "<tenant id>", "groups": "api-users"}`), and every problem — a variable that failed to load,
656/// a value that failed to parse, or a problem [`OAuthConfig::resolve`] itself
657/// found — is collected into one [`ConfigError`], never reported one at a
658/// time across repeated runs. The loader's own problems come first. A
659/// required setting whose variable failed to load is therefore reported
660/// twice: once with the load failure (the actual cause), and again among
661/// `resolve`'s "these required settings are empty", since it never got a
662/// value.
663///
664/// A field whose variable is unset keeps [`OAuthConfig::default`]'s value for
665/// it (so, for example, an unset `<PREFIX>ALGORITHMS` still resolves to
666/// [`crate::DEFAULT_ALGORITHMS`], not an empty list). An unset
667/// `<PREFIX>SCOPES_SUPPORTED` therefore resolves, as an omitted
668/// `scopes_supported` does anywhere, to the required scopes (`REQUIRED_SCOPE`,
669/// then `REQUIRED_SCOPES`, deduplicated), so the metadata document and the 401
670/// challenge advertise the scope a client must ask for. An environment
671/// variable cannot express an explicitly empty list — an empty value reads as
672/// unset — so this default never overrides an operator's choice.
673///
674/// No secret file's contents ever appear in a problem: [`EnvError`] names
675/// variables and paths only, and a failed read adds the I/O error's own text
676/// (e.g. "No such file or directory"), which never carries file contents.
677/// Non-secret setting values can appear: a value that fails to parse is
678/// echoed back, and [`OAuthConfig::resolve`] quotes the URLs and scopes it
679/// rejects. None of the settings read here is secret — they are public
680/// discovery and authorization material — but a secret-valued field added
681/// later must not be reported the same way.
682///
683/// # Errors
684///
685/// A [`ConfigError`] when OAuth is on and anything is wrong: a variable that
686/// failed to load (see [`EnvError`]), a value that failed to parse, or any
687/// problem [`OAuthConfig::resolve`] reports. All of them at once; its
688/// `Display` names each variable.
689///
690/// # Examples
691///
692/// ```no_run
693/// use std::sync::Arc;
694///
695/// use oauth_resource_server::OAuthValidator;
696/// use oauth_resource_server::env::oauth_config_from_env;
697///
698/// # #[tokio::main]
699/// # async fn main() {
700/// // MYAPP_OAUTH_ISSUER=https://auth.example.com/
701/// // MYAPP_OAUTH_AUDIENCE=example-api
702/// // MYAPP_OAUTH_RESOURCE=https://api.example.com
703/// // MYAPP_OAUTH_REQUIRED_SCOPE=api:read
704/// let oauth = match oauth_config_from_env("MYAPP_OAUTH_") {
705///     Ok(Some(resolved)) => {
706///         let validator = Arc::new(OAuthValidator::new(&resolved).expect("validator"));
707///         validator.spawn_background_refresh();
708///         Some(validator)
709///     }
710///     Ok(None) => None, // OAuth not configured
711///     Err(e) => {
712///         eprintln!("{e}"); // every problem, one per line
713///         std::process::exit(1);
714///     }
715/// };
716/// # let _ = oauth;
717/// # }
718/// ```
719pub fn oauth_config_from_env(prefix: &str) -> Result<Option<ResolvedOAuthConfig>, ConfigError> {
720    oauth_config_from_lookup(prefix, |v| std::env::var(v).ok(), read_secret_file)
721}
722
723/// [`oauth_config_from_env`] with the variable lookup and file reader
724/// injected, for tests. See [`unresolved_oauth_config_from_lookup`] for which
725/// variables it consults.
726///
727/// # Errors
728///
729/// As [`oauth_config_from_env`].
730///
731/// # Examples
732///
733/// ```
734/// use std::collections::HashMap;
735/// use std::io;
736///
737/// use oauth_resource_server::env::oauth_config_from_lookup;
738///
739/// let vars = HashMap::from([
740///     ("MYAPP_OAUTH_ISSUER", "https://auth.example.com/"),
741///     ("MYAPP_OAUTH_AUDIENCE", "example-api"),
742///     ("MYAPP_OAUTH_RESOURCE", "https://api.example.com"),
743///     ("MYAPP_OAUTH_REQUIRED_SCOPES", "api:read api:write"),
744/// ]);
745/// let lookup = |name: &str| vars.get(name).map(|v| v.to_string());
746/// let no_files = |_: &str| Err(io::Error::from(io::ErrorKind::NotFound));
747///
748/// let resolved = oauth_config_from_lookup("MYAPP_OAUTH_", &lookup, &no_files)
749///     .unwrap()
750///     .expect("the identifying variables are set, so OAuth is on");
751/// assert_eq!(resolved.required_scopes, ["api:read", "api:write"]);
752/// // SCOPES_SUPPORTED is unset, so it advertises the required scopes.
753/// assert_eq!(resolved.scopes_supported, ["api:read", "api:write"]);
754///
755/// // No identifying variable set: OAuth is off.
756/// let empty = |_: &str| None;
757/// assert_eq!(oauth_config_from_lookup("OTHER_", empty, &no_files).unwrap(), None);
758///
759/// // A partial set fails, naming each missing variable.
760/// let partial = |name: &str| (name == "APP2_ISSUER").then(|| "https://auth.example.com/".to_string());
761/// let err = oauth_config_from_lookup("APP2_", partial, &no_files).unwrap_err();
762/// assert!(err.to_string().contains("APP2_RESOURCE"));
763/// ```
764pub fn oauth_config_from_lookup<L, R>(
765    prefix: &str,
766    lookup: L,
767    read_file: R,
768) -> Result<Option<ResolvedOAuthConfig>, ConfigError>
769where
770    L: Fn(&str) -> Option<String>,
771    R: Fn(&str) -> io::Result<String>,
772{
773    match unresolved_oauth_config_from_lookup(prefix, lookup, read_file) {
774        Some(loaded) => loaded.resolve(),
775        None => Ok(None),
776    }
777}
778
779/// An [`OAuthConfig`] read from the environment but not yet resolved, with
780/// the problems the loader itself found (variables that failed to load,
781/// values that failed to parse).
782///
783/// Returned by [`unresolved_oauth_config_from_env`] so an application can
784/// apply its own defaults to [`config`](Self::config) before
785/// [`resolve`](Self::resolve) validates it — the same hook a config-file
786/// application has between deserializing an [`OAuthConfig`] and calling
787/// [`OAuthConfig::resolve`]. For example, to require `api:read` unless the
788/// operator named a required scope:
789///
790/// ```
791/// # fn main() -> Result<(), oauth_resource_server::ConfigError> {
792/// use oauth_resource_server::env::unresolved_oauth_config_from_env;
793///
794/// if let Some(mut loaded) = unresolved_oauth_config_from_env("MYAPP_OAUTH_") {
795///     let cfg = &mut loaded.config;
796///     if cfg.required_scope.is_none() && cfg.required_scopes.is_empty() {
797///         cfg.required_scope = Some("api:read".into());
798///     }
799///     let resolved = loaded.resolve()?;
800///     # let _ = resolved;
801/// }
802/// # Ok(())
803/// # }
804/// ```
805///
806/// A default set this way is validated by `resolve` like any other value, and
807/// is included in the `scopes_supported` default below.
808///
809/// [`config`](Self::config)`.scopes_supported` is `None` when
810/// `<PREFIX>SCOPES_SUPPORTED` was unset (or failed to load);
811/// [`OAuthConfig::resolve`] turns `None` into the required scopes as they
812/// stand at that point, application defaults included. Set it to `Some(..)` —
813/// `Some(vec![])` for an explicitly empty list — to override that.
814#[derive(Debug, Clone)]
815#[non_exhaustive]
816pub struct EnvOAuthConfig {
817    /// The loaded config, `enabled: true`, every unset field at its
818    /// [`OAuthConfig::default`] value. Free to modify before
819    /// [`resolve`](Self::resolve).
820    pub config: OAuthConfig,
821    /// Problems the loader found, each naming its variable. Reported by
822    /// [`resolve`](Self::resolve) ahead of any it finds itself; `resolve`
823    /// fails whenever this is non-empty.
824    ///
825    /// Kept for compatibility; the structured form is
826    /// [`problem_details`](Self::problem_details), filled from the same list at
827    /// load time. Editing this field in place (an application appending its
828    /// own problem, say) does not update `problem_details()`, but
829    /// [`resolve`](Self::resolve) reports whatever this field holds.
830    pub problems: Vec<String>,
831    details: Vec<ConfigProblem>,
832    prefix: String,
833}
834
835/// Equality covers every field except the structured `details`, which are
836/// derived from `problems` (this type derived `PartialEq` before they existed).
837impl PartialEq for EnvOAuthConfig {
838    fn eq(&self, other: &Self) -> bool {
839        self.config == other.config
840            && self.problems == other.problems
841            && self.prefix == other.prefix
842    }
843}
844
845impl Eq for EnvOAuthConfig {}
846
847impl EnvOAuthConfig {
848    /// The loader's problems as structured [`ConfigProblem`]s, in the order
849    /// [`problems`](Self::problems) lists them: [`ProblemKind::EnvLoad`] for a
850    /// variable or `_FILE` that could not be read, [`ProblemKind::EnvParse`]
851    /// for a value that could not be parsed. Match on the kind, not the text.
852    ///
853    /// Fixed at load time: editing the public `problems` field in place does
854    /// not change it, so this can be stale until [`resolve`](Self::resolve),
855    /// which reconciles the two. `keys()` of an `EnvLoad` problem is the
856    /// variable and its `_FILE` twin when both were set, else the `_FILE`
857    /// variable whose file could not be used.
858    pub fn problem_details(&self) -> &[ConfigProblem] {
859        &self.details
860    }
861
862    /// The variable prefix this config was loaded with, which
863    /// [`resolve`](Self::resolve) also uses to name settings in problems.
864    pub fn prefix(&self) -> &str {
865        &self.prefix
866    }
867
868    /// [`OAuthConfig::resolve`] with [`KeyNaming::Env`] of this prefix,
869    /// failing with the loader's [`problems`](Self::problems) followed by
870    /// `resolve`'s own if there are any of either.
871    ///
872    /// `Ok(None)` only if the application set
873    /// [`config`](Self::config)`.enabled` to `false` and the loader found no
874    /// problems.
875    ///
876    /// # Errors
877    ///
878    /// A [`ConfigError`] when [`problems`](Self::problems) is non-empty or
879    /// [`OAuthConfig::resolve`] finds any, listing the loader's first. The
880    /// public [`problems`](Self::problems) decides what is reported: an entry
881    /// the application edited or added surfaces in the resulting
882    /// [`ConfigError`] as [`ProblemKind::Other`], while an untouched one keeps
883    /// its kind and keys. [`problem_details`](Self::problem_details) may be
884    /// stale before this call; the reconciliation happens inside it.
885    pub fn resolve(self) -> Result<Option<ResolvedOAuthConfig>, ConfigError> {
886        let Self {
887            config,
888            problems,
889            details,
890            prefix,
891        } = self;
892        let naming = KeyNaming::Env(&prefix);
893        // `problems` is public and may have been edited since loading, so it
894        // stays the authority for what is reported; each entry keeps its
895        // structured form when one still matches, and is `Other` otherwise.
896        let mut all = reconcile(problems, details);
897        match config.resolve(naming) {
898            Ok(resolved) if all.is_empty() => Ok(resolved),
899            Ok(_) => Err(ConfigError::from_problems(naming, all)),
900            Err(resolve_err) => {
901                all.extend(resolve_err.problem_details().iter().cloned());
902                Err(ConfigError::from_problems(naming, all))
903            }
904        }
905    }
906}
907
908/// One [`ConfigProblem`] per string in `problems`, in order: the first unused
909/// entry of `details` with the same message, else a plain `Other` problem.
910fn reconcile(problems: Vec<String>, details: Vec<ConfigProblem>) -> Vec<ConfigProblem> {
911    let mut pool: Vec<Option<ConfigProblem>> = details.into_iter().map(Some).collect();
912    problems
913        .into_iter()
914        .map(|text| {
915            pool.iter_mut()
916                .find(|slot| slot.as_ref().is_some_and(|d| d.message() == text))
917                .and_then(Option::take)
918                .unwrap_or_else(|| ConfigProblem::from(text))
919        })
920        .collect()
921}
922
923/// Load an [`OAuthConfig`] from `<PREFIX><FIELD_UPPER>` variables without
924/// resolving it, so the application can apply its own defaults first; see
925/// [`EnvOAuthConfig`].
926///
927/// `None` means OAuth is off, decided exactly as [`oauth_config_from_env`]
928/// describes (`<PREFIX>ENABLED=false`, or `ENABLED` and every identifying
929/// variable unset). Every other outcome is `Some`, with any load and parse
930/// problems carried in [`EnvOAuthConfig::problems`] rather than returned
931/// here, so [`EnvOAuthConfig::resolve`] reports them together with its own.
932pub fn unresolved_oauth_config_from_env(prefix: &str) -> Option<EnvOAuthConfig> {
933    unresolved_oauth_config_from_lookup(prefix, |v| std::env::var(v).ok(), read_secret_file)
934}
935
936/// [`unresolved_oauth_config_from_env`] with the variable lookup and file
937/// reader injected, for tests.
938///
939/// For each field consulted, `lookup` is called for both `<PREFIX><FIELD>` and
940/// `<PREFIX><FIELD>_FILE`, and `read_file` only when the `_FILE` form is set
941/// and the plain one is not. When the result is `None` because OAuth is off,
942/// no field outside `ENABLED` and the identifying set (`ISSUER`, `JWKS_URI`,
943/// `AUDIENCE`, `AUDIENCES`, `RESOURCE`) is consulted — and none but `ENABLED`
944/// when that is `false`.
945pub fn unresolved_oauth_config_from_lookup<L, R>(
946    prefix: &str,
947    lookup: L,
948    read_file: R,
949) -> Option<EnvOAuthConfig>
950where
951    L: Fn(&str) -> Option<String>,
952    R: Fn(&str) -> io::Result<String>,
953{
954    let naming = KeyNaming::Env(prefix);
955    let field = |f: &str| secret_from_lookup(&naming.key(f), &lookup, &read_file);
956    let mut problems: Vec<ConfigProblem> = Vec::new();
957
958    // `true` when ENABLED was set in any form other than a clean "false" —
959    // including an unparsable value or a load failure, which are reported
960    // and must not silently leave OAuth off.
961    let explicitly_enabled = match field("enabled") {
962        Ok(None) => false,
963        Ok(Some(v)) => match parse_strict_bool(&v) {
964            Ok(false) => return None,
965            Ok(true) => true,
966            Err(()) => {
967                problems.push(bool_problem(naming, "enabled", &v));
968                true
969            }
970        },
971        Err(e) => {
972            problems.push(env_problem(&e));
973            true
974        }
975    };
976
977    let identifying = IdentifyingVars {
978        issuer: field("issuer"),
979        jwks_uri: field("jwks_uri"),
980        audience: field("audience"),
981        audiences: field("audiences"),
982        resource: field("resource"),
983    };
984    if !explicitly_enabled && !identifying.any_set() {
985        return None;
986    }
987
988    let mut cfg = OAuthConfig {
989        enabled: true,
990        ..OAuthConfig::default()
991    };
992    let IdentifyingVars {
993        issuer,
994        jwks_uri,
995        audience,
996        audiences,
997        resource,
998    } = identifying;
999    if let Some(v) = take(issuer, &mut problems) {
1000        cfg.issuer = v;
1001    }
1002    cfg.jwks_uri = take(jwks_uri, &mut problems);
1003    if let Some(v) = take(audience, &mut problems) {
1004        cfg.audience = v;
1005    }
1006    if let Some(v) = take(audiences, &mut problems) {
1007        cfg.audiences = split_list(&v);
1008    }
1009    if let Some(v) = take(resource, &mut problems) {
1010        cfg.resource = v;
1011    }
1012
1013    cfg.required_scope = take(field("required_scope"), &mut problems);
1014    // A set-but-blank value reads as unset above (every variable does); for
1015    // this one, as in a config file, blank is always an error
1016    // (`BlankRequiredScope` from `resolve`), never "no scope configured".
1017    if cfg.required_scope.is_none()
1018        && lookup(&naming.key("required_scope"))
1019            .is_some_and(|v| !v.is_empty() && v.trim().is_empty())
1020    {
1021        cfg.required_scope = Some(String::new());
1022    }
1023    if let Some(v) = take(field("required_scopes"), &mut problems) {
1024        cfg.required_scopes = split_list(&v);
1025    }
1026    // Left `None` when unset; `EnvOAuthConfig::resolve` supplies the default.
1027    cfg.scopes_supported = take(field("scopes_supported"), &mut problems).map(|v| split_list(&v));
1028    if let Some(v) = take(field("scope_claims"), &mut problems) {
1029        cfg.scope_claims = split_list(&v);
1030    }
1031    if let Some(v) = take(field("principal_claims"), &mut problems) {
1032        cfg.principal_claims = split_list(&v);
1033    }
1034    if let Some(v) = take(field("algorithms"), &mut problems) {
1035        cfg.algorithms = split_list(&v);
1036    }
1037    if let Some(v) = take(field("leeway_secs"), &mut problems) {
1038        match v.parse::<u64>() {
1039            Ok(n) => cfg.leeway_secs = n,
1040            Err(_) => problems.push(ConfigProblem::new(
1041                ProblemKind::EnvParse,
1042                [naming.key("leeway_secs")],
1043                format!(
1044                    "{} {v:?} is not a valid non-negative integer",
1045                    naming.key("leeway_secs")
1046                ),
1047            )),
1048        }
1049    }
1050    if let Some(v) = take(field("allowed_client_ids"), &mut problems) {
1051        cfg.allowed_client_ids = split_list(&v);
1052    }
1053    if let Some(v) = take(field("max_token_age_secs"), &mut problems) {
1054        match v.parse::<u64>() {
1055            Ok(n) => cfg.max_token_age_secs = Some(n),
1056            Err(_) => problems.push(ConfigProblem::new(
1057                ProblemKind::EnvParse,
1058                [naming.key("max_token_age_secs")],
1059                format!(
1060                    "{} {v:?} is not a valid non-negative integer",
1061                    naming.key("max_token_age_secs")
1062                ),
1063            )),
1064        }
1065    }
1066    if let Some(v) = take(field("required_claims"), &mut problems) {
1067        // One JSON object, `{"claim": value, ...}`: a claim value can be a
1068        // string, number or boolean, which a whitespace-split list cannot
1069        // carry. `resolve` checks the entries themselves.
1070        match serde_json::from_str::<serde_json::Value>(&v) {
1071            Ok(serde_json::Value::Object(map)) => match duplicated_claim(&v) {
1072                // `serde_json::Map` keeps the last of a repeated key; which
1073                // one an operator meant is not ours to guess.
1074                Some(name) => problems.push(ConfigProblem::new(
1075                    ProblemKind::InvalidRequiredClaim,
1076                    [naming.key("required_claims")],
1077                    format!(
1078                        "{} names {:?} more than once — each claim may appear once",
1079                        naming.key("required_claims"),
1080                        crate::token::for_log(&name)
1081                    ),
1082                )),
1083                None => cfg.required_claims = map.into_iter().collect(),
1084            },
1085            Ok(_) => problems.push(ConfigProblem::new(
1086                ProblemKind::EnvParse,
1087                [naming.key("required_claims")],
1088                format!(
1089                    "{} must be a JSON object, e.g. {{\"tid\": \"<tenant id>\"}}",
1090                    naming.key("required_claims")
1091                ),
1092            )),
1093            Err(e) => problems.push(ConfigProblem::new(
1094                ProblemKind::EnvParse,
1095                [naming.key("required_claims")],
1096                format!(
1097                    "{} is not valid JSON ({e}); it must be a JSON object, e.g. \
1098                     {{\"tid\": \"<tenant id>\"}}",
1099                    naming.key("required_claims")
1100                ),
1101            )),
1102        }
1103    }
1104    for (name, slot) in [
1105        ("require_at_jwt", &mut cfg.require_at_jwt),
1106        ("allow_unscoped_tokens", &mut cfg.allow_unscoped_tokens),
1107        ("allow_insecure_http", &mut cfg.allow_insecure_http),
1108        ("accept_static_bearer", &mut cfg.accept_static_bearer),
1109    ] {
1110        if let Some(v) = take(field(name), &mut problems) {
1111            match parse_strict_bool(&v) {
1112                Ok(b) => *slot = b,
1113                Err(()) => problems.push(bool_problem(naming, name, &v)),
1114            }
1115        }
1116    }
1117
1118    Some(EnvOAuthConfig {
1119        config: cfg,
1120        problems: problems.iter().map(|p| p.message().to_string()).collect(),
1121        details: problems,
1122        prefix: prefix.to_string(),
1123    })
1124}
1125#[cfg(test)]
1126mod tests {
1127    use super::*;
1128    use std::cell::Cell;
1129    use std::collections::HashMap;
1130
1131    // ── secret_from_lookup / secret_from_env ────────────────────────────────
1132
1133    fn lookup_from<'a>(
1134        vars: &'a HashMap<&'static str, &'static str>,
1135    ) -> impl Fn(&str) -> Option<String> + 'a {
1136        move |k| vars.get(k).map(|v| v.to_string())
1137    }
1138
1139    fn files_from<'a>(
1140        files: &'a HashMap<&'static str, &'static str>,
1141    ) -> impl Fn(&str) -> io::Result<String> + 'a {
1142        move |p| {
1143            files
1144                .get(p)
1145                .map(|c| c.to_string())
1146                .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no such file"))
1147        }
1148    }
1149
1150    #[test]
1151    fn absent_is_none() {
1152        let vars = HashMap::new();
1153        let files = HashMap::new();
1154        // `EnvError` wraps `std::io::Error`, which has no `PartialEq`, so
1155        // `Result<_, EnvError>` cannot be compared with `assert_eq!` directly
1156        // — unwrap the `Ok` side first throughout this module's tests.
1157        assert_eq!(
1158            secret_from_lookup("FOO", lookup_from(&vars), files_from(&files)).unwrap(),
1159            None
1160        );
1161    }
1162
1163    #[test]
1164    fn direct_value_is_trimmed() {
1165        let vars = HashMap::from([("FOO", "  bar  ")]);
1166        let files = HashMap::new();
1167        assert_eq!(
1168            secret_from_lookup("FOO", lookup_from(&vars), files_from(&files)).unwrap(),
1169            Some("bar".to_string())
1170        );
1171    }
1172
1173    #[test]
1174    fn direct_value_empty_or_whitespace_is_none() {
1175        for value in ["", "   "] {
1176            let vars = HashMap::from([("FOO", value)]);
1177            let files = HashMap::new();
1178            assert_eq!(
1179                secret_from_lookup("FOO", lookup_from(&vars), files_from(&files)).unwrap(),
1180                None,
1181                "{value:?} should read as unset"
1182            );
1183        }
1184    }
1185
1186    #[test]
1187    fn file_value_is_read_and_trimmed() {
1188        let vars = HashMap::from([("FOO_FILE", "/run/secrets/foo")]);
1189        let files = HashMap::from([("/run/secrets/foo", "bar\n")]);
1190        assert_eq!(
1191            secret_from_lookup("FOO", lookup_from(&vars), files_from(&files)).unwrap(),
1192            Some("bar".to_string())
1193        );
1194    }
1195
1196    /// A value no error message could contain by coincidence.
1197    const SENTINEL: &str = "s3cr3t-sentinel";
1198
1199    /// Neither `Display` nor `Debug` of `err` may contain [`SENTINEL`].
1200    fn assert_no_leak(err: &EnvError) {
1201        let display = err.to_string();
1202        let debug = format!("{err:?}");
1203        assert!(!display.contains(SENTINEL), "Display leaks: {display}");
1204        assert!(!debug.contains(SENTINEL), "Debug leaks: {debug}");
1205    }
1206
1207    #[test]
1208    fn both_set_is_an_error_naming_var_and_path_only() {
1209        let vars = HashMap::from([("FOO", SENTINEL), ("FOO_FILE", "/run/secrets/foo")]);
1210        let files = HashMap::from([("/run/secrets/foo", SENTINEL)]);
1211        let err = secret_from_lookup("FOO", lookup_from(&vars), files_from(&files)).unwrap_err();
1212        let text = err.to_string();
1213        assert!(text.contains("FOO"), "{text}");
1214        assert!(text.contains("FOO_FILE"), "{text}");
1215        assert!(text.contains("/run/secrets/foo"), "{text}");
1216        assert!(matches!(err, EnvError::BothSet { .. }));
1217        assert_no_leak(&err);
1218    }
1219
1220    #[test]
1221    fn unreadable_file_is_an_error_naming_the_path() {
1222        let vars = HashMap::from([("FOO_FILE", "/run/secrets/missing")]);
1223        let files = HashMap::new();
1224        let err = secret_from_lookup("FOO", lookup_from(&vars), files_from(&files)).unwrap_err();
1225        assert!(matches!(err, EnvError::ReadFailed { .. }));
1226        let text = err.to_string();
1227        assert!(text.contains("FOO_FILE"), "{text}");
1228        assert!(text.contains("/run/secrets/missing"), "{text}");
1229        // The I/O cause is the source, not part of Display, so a chain-walking
1230        // reporter prints it once.
1231        assert!(!text.contains("no such file"), "{text}");
1232        let source = std::error::Error::source(&err).expect("the I/O error is the source");
1233        assert_eq!(source.to_string(), "no such file");
1234    }
1235
1236    /// A `ConfigError` problem has no source chain, so the loader appends the
1237    /// I/O cause to a `ReadFailed` problem itself — exactly once.
1238    #[test]
1239    fn a_read_failure_problem_carries_its_io_cause_once() {
1240        let vars = HashMap::from([
1241            ("APP_OAUTH_ISSUER_FILE", "/run/secrets/missing"),
1242            ("APP_OAUTH_AUDIENCE", "client-id"),
1243            ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
1244        ]);
1245        let files = HashMap::new();
1246        let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1247            .unwrap_err();
1248        assert_eq!(
1249            err.problems[0],
1250            "APP_OAUTH_ISSUER_FILE=/run/secrets/missing: failed to read secret file: no such file"
1251        );
1252        assert_eq!(err.to_string().matches("no such file").count(), 1);
1253    }
1254
1255    /// `read_to_string` fails on a file that exists but is not valid UTF-8,
1256    /// after reading its bytes. Neither the I/O error nor `ReadFailed` may
1257    /// carry those bytes. The injected reader stands in for a file holding the
1258    /// sentinel, failing the way `read_to_string` does.
1259    #[test]
1260    fn a_file_that_fails_to_decode_does_not_leak_its_contents() {
1261        let vars = HashMap::from([("FOO_FILE", "/run/secrets/foo")]);
1262        let files = HashMap::from([("/run/secrets/foo", SENTINEL)]);
1263        let read_file = |path: &str| -> io::Result<String> {
1264            assert!(files.contains_key(path), "unexpected path {path}");
1265            Err(io::Error::new(
1266                io::ErrorKind::InvalidData,
1267                "stream did not contain valid UTF-8",
1268            ))
1269        };
1270        let err = secret_from_lookup("FOO", lookup_from(&vars), read_file).unwrap_err();
1271        assert!(matches!(err, EnvError::ReadFailed { .. }));
1272        assert_no_leak(&err);
1273    }
1274
1275    #[test]
1276    fn empty_file_is_an_error_not_none() {
1277        // The file is blank, so the only secret around is in a neighbouring
1278        // variable, which the error must not pick up either.
1279        let vars = HashMap::from([("FOO_FILE", "/run/secrets/foo"), ("FOO_TOKEN", SENTINEL)]);
1280        let files = HashMap::from([("/run/secrets/foo", "   \n")]);
1281        let err = secret_from_lookup("FOO", lookup_from(&vars), files_from(&files)).unwrap_err();
1282        assert!(matches!(err, EnvError::EmptyFile { .. }));
1283        let text = err.to_string();
1284        assert!(text.contains("FOO_FILE"), "{text}");
1285        assert!(text.contains("/run/secrets/foo"), "{text}");
1286        assert_no_leak(&err);
1287    }
1288
1289    #[test]
1290    fn secret_from_env_wraps_the_real_environment() {
1291        // No var of this name is plausibly set in CI; this only exercises that
1292        // the plain function delegates without panicking.
1293        assert_eq!(
1294            secret_from_env("OAUTH_RESOURCE_SERVER_ENV_RS_TEST_UNSET_VAR_9f3c").unwrap(),
1295            None
1296        );
1297    }
1298
1299    // ── oauth_config_from_lookup ─────────────────────────────────────────────
1300
1301    /// A lookup that also records every variable it was asked about, so a
1302    /// test can assert which variables `oauth_config_from_lookup` consults
1303    /// when OAuth turns out to be off.
1304    struct RecordingLookup<'a> {
1305        vars: &'a HashMap<&'static str, &'static str>,
1306        calls: Cell<Vec<String>>,
1307    }
1308
1309    impl<'a> RecordingLookup<'a> {
1310        fn new(vars: &'a HashMap<&'static str, &'static str>) -> Self {
1311            Self {
1312                vars,
1313                calls: Cell::new(Vec::new()),
1314            }
1315        }
1316
1317        fn call(&self, var: &str) -> Option<String> {
1318            let mut calls = self.calls.take();
1319            calls.push(var.to_string());
1320            self.calls.set(calls);
1321            self.vars.get(var).map(|v| v.to_string())
1322        }
1323    }
1324
1325    #[test]
1326    fn nothing_set_is_none_and_touches_only_identifying_variables() {
1327        let vars = HashMap::new();
1328        let files = HashMap::new();
1329        let recorder = RecordingLookup::new(&vars);
1330        let result =
1331            oauth_config_from_lookup("APP_OAUTH_", |v| recorder.call(v), files_from(&files));
1332        assert_eq!(result, Ok(None));
1333        let calls = recorder.calls.take();
1334        for var in &calls {
1335            assert!(
1336                var.starts_with("APP_OAUTH_")
1337                    && (var.ends_with("ENABLED")
1338                        || var.ends_with("ENABLED_FILE")
1339                        || var.ends_with("ISSUER")
1340                        || var.ends_with("ISSUER_FILE")
1341                        || var.ends_with("JWKS_URI")
1342                        || var.ends_with("JWKS_URI_FILE")
1343                        || var.ends_with("AUDIENCE")
1344                        || var.ends_with("AUDIENCE_FILE")
1345                        || var.ends_with("AUDIENCES")
1346                        || var.ends_with("AUDIENCES_FILE")
1347                        || var.ends_with("RESOURCE")
1348                        || var.ends_with("RESOURCE_FILE")),
1349                "unexpected variable consulted while OAuth is unconfigured: {var} (all: {calls:?})"
1350            );
1351        }
1352        assert!(!calls.is_empty(), "the identifying vars must be checked");
1353    }
1354
1355    /// A typical prefixed variable set (`MYAPP_OAUTH_{ISSUER,JWKS_URI,
1356    /// AUDIENCE,RESOURCE,REQUIRED_SCOPE,SCOPES_SUPPORTED}`) resolves every
1357    /// field it names verbatim, splits SCOPES_SUPPORTED on whitespace, and
1358    /// leaves every field it does not name at the crate default.
1359    #[test]
1360    fn a_typical_prefixed_variable_set_resolves_verbatim() {
1361        let vars = HashMap::from([
1362            (
1363                "MYAPP_OAUTH_ISSUER",
1364                "https://idp.example.test/application/o/myapp/",
1365            ),
1366            (
1367                "MYAPP_OAUTH_JWKS_URI",
1368                "https://idp.example.test/application/o/myapp/jwks/",
1369            ),
1370            ("MYAPP_OAUTH_AUDIENCE", "myapp-client-id"),
1371            ("MYAPP_OAUTH_RESOURCE", "https://myapp.example.test/mcp"),
1372            ("MYAPP_OAUTH_REQUIRED_SCOPE", "myapp:read"),
1373            ("MYAPP_OAUTH_SCOPES_SUPPORTED", "myapp:read myapp:write"),
1374        ]);
1375        let files = HashMap::new();
1376        let resolved =
1377            oauth_config_from_lookup("MYAPP_OAUTH_", lookup_from(&vars), files_from(&files))
1378                .unwrap()
1379                .expect("identifying vars are set");
1380
1381        assert_eq!(
1382            resolved.issuer,
1383            "https://idp.example.test/application/o/myapp/"
1384        );
1385        assert_eq!(
1386            resolved.jwks_uri.as_deref(),
1387            Some("https://idp.example.test/application/o/myapp/jwks/")
1388        );
1389        assert_eq!(resolved.audience, "myapp-client-id");
1390        assert_eq!(resolved.resource, "https://myapp.example.test/mcp");
1391        assert_eq!(resolved.required_scopes, ["myapp:read"]);
1392        assert_eq!(resolved.scopes_supported, ["myapp:read", "myapp:write"]);
1393        // Fields the variable set does not name keep the crate's defaults.
1394        assert_eq!(resolved.scope_claims, ["scope", "scp"]);
1395        assert!(!resolved.require_at_jwt);
1396        assert!(resolved.accept_static_bearer);
1397    }
1398
1399    #[test]
1400    fn jwks_uri_alone_counts_as_identifying_even_though_resolve_does_not_require_it() {
1401        let vars = HashMap::from([("APP_OAUTH_JWKS_URI", "https://idp.example.test/jwks")]);
1402        let files = HashMap::new();
1403        let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1404            .unwrap_err();
1405        // Enabled, but issuer/audience/resource are still required — jwks_uri
1406        // alone does not satisfy them.
1407        assert!(err.problems.iter().any(|p| p.contains("APP_OAUTH_ISSUER")));
1408        assert!(
1409            !err.problems
1410                .iter()
1411                .any(|p| p.contains("APP_OAUTH_JWKS_URI"))
1412        );
1413    }
1414
1415    #[test]
1416    fn partial_set_is_an_error_listing_what_is_missing() {
1417        let vars = HashMap::from([("APP_OAUTH_ISSUER", "https://idp.example.test/")]);
1418        let files = HashMap::new();
1419        let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1420            .unwrap_err();
1421        let text = err.to_string();
1422        assert!(text.contains("APP_OAUTH_AUDIENCE"), "{text}");
1423        assert!(text.contains("APP_OAUTH_RESOURCE"), "{text}");
1424    }
1425
1426    #[test]
1427    fn a_both_set_error_on_an_identifying_variable_still_counts_as_configured() {
1428        let vars = HashMap::from([
1429            ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1430            ("APP_OAUTH_ISSUER_FILE", "/run/secrets/issuer"),
1431        ]);
1432        let files = HashMap::from([("/run/secrets/issuer", "https://idp.example.test/")]);
1433        let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1434            .unwrap_err();
1435        // The load failure is reported first — it is the actual cause.
1436        assert!(
1437            err.problems[0].contains("APP_OAUTH_ISSUER") && err.problems[0].contains("both set"),
1438            "{:?}",
1439            err.problems
1440        );
1441        // ISSUER then never got a value, so `resolve` also lists it among the
1442        // empty required settings. The duplication is accepted (and documented
1443        // on `oauth_config_from_env`): suppressing it would mean rewriting
1444        // `resolve`'s message, and the first entry already names the cause.
1445        assert!(
1446            err.problems[1..]
1447                .iter()
1448                .any(|p| p.starts_with("these required settings are empty")
1449                    && p.contains("APP_OAUTH_ISSUER")),
1450            "{:?}",
1451            err.problems
1452        );
1453    }
1454
1455    /// An unset SCOPES_SUPPORTED advertises the required scope, so the metadata and
1456    /// the 401 challenge tell a client what to ask for.
1457    #[test]
1458    fn scopes_supported_defaults_to_the_required_scope() {
1459        let vars = HashMap::from([
1460            ("MYAPP_OAUTH_ISSUER", "https://idp.example.test/"),
1461            ("MYAPP_OAUTH_JWKS_URI", "http://127.0.0.1:1/jwks"),
1462            ("MYAPP_OAUTH_AUDIENCE", "myapp-client-id"),
1463            ("MYAPP_OAUTH_RESOURCE", "https://myapp.example.test/mcp"),
1464            ("MYAPP_OAUTH_REQUIRED_SCOPE", "myapp:read"),
1465        ]);
1466        let files = HashMap::new();
1467        let resolved =
1468            oauth_config_from_lookup("MYAPP_OAUTH_", lookup_from(&vars), files_from(&files))
1469                .unwrap()
1470                .unwrap();
1471        assert_eq!(resolved.required_scopes, ["myapp:read"]);
1472        assert_eq!(resolved.scopes_supported, ["myapp:read"]);
1473    }
1474
1475    #[test]
1476    fn scopes_supported_default_unions_required_scopes_in_order_without_duplicates() {
1477        let vars = HashMap::from([
1478            ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1479            ("APP_OAUTH_AUDIENCE", "client-id"),
1480            ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
1481            ("APP_OAUTH_REQUIRED_SCOPE", "api:read"),
1482            ("APP_OAUTH_REQUIRED_SCOPES", "api:write api:read"),
1483        ]);
1484        let files = HashMap::new();
1485        let resolved =
1486            oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1487                .unwrap()
1488                .unwrap();
1489        assert_eq!(resolved.scopes_supported, ["api:read", "api:write"]);
1490    }
1491
1492    #[test]
1493    fn scopes_supported_stays_empty_with_no_required_scope() {
1494        let vars = HashMap::from([
1495            ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1496            ("APP_OAUTH_AUDIENCE", "client-id"),
1497            ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
1498            // No scope at all needs the explicit opt-in.
1499            ("APP_OAUTH_ALLOW_UNSCOPED_TOKENS", "true"),
1500            // Blank reads as unset: it cannot mean "explicitly empty".
1501            ("APP_OAUTH_SCOPES_SUPPORTED", "  "),
1502        ]);
1503        let files = HashMap::new();
1504        let resolved =
1505            oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1506                .unwrap()
1507                .unwrap();
1508        assert!(resolved.required_scopes.is_empty());
1509        assert!(resolved.scopes_supported.is_empty());
1510    }
1511
1512    #[test]
1513    fn explicit_scopes_supported_is_not_replaced_by_the_default() {
1514        let vars = HashMap::from([
1515            ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1516            ("APP_OAUTH_AUDIENCE", "client-id"),
1517            ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
1518            ("APP_OAUTH_REQUIRED_SCOPE", "api:read"),
1519            ("APP_OAUTH_SCOPES_SUPPORTED", "api:admin"),
1520        ]);
1521        let files = HashMap::new();
1522        let resolved =
1523            oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1524                .unwrap()
1525                .unwrap();
1526        assert_eq!(resolved.scopes_supported, ["api:admin"]);
1527    }
1528
1529    #[test]
1530    fn enabled_true_alone_turns_oauth_on_and_reports_what_is_missing() {
1531        let vars = HashMap::from([("APP_OAUTH_ENABLED", "true")]);
1532        let files = HashMap::new();
1533        let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1534            .unwrap_err();
1535        let text = err.to_string();
1536        assert!(text.contains("APP_OAUTH_ISSUER"), "{text}");
1537        assert!(text.contains("APP_OAUTH_AUDIENCE"), "{text}");
1538        assert!(text.contains("APP_OAUTH_RESOURCE"), "{text}");
1539    }
1540
1541    #[test]
1542    fn enabled_true_with_a_complete_set_resolves() {
1543        let vars = HashMap::from([
1544            ("APP_OAUTH_ENABLED", "true"),
1545            ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1546            ("APP_OAUTH_AUDIENCE", "client-id"),
1547            ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
1548            ("APP_OAUTH_REQUIRED_SCOPE", "api:read"),
1549        ]);
1550        let files = HashMap::new();
1551        let resolved =
1552            oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files)).unwrap();
1553        assert!(resolved.is_some());
1554    }
1555
1556    #[test]
1557    fn enabled_false_turns_oauth_off_without_reading_anything_else() {
1558        let vars = HashMap::from([
1559            ("APP_OAUTH_ENABLED", "false"),
1560            ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1561            ("APP_OAUTH_AUDIENCE", "client-id"),
1562            ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
1563            // Would be an error if it were read.
1564            ("APP_OAUTH_LEEWAY_SECS", "not-a-number"),
1565        ]);
1566        let files = HashMap::new();
1567        let recorder = RecordingLookup::new(&vars);
1568        let result =
1569            oauth_config_from_lookup("APP_OAUTH_", |v| recorder.call(v), files_from(&files));
1570        assert_eq!(result, Ok(None));
1571        let calls = recorder.calls.take();
1572        assert_eq!(calls, ["APP_OAUTH_ENABLED", "APP_OAUTH_ENABLED_FILE"]);
1573    }
1574
1575    #[test]
1576    fn enabled_from_a_file_is_honoured() {
1577        let vars = HashMap::from([
1578            ("APP_OAUTH_ENABLED_FILE", "/run/secrets/enabled"),
1579            ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1580        ]);
1581        let files = HashMap::from([("/run/secrets/enabled", "false\n")]);
1582        assert_eq!(
1583            oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files)),
1584            Ok(None)
1585        );
1586    }
1587
1588    #[test]
1589    fn an_unparsable_enabled_is_reported_and_treated_as_on() {
1590        let vars = HashMap::from([("APP_OAUTH_ENABLED", "yes")]);
1591        let files = HashMap::new();
1592        let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1593            .unwrap_err();
1594        assert!(
1595            err.problems[0].contains("APP_OAUTH_ENABLED")
1596                && err.problems[0].contains("must be \"true\" or \"false\""),
1597            "{:?}",
1598            err.problems
1599        );
1600        // Treated as on, so the rest is checked in the same run.
1601        assert!(err.to_string().contains("APP_OAUTH_ISSUER"));
1602    }
1603
1604    #[test]
1605    fn an_enabled_that_fails_to_load_is_reported_and_treated_as_on() {
1606        let vars = HashMap::from([("APP_OAUTH_ENABLED_FILE", "/run/secrets/missing")]);
1607        let files = HashMap::new();
1608        let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1609            .unwrap_err();
1610        assert!(
1611            err.problems[0].contains("APP_OAUTH_ENABLED_FILE"),
1612            "{:?}",
1613            err.problems
1614        );
1615        assert!(err.to_string().contains("APP_OAUTH_ISSUER"));
1616    }
1617
1618    #[test]
1619    fn loader_problems_carry_env_load_and_env_parse_kinds() {
1620        let vars = HashMap::from([
1621            ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1622            ("APP_OAUTH_ISSUER_FILE", "/run/secrets/issuer"),
1623            ("APP_OAUTH_AUDIENCE_FILE", "/run/secrets/missing"),
1624            ("APP_OAUTH_RESOURCE", "https://kb.example.test/"),
1625            ("APP_OAUTH_REQUIRE_AT_JWT", "yes"),
1626            ("APP_OAUTH_LEEWAY_SECS", "soon"),
1627            ("APP_OAUTH_ALLOW_UNSCOPED_TOKENS_FILE", "/run/secrets/empty"),
1628        ]);
1629        let files = HashMap::from([("/run/secrets/empty", "  ")]);
1630        let loaded = unresolved_oauth_config_from_lookup(
1631            "APP_OAUTH_",
1632            lookup_from(&vars),
1633            files_from(&files),
1634        )
1635        .unwrap();
1636        let got: Vec<_> = loaded
1637            .problem_details()
1638            .iter()
1639            .map(|p| (p.kind(), p.keys().to_vec()))
1640            .collect();
1641        let env = |v: &str| vec![v.to_string()];
1642        assert_eq!(
1643            got,
1644            [
1645                (
1646                    ProblemKind::EnvLoad,
1647                    vec![
1648                        "APP_OAUTH_ISSUER".to_string(),
1649                        "APP_OAUTH_ISSUER_FILE".to_string()
1650                    ],
1651                ),
1652                (ProblemKind::EnvLoad, env("APP_OAUTH_AUDIENCE_FILE")),
1653                (ProblemKind::EnvParse, env("APP_OAUTH_LEEWAY_SECS")),
1654                (ProblemKind::EnvParse, env("APP_OAUTH_REQUIRE_AT_JWT")),
1655                (
1656                    ProblemKind::EnvLoad,
1657                    env("APP_OAUTH_ALLOW_UNSCOPED_TOKENS_FILE")
1658                ),
1659            ]
1660        );
1661        // The strings are rendered from the same list, in the same order.
1662        let texts: Vec<&str> = loaded
1663            .problem_details()
1664            .iter()
1665            .map(ConfigProblem::message)
1666            .collect();
1667        assert_eq!(texts, loaded.problems);
1668
1669        // `resolve` carries them, then its own, into the `ConfigError`.
1670        let err = loaded.resolve().unwrap_err();
1671        assert_eq!(err.problems.len(), err.problem_details().len());
1672        assert_eq!(err.problem_details()[0].kind(), ProblemKind::EnvLoad);
1673        assert_eq!(err.problem_details()[2].kind(), ProblemKind::EnvParse);
1674        let texts: Vec<&str> = err
1675            .problem_details()
1676            .iter()
1677            .map(ConfigProblem::message)
1678            .collect();
1679        assert_eq!(texts, err.problems);
1680    }
1681
1682    #[test]
1683    fn env_oauth_config_equality_ignores_the_structured_details() {
1684        let vars = HashMap::from([
1685            ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1686            ("APP_OAUTH_ALLOW_INSECURE_HTTP", "maybe"),
1687        ]);
1688        let files = HashMap::new();
1689        let load = || {
1690            unresolved_oauth_config_from_lookup(
1691                "APP_OAUTH_",
1692                lookup_from(&vars),
1693                files_from(&files),
1694            )
1695            .unwrap()
1696        };
1697        let a = load();
1698        let mut b = load();
1699        assert_eq!(a, b);
1700        // Same public fields, different details: still equal, as in 0.1.2.
1701        b.details = vec![ConfigProblem::from(a.problems[0].clone())];
1702        assert_eq!(a, b);
1703        // A differing public field is not.
1704        b.problems.push("extra".into());
1705        assert_ne!(a, b);
1706    }
1707
1708    #[test]
1709    fn an_enabled_that_is_not_a_bool_is_an_env_parse_problem() {
1710        let vars = HashMap::from([("APP_OAUTH_ENABLED", "maybe")]);
1711        let files = HashMap::new();
1712        let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1713            .unwrap_err();
1714        let p = &err.problem_details()[0];
1715        assert_eq!(p.kind(), ProblemKind::EnvParse);
1716        assert_eq!(p.keys(), ["APP_OAUTH_ENABLED"]);
1717        // resolve's own problems keep their kinds and env-spelled keys.
1718        assert!(
1719            err.problem_details()
1720                .iter()
1721                .any(|p| p.kind() == ProblemKind::MissingRequired
1722                    && p.keys().contains(&"APP_OAUTH_ISSUER".to_string()))
1723        );
1724    }
1725
1726    #[test]
1727    fn edited_problems_still_reach_resolve_as_other_and_matches_keep_their_kind() {
1728        let vars = HashMap::from([
1729            ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1730            ("APP_OAUTH_AUDIENCE", "client-id"),
1731            ("APP_OAUTH_RESOURCE", "https://kb.example.test/"),
1732            ("APP_OAUTH_ALLOW_UNSCOPED_TOKENS", "maybe"),
1733        ]);
1734        let files = HashMap::new();
1735        let mut loaded = unresolved_oauth_config_from_lookup(
1736            "APP_OAUTH_",
1737            lookup_from(&vars),
1738            files_from(&files),
1739        )
1740        .unwrap();
1741        loaded.problems.insert(0, "app-side problem".into());
1742        // `problem_details` is fixed at load time.
1743        assert_eq!(loaded.problem_details().len(), 1);
1744        let err = loaded.resolve().unwrap_err();
1745        assert_eq!(err.problems[0], "app-side problem");
1746        assert_eq!(err.problem_details()[0].kind(), ProblemKind::Other);
1747        assert_eq!(err.problem_details()[1].kind(), ProblemKind::EnvParse);
1748    }
1749
1750    #[test]
1751    fn whitespace_lists_are_split() {
1752        let vars = HashMap::from([
1753            ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1754            ("APP_OAUTH_AUDIENCE", "client-id"),
1755            ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
1756            ("APP_OAUTH_REQUIRED_SCOPES", "  api:read   api:write  "),
1757            ("APP_OAUTH_AUDIENCES", "extra-aud another-aud"),
1758            ("APP_OAUTH_ALGORITHMS", "RS256 ES256"),
1759        ]);
1760        let files = HashMap::new();
1761        let resolved =
1762            oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1763                .unwrap()
1764                .unwrap();
1765        assert_eq!(resolved.required_scopes, ["api:read", "api:write"]);
1766        assert_eq!(
1767            resolved.accepted_audiences(),
1768            ["client-id", "extra-aud", "another-aud"]
1769        );
1770        assert!(resolved.algorithms.contains(&crate::Algorithm::RS256));
1771        assert!(resolved.algorithms.contains(&crate::Algorithm::ES256));
1772    }
1773
1774    #[test]
1775    fn parse_errors_for_bool_and_integer_fields_are_aggregated_with_other_problems() {
1776        let vars = HashMap::from([
1777            ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1778            ("APP_OAUTH_AUDIENCE", "client-id"),
1779            ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
1780            ("APP_OAUTH_REQUIRED_SCOPE", "api:read"),
1781            ("APP_OAUTH_LEEWAY_SECS", "not-a-number"),
1782            ("APP_OAUTH_REQUIRE_AT_JWT", "yes"),
1783            ("APP_OAUTH_ACCEPT_STATIC_BEARER", "0"),
1784        ]);
1785        let files = HashMap::new();
1786        let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1787            .unwrap_err();
1788        assert_eq!(err.problems.len(), 3, "{:?}", err.problems);
1789        let text = err.to_string();
1790        assert!(text.contains("APP_OAUTH_LEEWAY_SECS"), "{text}");
1791        assert!(text.contains("not-a-number"), "{text}");
1792        assert!(text.contains("APP_OAUTH_REQUIRE_AT_JWT"), "{text}");
1793        assert!(text.contains("APP_OAUTH_ACCEPT_STATIC_BEARER"), "{text}");
1794    }
1795
1796    #[test]
1797    fn strict_bool_parsing_accepts_only_true_and_false() {
1798        for good in ["true", "false"] {
1799            let vars = HashMap::from([
1800                ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1801                ("APP_OAUTH_AUDIENCE", "client-id"),
1802                ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
1803                ("APP_OAUTH_REQUIRED_SCOPE", "api:read"),
1804                ("APP_OAUTH_REQUIRE_AT_JWT", good),
1805            ]);
1806            let files = HashMap::new();
1807            let resolved =
1808                oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1809                    .unwrap()
1810                    .unwrap();
1811            assert_eq!(resolved.require_at_jwt, good == "true");
1812        }
1813        for bad in ["True", "FALSE", "1", "0", "yes"] {
1814            let vars = HashMap::from([
1815                ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1816                ("APP_OAUTH_AUDIENCE", "client-id"),
1817                ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
1818                ("APP_OAUTH_REQUIRED_SCOPE", "api:read"),
1819                ("APP_OAUTH_REQUIRE_AT_JWT", bad),
1820            ]);
1821            let files = HashMap::new();
1822            let result =
1823                oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files));
1824            assert!(result.is_err(), "{bad:?} should be rejected");
1825        }
1826        // An empty value reads as unset, not a parse failure.
1827        let vars = HashMap::from([
1828            ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1829            ("APP_OAUTH_AUDIENCE", "client-id"),
1830            ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
1831            ("APP_OAUTH_REQUIRED_SCOPE", "api:read"),
1832            ("APP_OAUTH_REQUIRE_AT_JWT", ""),
1833        ]);
1834        let files = HashMap::new();
1835        let resolved =
1836            oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1837                .unwrap()
1838                .unwrap();
1839        assert!(!resolved.require_at_jwt);
1840    }
1841
1842    #[test]
1843    fn the_explicit_opt_ins_are_read_as_strict_booleans() {
1844        let files = HashMap::new();
1845        let vars = HashMap::from([
1846            ("APP_OAUTH_ISSUER", "http://idp.internal.test/"),
1847            ("APP_OAUTH_AUDIENCE", "client-id"),
1848            ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
1849            ("APP_OAUTH_ALLOW_UNSCOPED_TOKENS", "true"),
1850            ("APP_OAUTH_ALLOW_INSECURE_HTTP", "true"),
1851        ]);
1852        let resolved =
1853            oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1854                .unwrap()
1855                .unwrap();
1856        assert!(resolved.allow_unscoped_tokens && resolved.allow_insecure_http);
1857
1858        // Without them, both are problems — named by variable.
1859        let mut vars = vars;
1860        vars.remove("APP_OAUTH_ALLOW_UNSCOPED_TOKENS");
1861        vars.insert("APP_OAUTH_ALLOW_INSECURE_HTTP", "yes");
1862        let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1863            .unwrap_err();
1864        let text = err.to_string();
1865        assert!(
1866            text.contains("APP_OAUTH_ALLOW_INSECURE_HTTP \"yes\" must be"),
1867            "{text}"
1868        );
1869        assert!(
1870            text.contains("APP_OAUTH_ISSUER \"http://idp.internal.test/\" uses plain http"),
1871            "{text}"
1872        );
1873        assert!(
1874            text.contains("set APP_OAUTH_ALLOW_UNSCOPED_TOKENS"),
1875            "{text}"
1876        );
1877    }
1878
1879    #[test]
1880    fn problem_messages_are_named_with_the_configured_prefix() {
1881        let vars = HashMap::from([("MYAPP_OAUTH_ISSUER", "https://idp.example.test/")]);
1882        let files = HashMap::new();
1883        let err = oauth_config_from_lookup("MYAPP_OAUTH_", lookup_from(&vars), files_from(&files))
1884            .unwrap_err();
1885        let text = err.to_string();
1886        assert!(text.starts_with(
1887            "OAuth is configured through MYAPP_OAUTH_* but the config is not usable:"
1888        ));
1889        assert!(text.contains("MYAPP_OAUTH_AUDIENCE"), "{text}");
1890        assert!(text.contains("MYAPP_OAUTH_RESOURCE"), "{text}");
1891        assert!(!text.contains("mcp.oauth"), "{text}");
1892    }
1893
1894    // ── unresolved_oauth_config_from_lookup / EnvOAuthConfig ────────────────
1895
1896    /// A complete set with no scope configured.
1897    fn base_vars() -> HashMap<&'static str, &'static str> {
1898        HashMap::from([
1899            ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1900            ("APP_OAUTH_AUDIENCE", "client-id"),
1901            ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
1902        ])
1903    }
1904
1905    /// The pattern `EnvOAuthConfig`'s docs show: an app default for the
1906    /// required scope, applied only when the operator named none.
1907    fn with_app_scope_default(mut loaded: EnvOAuthConfig) -> EnvOAuthConfig {
1908        let cfg = &mut loaded.config;
1909        if cfg.required_scope.is_none() && cfg.required_scopes.is_empty() {
1910            cfg.required_scope = Some("mcp:read".into());
1911        }
1912        loaded
1913    }
1914
1915    #[test]
1916    fn unresolved_is_none_exactly_when_oauth_is_off() {
1917        let files = HashMap::new();
1918        let nothing = HashMap::new();
1919        assert_eq!(
1920            unresolved_oauth_config_from_lookup(
1921                "APP_OAUTH_",
1922                lookup_from(&nothing),
1923                files_from(&files)
1924            ),
1925            None
1926        );
1927        let mut disabled = base_vars();
1928        disabled.insert("APP_OAUTH_ENABLED", "false");
1929        assert_eq!(
1930            unresolved_oauth_config_from_lookup(
1931                "APP_OAUTH_",
1932                lookup_from(&disabled),
1933                files_from(&files)
1934            ),
1935            None
1936        );
1937        let loaded = unresolved_oauth_config_from_lookup(
1938            "APP_OAUTH_",
1939            lookup_from(&base_vars()),
1940            files_from(&files),
1941        )
1942        .expect("identifying vars are set");
1943        assert!(loaded.config.enabled);
1944        assert_eq!(loaded.config.issuer, "https://idp.example.test/");
1945        assert_eq!(
1946            loaded.config.scopes_supported, None,
1947            "default not yet applied"
1948        );
1949        assert!(loaded.problems.is_empty());
1950        assert_eq!(loaded.prefix(), "APP_OAUTH_");
1951    }
1952
1953    #[test]
1954    fn an_app_scope_default_is_enforced_and_advertised() {
1955        let files = HashMap::new();
1956        let loaded = unresolved_oauth_config_from_lookup(
1957            "APP_OAUTH_",
1958            lookup_from(&base_vars()),
1959            files_from(&files),
1960        )
1961        .unwrap();
1962        let resolved = with_app_scope_default(loaded).resolve().unwrap().unwrap();
1963        assert_eq!(resolved.required_scopes, ["mcp:read"]);
1964        // The scopes_supported default runs at resolve, so it sees the app's
1965        // default — the metadata and challenge advertise it.
1966        assert_eq!(resolved.scopes_supported, ["mcp:read"]);
1967    }
1968
1969    #[test]
1970    fn an_operator_scope_wins_over_the_app_default() {
1971        let mut vars = base_vars();
1972        vars.insert("APP_OAUTH_REQUIRED_SCOPE", "api:read");
1973        let files = HashMap::new();
1974        let loaded = unresolved_oauth_config_from_lookup(
1975            "APP_OAUTH_",
1976            lookup_from(&vars),
1977            files_from(&files),
1978        )
1979        .unwrap();
1980        let resolved = with_app_scope_default(loaded).resolve().unwrap().unwrap();
1981        assert_eq!(resolved.required_scopes, ["api:read"]);
1982        assert_eq!(resolved.scopes_supported, ["api:read"]);
1983    }
1984
1985    #[test]
1986    fn an_app_default_goes_through_resolves_validation() {
1987        let files = HashMap::new();
1988        let mut loaded = unresolved_oauth_config_from_lookup(
1989            "APP_OAUTH_",
1990            lookup_from(&base_vars()),
1991            files_from(&files),
1992        )
1993        .unwrap();
1994        loaded.config.required_scope = Some("mcp:read mcp:write".into());
1995        let err = loaded.resolve().unwrap_err();
1996        assert!(
1997            err.problems
1998                .iter()
1999                .any(|p| p.contains("APP_OAUTH_REQUIRED_SCOPE") && p.contains("single scope")),
2000            "{:?}",
2001            err.problems
2002        );
2003    }
2004
2005    #[test]
2006    fn an_app_set_explicit_empty_scopes_supported_is_kept() {
2007        let mut vars = base_vars();
2008        vars.insert("APP_OAUTH_REQUIRED_SCOPE", "api:read");
2009        let files = HashMap::new();
2010        let mut loaded = unresolved_oauth_config_from_lookup(
2011            "APP_OAUTH_",
2012            lookup_from(&vars),
2013            files_from(&files),
2014        )
2015        .unwrap();
2016        loaded.config.scopes_supported = Some(vec![]);
2017        let resolved = loaded.resolve().unwrap().unwrap();
2018        assert!(resolved.scopes_supported.is_empty());
2019    }
2020
2021    #[test]
2022    fn loader_problems_are_carried_and_reported_first_by_resolve() {
2023        let mut vars = base_vars();
2024        vars.insert("APP_OAUTH_LEEWAY_SECS", "soon");
2025        vars.remove("APP_OAUTH_RESOURCE");
2026        let files = HashMap::new();
2027        let loaded = unresolved_oauth_config_from_lookup(
2028            "APP_OAUTH_",
2029            lookup_from(&vars),
2030            files_from(&files),
2031        )
2032        .unwrap();
2033        assert_eq!(loaded.problems.len(), 1, "{:?}", loaded.problems);
2034        assert!(loaded.problems[0].contains("APP_OAUTH_LEEWAY_SECS"));
2035        let err = loaded.resolve().unwrap_err();
2036        assert!(
2037            err.problems[0].contains("APP_OAUTH_LEEWAY_SECS"),
2038            "{:?}",
2039            err.problems
2040        );
2041        assert!(
2042            err.problems[1..]
2043                .iter()
2044                .any(|p| p.contains("APP_OAUTH_RESOURCE")),
2045            "{:?}",
2046            err.problems
2047        );
2048    }
2049
2050    #[test]
2051    fn loader_problems_fail_resolve_even_when_the_config_is_otherwise_valid() {
2052        let mut vars = base_vars();
2053        vars.insert("APP_OAUTH_REQUIRED_SCOPE", "api:read");
2054        vars.insert("APP_OAUTH_REQUIRE_AT_JWT", "yes");
2055        let files = HashMap::new();
2056        let loaded = unresolved_oauth_config_from_lookup(
2057            "APP_OAUTH_",
2058            lookup_from(&vars),
2059            files_from(&files),
2060        )
2061        .unwrap();
2062        let err = loaded.resolve().unwrap_err();
2063        assert_eq!(err.problems.len(), 1, "{:?}", err.problems);
2064        assert!(err.problems[0].contains("APP_OAUTH_REQUIRE_AT_JWT"));
2065    }
2066
2067    #[test]
2068    fn unresolved_oauth_config_from_env_wraps_the_real_environment() {
2069        assert_eq!(
2070            unresolved_oauth_config_from_env("OAUTH_RESOURCE_SERVER_ENV_RS_TEST_UNSET_9f3c_"),
2071            None
2072        );
2073    }
2074
2075    #[test]
2076    fn oauth_config_from_env_wraps_the_real_environment() {
2077        // No var of this prefix is plausibly set in CI; this only exercises
2078        // that the plain function delegates without panicking.
2079        assert_eq!(
2080            oauth_config_from_env("OAUTH_RESOURCE_SERVER_ENV_RS_TEST_UNSET_9f3c_"),
2081            Ok(None)
2082        );
2083    }
2084
2085    // ── allowed_client_ids, max_token_age_secs, required_claims ─────────────
2086
2087    fn policy_vars() -> HashMap<&'static str, &'static str> {
2088        HashMap::from([
2089            ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
2090            ("APP_OAUTH_AUDIENCE", "client-a"),
2091            ("APP_OAUTH_RESOURCE", "https://kb.example.test/"),
2092            ("APP_OAUTH_REQUIRED_SCOPE", "api:read"),
2093        ])
2094    }
2095
2096    #[test]
2097    fn the_claim_policy_settings_load_from_their_variables() {
2098        let mut vars = policy_vars();
2099        vars.insert("APP_OAUTH_ALLOWED_CLIENT_IDS", "client-a  client-b");
2100        vars.insert("APP_OAUTH_MAX_TOKEN_AGE_SECS", "3600");
2101        vars.insert(
2102            "APP_OAUTH_REQUIRED_CLAIMS",
2103            r#"{"tid": "tenant-1", "level": 2, "mfa": true}"#,
2104        );
2105        let files = HashMap::new();
2106        let resolved =
2107            oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
2108                .unwrap()
2109                .unwrap();
2110        assert_eq!(resolved.allowed_client_ids, ["client-a", "client-b"]);
2111        assert_eq!(resolved.max_token_age_secs, Some(3600));
2112        assert_eq!(
2113            resolved.required_claims,
2114            [
2115                ("level".to_string(), serde_json::json!(2)),
2116                ("mfa".to_string(), serde_json::json!(true)),
2117                ("tid".to_string(), serde_json::json!("tenant-1")),
2118            ]
2119            .into_iter()
2120            .collect()
2121        );
2122
2123        // Unset: every one stays off.
2124        let vars = policy_vars();
2125        let resolved =
2126            oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
2127                .unwrap()
2128                .unwrap();
2129        assert!(resolved.allowed_client_ids.is_empty());
2130        assert_eq!(resolved.max_token_age_secs, None);
2131        assert!(resolved.required_claims.is_empty());
2132    }
2133
2134    #[test]
2135    fn claim_policy_parse_and_resolve_problems_are_reported_together() {
2136        let mut vars = policy_vars();
2137        vars.insert("APP_OAUTH_MAX_TOKEN_AGE_SECS", "an hour");
2138        vars.insert("APP_OAUTH_REQUIRED_CLAIMS", r#"["tid"]"#);
2139        let files = HashMap::new();
2140        let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
2141            .unwrap_err();
2142        let got: Vec<_> = err
2143            .problem_details()
2144            .iter()
2145            .map(|p| (p.kind(), p.keys().to_vec()))
2146            .collect();
2147        assert_eq!(
2148            got,
2149            [
2150                (
2151                    ProblemKind::EnvParse,
2152                    vec!["APP_OAUTH_MAX_TOKEN_AGE_SECS".to_string()]
2153                ),
2154                (
2155                    ProblemKind::EnvParse,
2156                    vec!["APP_OAUTH_REQUIRED_CLAIMS".to_string()]
2157                ),
2158            ]
2159        );
2160        assert!(err.problems[1].contains("must be a JSON object"), "{err}");
2161
2162        // Not JSON at all.
2163        let mut vars = policy_vars();
2164        vars.insert("APP_OAUTH_REQUIRED_CLAIMS", "tid=tenant-1");
2165        let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
2166            .unwrap_err();
2167        assert_eq!(err.problem_details()[0].kind(), ProblemKind::EnvParse);
2168        assert!(err.problems[0].contains("is not valid JSON"), "{err}");
2169
2170        // Loaded fine, refused by `resolve`, named as variables — every one at
2171        // once.
2172        let mut vars = policy_vars();
2173        vars.insert("APP_OAUTH_ALLOWED_CLIENT_IDS", "client-a");
2174        vars.insert("APP_OAUTH_MAX_TOKEN_AGE_SECS", "0");
2175        vars.insert(
2176            "APP_OAUTH_REQUIRED_CLAIMS",
2177            r#"{"aud": "x", "org": {"id": 1}}"#,
2178        );
2179        let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
2180            .unwrap_err();
2181        let got: Vec<_> = err
2182            .problem_details()
2183            .iter()
2184            .map(|p| (p.kind(), p.keys().to_vec()))
2185            .collect();
2186        assert_eq!(
2187            got,
2188            [
2189                (
2190                    ProblemKind::TokenAgeOutOfRange,
2191                    vec!["APP_OAUTH_MAX_TOKEN_AGE_SECS".to_string()]
2192                ),
2193                (
2194                    ProblemKind::InvalidRequiredClaim,
2195                    vec!["APP_OAUTH_REQUIRED_CLAIMS".to_string()]
2196                ),
2197                (
2198                    ProblemKind::InvalidRequiredClaim,
2199                    vec!["APP_OAUTH_REQUIRED_CLAIMS".to_string()]
2200                ),
2201            ]
2202        );
2203    }
2204
2205    // ── _FILE hardening, duplicates and blank scopes ─────────────────────────
2206
2207    /// A path in the system temp directory unique to this test and process.
2208    fn temp_path(name: &str) -> std::path::PathBuf {
2209        std::env::temp_dir().join(format!(
2210            "oauth-resource-server-{}-{name}",
2211            std::process::id()
2212        ))
2213    }
2214
2215    #[test]
2216    fn a_file_var_naming_a_directory_is_not_a_file_and_is_never_read() {
2217        let dir = std::env::temp_dir();
2218        let dir = dir.to_str().unwrap().to_string();
2219        let lookup = |name: &str| (name == "KEY_FILE").then(|| dir.clone());
2220        match secret_from_lookup("KEY", lookup, read_secret_file) {
2221            Err(EnvError::NotAFile { var, path }) => {
2222                assert_eq!((var.as_str(), path.as_str()), ("KEY", dir.as_str()));
2223            }
2224            other => panic!("{other:?}"),
2225        }
2226        let err = secret_from_lookup("KEY", lookup, read_secret_file).unwrap_err();
2227        assert_eq!(
2228            err.to_string(),
2229            format!("KEY_FILE={dir}: not a regular file")
2230        );
2231        assert_eq!(error_keys(&err), ["KEY_FILE"]);
2232    }
2233
2234    #[test]
2235    fn a_file_over_the_cap_is_refused_and_one_at_the_cap_is_read() {
2236        let over = temp_path("over-cap");
2237        let at = temp_path("at-cap");
2238        std::fs::write(&over, "x".repeat(MAX_SECRET_FILE_BYTES + 1)).unwrap();
2239        std::fs::write(&at, "y".repeat(MAX_SECRET_FILE_BYTES)).unwrap();
2240        let over_s = over.to_str().unwrap().to_string();
2241        let at_s = at.to_str().unwrap().to_string();
2242        let result_over = secret_from_lookup(
2243            "KEY",
2244            |n: &str| (n == "KEY_FILE").then(|| over_s.clone()),
2245            read_secret_file,
2246        );
2247        let result_at = secret_from_lookup(
2248            "KEY",
2249            |n: &str| (n == "KEY_FILE").then(|| at_s.clone()),
2250            read_secret_file,
2251        );
2252        let _ = std::fs::remove_file(&over);
2253        let _ = std::fs::remove_file(&at);
2254        match result_over {
2255            Err(EnvError::FileTooLarge { var, path }) => {
2256                assert_eq!((var.as_str(), path.as_str()), ("KEY", over_s.as_str()));
2257            }
2258            other => panic!("{:?}", other.map(|v| v.map(|s| s.len()))),
2259        }
2260        assert_eq!(result_at.unwrap().unwrap().len(), MAX_SECRET_FILE_BYTES);
2261
2262        // An injected reader is held to the same cap.
2263        let big = "z".repeat(MAX_SECRET_FILE_BYTES + 1);
2264        let err = secret_from_lookup(
2265            "KEY",
2266            |n: &str| (n == "KEY_FILE").then(|| "/run/secrets/key".to_string()),
2267            |_: &str| Ok(big.clone()),
2268        )
2269        .unwrap_err();
2270        assert!(matches!(err, EnvError::FileTooLarge { .. }), "{err:?}");
2271        // ...and a config variable's file too, reported as an env problem.
2272        let vars = HashMap::from([
2273            ("APP_OAUTH_ISSUER_FILE", "/run/secrets/issuer"),
2274            ("APP_OAUTH_AUDIENCE", "client-a"),
2275            ("APP_OAUTH_RESOURCE", "https://kb.example.test/"),
2276        ]);
2277        let err =
2278            oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), |_: &str| Ok(big.clone()))
2279                .unwrap_err();
2280        assert_eq!(err.problem_details()[0].kind(), ProblemKind::EnvLoad);
2281        assert_eq!(err.problem_details()[0].keys(), ["APP_OAUTH_ISSUER_FILE"]);
2282    }
2283
2284    #[test]
2285    fn a_readable_small_file_still_reads_and_trims() {
2286        let path = temp_path("small");
2287        std::fs::write(&path, "s3cret\n").unwrap();
2288        let p = path.to_str().unwrap().to_string();
2289        let got = secret_from_lookup(
2290            "KEY",
2291            |n: &str| (n == "KEY_FILE").then(|| p.clone()),
2292            read_secret_file,
2293        );
2294        let missing = secret_from_lookup(
2295            "KEY",
2296            |n: &str| (n == "KEY_FILE").then(|| format!("{p}-missing")),
2297            read_secret_file,
2298        );
2299        let _ = std::fs::remove_file(&path);
2300        assert_eq!(got.unwrap().as_deref(), Some("s3cret"));
2301        assert!(
2302            matches!(missing, Err(EnvError::ReadFailed { .. })),
2303            "{missing:?}"
2304        );
2305    }
2306
2307    /// `serde_json::Map` keeps the last of a repeated key; `REQUIRED_CLAIMS`
2308    /// naming a claim twice is refused instead, never read as either value.
2309    #[test]
2310    fn a_claim_named_twice_in_required_claims_is_refused() {
2311        let files = HashMap::new();
2312        for json in [
2313            r#"{"tid": "good", "tid": "evil"}"#,
2314            r#"{"a": 1, "tid": "good", "b": true, "tid": "good"}"#,
2315        ] {
2316            let mut vars = policy_vars();
2317            vars.insert("APP_OAUTH_REQUIRED_CLAIMS", json);
2318            let err =
2319                oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
2320                    .unwrap_err();
2321            let details = err.problem_details();
2322            assert_eq!(details.len(), 1, "{err}");
2323            assert_eq!(details[0].kind(), ProblemKind::InvalidRequiredClaim);
2324            assert_eq!(details[0].keys(), ["APP_OAUTH_REQUIRED_CLAIMS"]);
2325            assert!(err.problems[0].contains("\"tid\" more than once"), "{err}");
2326            assert!(!err.problems[0].contains("evil"), "{err}");
2327        }
2328    }
2329
2330    /// A whitespace-only `REQUIRED_SCOPE` is the config file's
2331    /// `BlankRequiredScope`, not "unset" — which, with
2332    /// `ALLOW_UNSCOPED_TOKENS=true`, would have meant no scope check at all.
2333    #[test]
2334    fn a_blank_required_scope_variable_is_an_error_not_unset() {
2335        let files = HashMap::new();
2336        for allow_unscoped in [None, Some("true")] {
2337            let mut vars = policy_vars();
2338            vars.insert("APP_OAUTH_REQUIRED_SCOPE", "   ");
2339            if let Some(v) = allow_unscoped {
2340                vars.insert("APP_OAUTH_ALLOW_UNSCOPED_TOKENS", v);
2341            }
2342            let err =
2343                oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
2344                    .unwrap_err();
2345            let kinds: Vec<_> = err.problem_details().iter().map(|p| p.kind()).collect();
2346            assert!(kinds.contains(&ProblemKind::BlankRequiredScope), "{err}");
2347            assert!(
2348                err.problem_details()
2349                    .iter()
2350                    .any(|p| p.keys() == ["APP_OAUTH_REQUIRED_SCOPE"]),
2351                "{err}"
2352            );
2353        }
2354        // Empty is still plain unset.
2355        let mut vars = policy_vars();
2356        vars.insert("APP_OAUTH_REQUIRED_SCOPE", "");
2357        vars.insert("APP_OAUTH_REQUIRE_AT_JWT", "true");
2358        assert!(
2359            oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
2360                .unwrap()
2361                .unwrap()
2362                .required_scopes
2363                .is_empty()
2364        );
2365    }
2366
2367    // ── static_tokens_from_lookup ────────────────────────────────────────────
2368
2369    fn tokens(
2370        vars: &[(&'static str, &'static str)],
2371        files: &[(&'static str, &'static str)],
2372    ) -> Result<Option<StaticTokens>, EnvError> {
2373        let vars: HashMap<_, _> = vars.iter().copied().collect();
2374        let files: HashMap<_, _> = files.iter().copied().collect();
2375        static_tokens_from_lookup("KEY", lookup_from(&vars), files_from(&files))
2376    }
2377
2378    fn labels(set: &StaticTokens) -> Vec<Option<&str>> {
2379        set.labels().collect()
2380    }
2381
2382    /// Which label, if any, accepts `candidate`.
2383    fn accepts(set: &StaticTokens, candidate: &str) -> Option<Option<String>> {
2384        let rt = tokio::runtime::Builder::new_current_thread()
2385            .build()
2386            .unwrap();
2387        rt.block_on(crate::authenticate_with_static_tokens(
2388            [candidate],
2389            Some(set),
2390            None,
2391        ))
2392        .ok()
2393        .and_then(|(_, m)| m)
2394        .map(|m| m.label().map(str::to_string))
2395    }
2396
2397    #[test]
2398    fn static_tokens_absent_is_none() {
2399        assert!(tokens(&[], &[]).unwrap().is_none());
2400        assert!(
2401            tokens(&[("KEY", "  "), ("KEY_NEXT", "")], &[])
2402                .unwrap()
2403                .is_none()
2404        );
2405    }
2406
2407    #[test]
2408    fn static_tokens_var_only_is_the_current_key() {
2409        let set = tokens(&[("KEY", " old\n")], &[]).unwrap().unwrap();
2410        assert_eq!(labels(&set), [Some("current")]);
2411        assert_eq!(accepts(&set, "old"), Some(Some("current".into())));
2412        assert_eq!(accepts(&set, " old\n"), None, "trimmed, as secret_from_env");
2413    }
2414
2415    #[test]
2416    fn static_tokens_var_and_next_are_both_accepted() {
2417        let set = tokens(&[("KEY", "old"), ("KEY_NEXT", "new")], &[])
2418            .unwrap()
2419            .unwrap();
2420        assert_eq!(
2421            labels(&set),
2422            [Some(CURRENT_KEY_LABEL), Some(NEXT_KEY_LABEL)]
2423        );
2424        assert_eq!(accepts(&set, "old"), Some(Some("current".into())));
2425        assert_eq!(accepts(&set, "new"), Some(Some("next".into())));
2426        assert_eq!(accepts(&set, "other"), None);
2427        // The promotion step: both hold the new key, which is one entry.
2428        let set = tokens(&[("KEY", "new"), ("KEY_NEXT", "new")], &[])
2429            .unwrap()
2430            .unwrap();
2431        assert_eq!(labels(&set), [Some("current")]);
2432    }
2433
2434    #[test]
2435    fn static_tokens_file_forms() {
2436        let set = tokens(
2437            &[("KEY_FILE", "/run/k"), ("KEY_NEXT_FILE", "/run/k_next")],
2438            &[("/run/k", "old\n"), ("/run/k_next", "new\n")],
2439        )
2440        .unwrap()
2441        .unwrap();
2442        assert_eq!(accepts(&set, "old"), Some(Some("current".into())));
2443        assert_eq!(accepts(&set, "new"), Some(Some("next".into())));
2444        // Mixed: current from a variable, next from a file.
2445        let set = tokens(
2446            &[("KEY", "old"), ("KEY_NEXT_FILE", "/run/k_next")],
2447            &[("/run/k_next", "new\n")],
2448        )
2449        .unwrap()
2450        .unwrap();
2451        assert_eq!(labels(&set), [Some("current"), Some("next")]);
2452    }
2453
2454    #[test]
2455    fn static_tokens_both_forms_set_is_an_error() {
2456        let err = tokens(&[("KEY", "a"), ("KEY_FILE", "/run/k")], &[("/run/k", "b")]).unwrap_err();
2457        assert!(
2458            matches!(&err, EnvError::BothSet { var, .. } if var == "KEY"),
2459            "{err:?}"
2460        );
2461        let err = tokens(
2462            &[("KEY", "a"), ("KEY_NEXT", "b"), ("KEY_NEXT_FILE", "/run/n")],
2463            &[("/run/n", "c")],
2464        )
2465        .unwrap_err();
2466        assert!(
2467            matches!(&err, EnvError::BothSet { var, .. } if var == "KEY_NEXT"),
2468            "{err:?}"
2469        );
2470    }
2471
2472    #[test]
2473    fn static_tokens_empty_file_is_an_error() {
2474        let err = tokens(&[("KEY_FILE", "/run/k")], &[("/run/k", " \n")]).unwrap_err();
2475        assert!(
2476            matches!(&err, EnvError::EmptyFile { var, .. } if var == "KEY"),
2477            "{err:?}"
2478        );
2479        let err = tokens(
2480            &[("KEY", "a"), ("KEY_NEXT_FILE", "/run/n")],
2481            &[("/run/n", "")],
2482        )
2483        .unwrap_err();
2484        assert!(
2485            matches!(&err, EnvError::EmptyFile { var, .. } if var == "KEY_NEXT"),
2486            "{err:?}"
2487        );
2488    }
2489
2490    #[test]
2491    fn static_tokens_next_without_current_is_an_error() {
2492        let err = tokens(&[("KEY_NEXT", "new")], &[]).unwrap_err();
2493        assert!(
2494            matches!(&err, EnvError::NextWithoutCurrent { var } if var == "KEY"),
2495            "{err:?}"
2496        );
2497        assert!(err.to_string().contains("KEY_NEXT is set but KEY is not"));
2498        assert!(!err.to_string().contains("new") && !format!("{err:?}").contains("\"new\""));
2499    }
2500
2501    #[test]
2502    fn static_tokens_report_both_failures_at_once() {
2503        let err = tokens(
2504            &[
2505                ("KEY", "s3cret-a"),
2506                ("KEY_FILE", "/run/k"),
2507                ("KEY_NEXT_FILE", "/run/missing"),
2508            ],
2509            &[("/run/k", "s3cret-b")],
2510        )
2511        .unwrap_err();
2512        let EnvError::Several { errors } = &err else {
2513            panic!("expected Several, got {err:?}");
2514        };
2515        assert!(matches!(errors[0], EnvError::BothSet { .. }));
2516        assert!(matches!(errors[1], EnvError::ReadFailed { .. }));
2517        let text = err.to_string();
2518        assert!(text.contains("KEY and KEY_FILE are both set"), "{text}");
2519        assert!(
2520            text.contains("KEY_NEXT_FILE=/run/missing: failed to read secret file: no such file"),
2521            "{text}"
2522        );
2523        assert!(!text.contains("s3cret") && !format!("{err:?}").contains("s3cret"));
2524    }
2525
2526    #[test]
2527    fn static_tokens_debug_prints_labels_only() {
2528        let set = tokens(&[("KEY", "s3cret-a"), ("KEY_NEXT", "s3cret-b")], &[])
2529            .unwrap()
2530            .unwrap();
2531        let rendered = format!("{set:?}");
2532        assert!(
2533            !rendered.contains("s3cret") && rendered.contains("next"),
2534            "{rendered}"
2535        );
2536    }
2537}