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