Skip to main content

oauth_resource_server/
authenticate.rs

1//! Transport-agnostic credential checking: static tokens and/or OAuth, over
2//! every candidate credential a request carries.
3//!
4//! This is the framework-free core of the axum middleware (feature `axum`). An
5//! application on another HTTP stack collects its candidate header values itself
6//! and calls [`authenticate`] (one static token) or
7//! [`authenticate_with_static_tokens`] (a [`StaticTokens`] set, reporting which
8//! entry matched); the status code and challenge for a failure follow from the
9//! returned [`TokenRejection`] (see its docs).
10
11use std::fmt;
12
13use subtle::{Choice, ConditionallySelectable, ConstantTimeEq};
14use zeroize::Zeroizing;
15
16use crate::token::{AuthorizedToken, InvalidToken, InvalidTokenKind, TokenRejection};
17use crate::validator::{CachedAttempt, OAuthValidator};
18
19// Counts every static-token comparison `find_static` makes, so a test can
20// prove no entry is skipped once one has matched. Test builds only.
21#[cfg(test)]
22thread_local! {
23    pub(crate) static STATIC_COMPARISONS: std::cell::Cell<usize> =
24        const { std::cell::Cell::new(0) };
25}
26
27/// Which mechanism accepted a request.
28///
29/// Deliberately not something to put in a response: a client should not be able
30/// to tell from the outcome which mechanism accepted (or refused) which of its
31/// credentials. The axum middleware inserts it into request extensions so a
32/// handler can, for example, attribute a write to an OAuth principal.
33// `OAuth` holds the token inline: boxing it would change a public variant's type
34// (a breaking change), for a value that exists once per request.
35#[allow(clippy::large_enum_variant)]
36#[derive(Debug, Clone, PartialEq, Eq)]
37#[non_exhaustive]
38pub enum Credential {
39    /// A candidate matched a configured static token. Which one, when several
40    /// are configured, is reported separately as a [`StaticTokenMatch`]: by
41    /// [`authenticate_with_static_tokens`], and in request extensions by the
42    /// layers.
43    StaticToken,
44    /// A candidate validated as an OAuth access token carrying every required
45    /// scope.
46    OAuth(AuthorizedToken),
47}
48
49/// A set of static API keys, each with an optional label: several keys
50/// accepted at once, for rotating a key with no downtime (the old and the new
51/// one both accepted until every client has moved) or for one key per client.
52///
53/// Opaque: nothing reads a secret back out of it. Build it with
54/// [`StaticTokens::single`] or [`StaticTokens::new`] and
55/// [`StaticTokens::with`]; hand it to [`authenticate_with_static_tokens`], or
56/// to a layer's `static_tokens` builder method (features `axum`/`tower`). The
57/// `env` feature's `static_tokens_from_env` loads a current and a next key.
58///
59/// # Rules
60///
61/// Checked by [`StaticTokens::with`], which refuses (never silently fixes) an
62/// entry that breaks one:
63///
64/// - A secret must not be empty or whitespace-only
65///   ([`StaticTokensError::BlankSecret`]). It is otherwise kept verbatim,
66///   untrimmed, like the single `static_token` everywhere else.
67/// - A secret must not repeat one already in the set
68///   ([`StaticTokensError::DuplicateSecret`]): two entries holding one secret
69///   would make the reported label depend on insertion order, and for
70///   per-client keys means two clients share a key, so it is refused rather
71///   than deduplicated.
72/// - A label is 1 to [`StaticTokens::MAX_LABEL_LEN`] (64) visible ASCII
73///   characters, `!` through `~` — no space, no control character, nothing
74///   else ([`StaticTokensError::InvalidLabel`]) — so it is always safe to put
75///   in a log line or a metric. Labels are not secrets.
76/// - A label must not repeat one already in the set
77///   ([`StaticTokensError::DuplicateLabel`]); any number of entries may be
78///   unlabeled.
79///
80/// # Security
81///
82/// Comparison is constant-time across entries: every candidate is compared
83/// with every entry, with no early exit once one matches, and the matching
84/// index is selected without a data-dependent branch (`subtle`). What is not
85/// hidden is length: each single comparison returns faster when the
86/// candidate's length differs from that entry's, exactly as the single-token
87/// comparison always has, so the total time depends on how many entries share
88/// the candidate's length (never on which entry matched, apart from cloning
89/// the matched label after the comparison, observable only by a holder of a
90/// valid key). Use high-entropy keys of one fixed length, and the length
91/// reveals nothing useful.
92///
93/// `Debug` prints the number of entries and their labels, never a secret. No
94/// `PartialEq`: comparing two sets would compare secrets in variable time.
95/// Each secret is held in a `zeroize::Zeroizing` buffer, wiped when the
96/// set (or a clone of it) is dropped; so are the layer builders' single
97/// `static_token`, and the `env` loaders' intermediate copies (untrimmed
98/// values, a next key equal to the current one). What is *not* wiped: the
99/// strings a caller passes in or keeps (`with`'s argument before it is
100/// moved in, `secret_from_env`'s returned `String`), a
101/// [`StaticTokenDecision`](crate::StaticTokenDecision)'s `String` payload,
102/// the process environment, and a secrets file. A `String`'s earlier
103/// buffers, left behind if it was grown before being handed over, are not
104/// wiped either.
105///
106/// # Examples
107///
108/// ```
109/// use oauth_resource_server::{StaticTokens, StaticTokensError};
110///
111/// # fn main() -> Result<(), StaticTokensError> {
112/// // Rotation: the current key and the one replacing it.
113/// let tokens = StaticTokens::new()
114///     .with(Some("current"), "example-key-2024")?
115///     .with(Some("next"), "example-key-2025")?;
116/// assert_eq!(tokens.len(), 2);
117/// assert!(!format!("{tokens:?}").contains("example-key"));
118///
119/// // Blank secrets and repeated secrets are refused.
120/// assert!(matches!(
121///     StaticTokens::single("  "),
122///     Err(StaticTokensError::BlankSecret { .. })
123/// ));
124/// assert!(matches!(
125///     tokens.with(None, "example-key-2025"),
126///     Err(StaticTokensError::DuplicateSecret { .. })
127/// ));
128/// # Ok(())
129/// # }
130/// ```
131#[derive(Clone, Default)]
132pub struct StaticTokens {
133    entries: Vec<StaticEntry>,
134}
135
136#[derive(Clone)]
137struct StaticEntry {
138    label: Option<String>,
139    secret: Zeroizing<String>,
140}
141
142/// Why [`StaticTokens::with`] (or [`StaticTokens::single`]) refused an entry.
143/// Each variant names the entry by its 0-based position in the set, and never
144/// includes a secret.
145#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
146#[non_exhaustive]
147pub enum StaticTokensError {
148    /// The secret was empty or whitespace-only; a blank secret must never be
149    /// matchable.
150    #[error("the static token at position {index} is empty or blank")]
151    #[non_exhaustive]
152    BlankSecret {
153        /// The refused entry's position.
154        index: usize,
155    },
156    /// The secret is already in the set, at position `first`.
157    #[error("the static token at position {index} repeats the one at position {first}")]
158    #[non_exhaustive]
159    DuplicateSecret {
160        /// The refused entry's position.
161        index: usize,
162        /// The position of the entry holding the same secret.
163        first: usize,
164    },
165    /// The label is empty, longer than [`StaticTokens::MAX_LABEL_LEN`], or
166    /// holds a character outside visible ASCII (`!` through `~`). The label
167    /// itself is not echoed, since it failed the rules that make it log-safe.
168    #[error(
169        "the label of the static token at position {index} must be 1 to 64 visible ASCII \
170         characters (no spaces)"
171    )]
172    #[non_exhaustive]
173    InvalidLabel {
174        /// The refused entry's position.
175        index: usize,
176    },
177    /// The label is already used by the entry at position `first`.
178    #[error(
179        "the label {label:?} of the static token at position {index} is already used at \
180         position {first}"
181    )]
182    #[non_exhaustive]
183    DuplicateLabel {
184        /// The refused entry's position.
185        index: usize,
186        /// The position of the entry with the same label.
187        first: usize,
188        /// The repeated label (valid, so log-safe).
189        label: String,
190    },
191}
192
193impl StaticTokens {
194    /// The longest label [`StaticTokens::with`] accepts, in bytes (every
195    /// accepted character is one byte).
196    pub const MAX_LABEL_LEN: usize = 64;
197
198    /// An empty set. Add entries with [`StaticTokens::with`]. An empty set
199    /// accepts nothing and, handed to a layer, does not count as a credential
200    /// mechanism.
201    pub fn new() -> Self {
202        Self::default()
203    }
204
205    /// A set holding one unlabeled secret: the same credential a single
206    /// `static_token` configures.
207    ///
208    /// # Errors
209    ///
210    /// [`StaticTokensError::BlankSecret`] for an empty or whitespace-only
211    /// secret.
212    pub fn single(secret: impl Into<String>) -> Result<Self, StaticTokensError> {
213        Self::new().with(None, secret)
214    }
215
216    /// This set plus one more secret, optionally labeled (see the
217    /// [rules](StaticTokens#rules)).
218    ///
219    /// # Errors
220    ///
221    /// [`StaticTokensError::BlankSecret`], [`StaticTokensError::InvalidLabel`],
222    /// [`StaticTokensError::DuplicateLabel`] or
223    /// [`StaticTokensError::DuplicateSecret`], checked in that order. The set
224    /// is consumed either way; on an error its secrets are wiped with it.
225    pub fn with(
226        mut self,
227        label: Option<&str>,
228        secret: impl Into<String>,
229    ) -> Result<Self, StaticTokensError> {
230        let secret = Zeroizing::new(secret.into());
231        let index = self.entries.len();
232        if secret.trim().is_empty() {
233            return Err(StaticTokensError::BlankSecret { index });
234        }
235        if let Some(label) = label {
236            if !is_log_safe_label(label) {
237                return Err(StaticTokensError::InvalidLabel { index });
238            }
239            if let Some(first) = self
240                .entries
241                .iter()
242                .position(|e| e.label.as_deref() == Some(label))
243            {
244                return Err(StaticTokensError::DuplicateLabel {
245                    index,
246                    first,
247                    label: label.to_string(),
248                });
249            }
250        }
251        if let Some(first) = self.position_of(&secret) {
252            return Err(StaticTokensError::DuplicateSecret { index, first });
253        }
254        self.entries.push(StaticEntry {
255            label: label.map(str::to_string),
256            secret,
257        });
258        Ok(self)
259    }
260
261    /// How many secrets the set holds.
262    pub fn len(&self) -> usize {
263        self.entries.len()
264    }
265
266    /// Whether the set holds none.
267    pub fn is_empty(&self) -> bool {
268        self.entries.is_empty()
269    }
270
271    /// Every entry's label, in insertion order (`None` for an unlabeled
272    /// one) — for a startup log line saying which keys are accepted.
273    pub fn labels(&self) -> impl Iterator<Item = Option<&str>> {
274        self.entries.iter().map(|e| e.label.as_deref())
275    }
276
277    /// `set` plus `token` (a single `static_token` setting), as one set.
278    /// `token` is added as an unlabeled entry unless it is empty (not
279    /// configured) or an entry already holds it, in which case that entry
280    /// (and its label) stands for both. A whitespace-only `token` counts as
281    /// empty, as [`StaticTokens::with`] refuses one: it could never be
282    /// matched (blank candidates are discarded before any comparison), so
283    /// keeping it would build a layer that silently admits nobody. Dropped,
284    /// a layer with nothing else to check is `AuthLayerError::NoCredential`
285    /// at build — fail closed, and loudly. `None` when the result is empty.
286    #[cfg(feature = "tower")]
287    pub(crate) fn merged(set: Option<Self>, token: Option<Zeroizing<String>>) -> Option<Self> {
288        let mut merged = set.unwrap_or_default();
289        // A `token` that is empty or already in the set is dropped here,
290        // wiped by its `Zeroizing`.
291        if let Some(token) = token.filter(|t| !t.trim().is_empty())
292            && merged.position_of(&token).is_none()
293        {
294            merged.entries.push(StaticEntry {
295                label: None,
296                secret: token,
297            });
298        }
299        (!merged.is_empty()).then_some(merged)
300    }
301
302    /// Add an entry the caller has already checked against every rule
303    /// (non-blank, valid unique label, secret not in the set).
304    #[cfg(feature = "env")]
305    pub(crate) fn push_checked(&mut self, label: &str, secret: Zeroizing<String>) {
306        debug_assert!(is_log_safe_label(label) && !secret.trim().is_empty());
307        self.entries.push(StaticEntry {
308            label: Some(label.to_string()),
309            secret,
310        });
311    }
312
313    /// Whether `secret` is already in the set (compared in constant time).
314    #[cfg(feature = "env")]
315    pub(crate) fn contains(&self, secret: &str) -> bool {
316        self.position_of(secret).is_some()
317    }
318
319    /// The position of the entry holding `secret`, compared in constant time
320    /// (startup-only: used to refuse or merge a repeated secret).
321    fn position_of(&self, secret: &str) -> Option<usize> {
322        let secrets: Vec<&str> = self.secrets();
323        find_static(&[secret], &secrets)
324    }
325
326    fn secrets(&self) -> Vec<&str> {
327        self.entries.iter().map(|e| e.secret.as_str()).collect()
328    }
329
330    fn match_at(&self, index: usize) -> StaticTokenMatch {
331        StaticTokenMatch {
332            label: self.entries.get(index).and_then(|e| e.label.clone()),
333        }
334    }
335}
336
337/// Hand-written so no secret ever reaches a log line through `{:?}`: the
338/// count and the labels (log-safe by construction), never a secret.
339impl fmt::Debug for StaticTokens {
340    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
341        f.debug_struct("StaticTokens")
342            .field("len", &self.entries.len())
343            .field("labels", &self.labels().collect::<Vec<_>>())
344            .field("secrets", &"<redacted>")
345            .finish()
346    }
347}
348
349/// 1 to [`StaticTokens::MAX_LABEL_LEN`] visible ASCII characters.
350fn is_log_safe_label(label: &str) -> bool {
351    !label.is_empty()
352        && label.len() <= StaticTokens::MAX_LABEL_LEN
353        && label.bytes().all(|b| b.is_ascii_graphic())
354}
355
356/// Which [`StaticTokens`] entry accepted a request: returned by
357/// [`authenticate_with_static_tokens`] next to [`Credential::StaticToken`],
358/// and inserted into request extensions by the layers (features `axum` and
359/// `tower`) whenever they insert [`Credential::StaticToken`] — with the
360/// `axum` feature it is also an extractor. It carries the entry's label only;
361/// nothing in it exposes the secret.
362///
363/// A layer configured with a single `static_token` inserts one too, with no
364/// label.
365#[derive(Debug, Clone, PartialEq, Eq)]
366#[non_exhaustive]
367pub struct StaticTokenMatch {
368    label: Option<String>,
369}
370
371impl StaticTokenMatch {
372    /// The matched entry's label, as given to [`StaticTokens::with`]; `None`
373    /// for an unlabeled entry.
374    pub fn label(&self) -> Option<&str> {
375        self.label.as_deref()
376    }
377
378    /// The match for an unlabeled entry.
379    #[cfg(feature = "tower")]
380    pub(crate) fn unlabeled() -> Self {
381        Self { label: None }
382    }
383}
384
385/// `subtle`'s constant-time equality of two strings' bytes: the one place a
386/// static token is compared, so the test-only `STATIC_COMPARISONS` counter
387/// counts actual comparisons, not loop iterations.
388fn counted_ct_eq(candidate: &str, secret: &str) -> Choice {
389    #[cfg(test)]
390    STATIC_COMPARISONS.with(|n| n.set(n.get() + 1));
391    candidate.as_bytes().ct_eq(secret.as_bytes())
392}
393
394/// The position of the first entry of `secrets` any candidate equals — the
395/// first matching candidate's, in `candidates` order — compared in constant
396/// time: every candidate against every entry, no early exit, and the index
397/// chosen by `conditional_assign`, never a branch on a comparison. The one
398/// static-token comparison in the crate; see [`StaticTokens`]' "Security"
399/// for what its timing does and does not reveal.
400fn find_static(candidates: &[&str], secrets: &[&str]) -> Option<usize> {
401    let mut found = Choice::from(0);
402    let mut index: u64 = 0;
403    // This loop must stay branch-free on secret-derived values (`equal`,
404    // `found`): no `if`, no `break`, no skipping a comparison once one has
405    // matched. `counted_ct_eq` is the only comparison, so the test-only
406    // counter catches a skipped comparison. Replacing `conditional_assign`
407    // with an `if` would change timing only, which no functional test can
408    // observe; that property rests on review, not on a test.
409    for candidate in candidates {
410        for (i, secret) in (0u64..).zip(secrets) {
411            let equal = counted_ct_eq(candidate, secret);
412            index.conditional_assign(&i, equal & !found);
413            found |= equal;
414        }
415    }
416    if bool::from(found) {
417        usize::try_from(index).ok()
418    } else {
419        None
420    }
421}
422
423/// Check every candidate credential against every configured mechanism, and
424/// accept if ANY candidate satisfies ANY mechanism.
425///
426/// `candidates` is every value that could carry a credential — typically the
427/// token from `Authorization: Bearer <token>` and, for an application that also
428/// takes one, the value of a raw API-key header — in any order, however many are
429/// present. The presence of one candidate never decides whether another is
430/// looked at, so an invalid credential in one place cannot hide a valid one in
431/// another.
432///
433/// Candidates are used verbatim; an empty or whitespace-only candidate counts as
434/// absent. `static_token` of `None` or `Some("")` means no static token is
435/// configured (a blank secret must never be matchable); `oauth` of `None` means
436/// OAuth is off. With neither configured nothing can succeed: this function
437/// never passes a request through. Deciding to run without authentication is the
438/// caller's explicit choice, made before calling it (see
439/// [`crate::static_token_policy`]).
440///
441/// # Order
442///
443/// Every candidate is first compared with the static token, in constant time
444/// (`subtle`; the lengths are not hidden — see [`StaticTokens`]' "Security",
445/// the same comparison over a one-entry set). A match is decisive and needs no
446/// network, so the common static-token request never reaches the JWT machinery
447/// or depends on the authorization server being up. Only then is every
448/// candidate validated through OAuth, in order, until one is accepted — first
449/// against the signing keys already held, and only if that accepts none of them
450/// is a candidate whose `kid` is not held allowed to trigger a key refetch. A
451/// foreign JWT in one source therefore never makes a request wait on the
452/// authorization server when another candidate's key is already cached.
453///
454/// # Result
455///
456/// - `Ok` as soon as any candidate is accepted.
457/// - Otherwise [`TokenRejection::InsufficientScope`] if any candidate was a
458///   valid OAuth token lacking a required scope: "this credential is fine but
459///   not sufficient" (RFC 6750's 403) is the more useful answer when it is true
460///   of any of them.
461/// - Otherwise [`TokenRejection::Missing`] if there was no non-blank candidate.
462/// - Otherwise [`TokenRejection::Invalid`] carrying the first candidate's
463///   [`InvalidToken`] — the validator's, when OAuth is configured, kind
464///   included; without OAuth, [`InvalidTokenKind::StaticTokenMismatch`] (or
465///   [`InvalidTokenKind::NoMechanism`] with nothing configured). Its detail
466///   is for logs only; never send it to the caller.
467///
468/// Map a refusal to a response the same way the axum layer does: 403 with
469/// [`OAuthValidator::insufficient_scope_challenge`] for `InsufficientScope`,
470/// 401 with [`OAuthValidator::invalid_token_challenge`] for everything else,
471/// and (with OAuth configured) the challenge in `WWW-Authenticate` on both.
472///
473/// # Errors
474///
475/// A [`TokenRejection`], chosen as described under "Result" above. This
476/// function logs nothing; logging the refusal is the caller's job.
477///
478/// # Panics
479///
480/// Outside a Tokio 1.x runtime, when an OAuth candidate's signing key has to
481/// be fetched (see [`OAuthValidator`]'s "Runtime" section). A static-token
482/// match, or a key already held, needs no runtime.
483///
484/// # Security
485///
486/// The static token is compared in constant time, but its length is not
487/// hidden. Candidates are never trimmed or normalized, so a static token is
488/// matched only byte for byte.
489///
490/// To accept several static tokens (key rotation, one key per client) and
491/// learn which one matched, use [`authenticate_with_static_tokens`]: this
492/// function is that one over a one-entry set, the same code.
493///
494/// # Examples
495///
496/// ```
497/// use oauth_resource_server::{Credential, TokenRejection, authenticate};
498///
499/// # #[tokio::main(flavor = "current_thread")]
500/// # async fn main() {
501/// // Static token only (no OAuth validator): a junk value in one header does
502/// // not stop the key in another from being accepted.
503/// let key = Some("example-static-key");
504/// let ok = authenticate(["junk", "example-static-key"], key, None).await;
505/// assert_eq!(ok, Ok(Credential::StaticToken));
506///
507/// assert_eq!(authenticate([" ", ""], key, None).await, Err(TokenRejection::Missing));
508/// assert!(matches!(
509///     authenticate(["guess"], key, None).await,
510///     Err(TokenRejection::Invalid(_))
511/// ));
512/// # }
513/// ```
514pub async fn authenticate<'a>(
515    candidates: impl IntoIterator<Item = &'a str>,
516    static_token: Option<&str>,
517    oauth: Option<&OAuthValidator>,
518) -> Result<Credential, TokenRejection> {
519    // A one-entry set, borrowed rather than copied into a `StaticTokens`: the
520    // secret is not duplicated on the heap for every request.
521    let one: [&str; 1];
522    let secrets: &[&str] = match static_token.filter(|t| !t.is_empty()) {
523        Some(token) => {
524            one = [token];
525            &one
526        }
527        None => &[],
528    };
529    check_candidates(candidates, secrets, oauth)
530        .await
531        .map(|(credential, _)| credential)
532}
533
534/// [`authenticate`] against a set of static tokens, reporting which one
535/// matched: the same candidate handling, order and refusal precedence, over
536/// every entry of `static_tokens` instead of one token.
537///
538/// On a static match the result is `(Credential::StaticToken,
539/// Some(StaticTokenMatch))`, the match naming the entry's label; on an OAuth
540/// match, `(Credential::OAuth(..), None)`. If several candidates match
541/// entries, the first matching candidate (in `candidates` order) decides the
542/// reported entry. `static_tokens` of `None` or an empty set means no static
543/// token is configured.
544///
545/// [`authenticate`] is this function over a one-entry set — one
546/// implementation — so a one-entry [`StaticTokens`] behaves exactly as
547/// `authenticate` with that token, down to the refusal reason.
548///
549/// # Errors
550///
551/// As [`authenticate`]. With more than one entry, an unmatched candidate's
552/// reason (static-only) reads "credential does not match any static token".
553///
554/// # Panics
555///
556/// As [`authenticate`]: outside a Tokio 1.x runtime, only when an OAuth
557/// candidate's signing key has to be fetched.
558///
559/// # Security
560///
561/// Every candidate is compared with every entry in constant time, with no
562/// early exit once one matches; lengths are not hidden. See [`StaticTokens`].
563///
564/// # Examples
565///
566/// ```
567/// use oauth_resource_server::{
568///     Credential, StaticTokens, TokenRejection, authenticate_with_static_tokens,
569/// };
570///
571/// # #[tokio::main(flavor = "current_thread")]
572/// # async fn main() {
573/// let tokens = StaticTokens::new()
574///     .with(Some("current"), "example-key-old")
575///     .and_then(|t| t.with(Some("next"), "example-key-new"))
576///     .unwrap();
577///
578/// let (credential, matched) =
579///     authenticate_with_static_tokens(["example-key-new"], Some(&tokens), None)
580///         .await
581///         .unwrap();
582/// assert_eq!(credential, Credential::StaticToken);
583/// assert_eq!(matched.unwrap().label(), Some("next"));
584///
585/// assert!(matches!(
586///     authenticate_with_static_tokens(["guess"], Some(&tokens), None).await,
587///     Err(TokenRejection::Invalid(_))
588/// ));
589/// # }
590/// ```
591pub async fn authenticate_with_static_tokens<'a>(
592    candidates: impl IntoIterator<Item = &'a str>,
593    static_tokens: Option<&StaticTokens>,
594    oauth: Option<&OAuthValidator>,
595) -> Result<(Credential, Option<StaticTokenMatch>), TokenRejection> {
596    let secrets = static_tokens.map(StaticTokens::secrets).unwrap_or_default();
597    let (credential, index) = check_candidates(candidates, &secrets, oauth).await?;
598    let matched = index.zip(static_tokens).map(|(i, set)| set.match_at(i));
599    Ok((credential, matched))
600}
601
602/// The one implementation behind [`authenticate`] and
603/// [`authenticate_with_static_tokens`]; on a static match, also the position
604/// of the matching entry of `secrets`.
605async fn check_candidates<'a>(
606    candidates: impl IntoIterator<Item = &'a str>,
607    secrets: &[&str],
608    oauth: Option<&OAuthValidator>,
609) -> Result<(Credential, Option<usize>), TokenRejection> {
610    // Every candidate is considered, never just the first one present:
611    // choosing the `Authorization` header whenever it was present would let a
612    // foreign JWT there, added by a proxy, make a valid token in a second
613    // credential header (such as `X-Api-Key`) unreachable.
614    let candidates: Vec<&str> = candidates
615        .into_iter()
616        .filter(|c| !c.trim().is_empty())
617        .collect();
618    if candidates.is_empty() {
619        return Err(TokenRejection::Missing);
620    }
621
622    if let Some(index) = find_static(&candidates, secrets) {
623        return Ok((Credential::StaticToken, Some(index)));
624    }
625
626    let Some(validator) = oauth else {
627        return Err(match secrets.len() {
628            0 => TokenRejection::invalid(
629                InvalidTokenKind::NoMechanism,
630                "no credential mechanism is configured",
631            ),
632            1 => TokenRejection::invalid(
633                InvalidTokenKind::StaticTokenMismatch,
634                "credential does not match the static token",
635            ),
636            _ => TokenRejection::invalid(
637                InvalidTokenKind::StaticTokenMismatch,
638                "credential does not match any static token",
639            ),
640        });
641    };
642
643    // Two passes. The first decides every candidate it can from the keys
644    // already held; only if none of them is accepted does the second validate
645    // the rest, which may refetch the JWKS. Otherwise a foreign JWT in an
646    // earlier source (a proxy's own token, signed by some other issuer, so an
647    // unknown `kid`) would queue every request behind a key refetch even when a
648    // later candidate's key is cached. Refusals are recorded per candidate
649    // position, so the reason reported is still the first candidate's.
650    let mut refusals: Vec<Option<TokenRejection>> = Vec::with_capacity(candidates.len());
651    for candidate in &candidates {
652        match validator.validate_cached(candidate).await {
653            CachedAttempt::Decided(Ok(token)) => return Ok((Credential::OAuth(token), None)),
654            CachedAttempt::Decided(Err(rejection)) => refusals.push(Some(rejection)),
655            CachedAttempt::NeedsKeyFetch => refusals.push(None),
656        }
657    }
658    for (candidate, refusal) in candidates.iter().zip(refusals.iter_mut()) {
659        if refusal.is_none() {
660            match validator.validate(candidate).await {
661                Ok(token) => return Ok((Credential::OAuth(token), None)),
662                Err(rejection) => *refusal = Some(rejection),
663            }
664        }
665    }
666
667    let mut insufficient_scope = false;
668    let mut first_reason: Option<InvalidToken> = None;
669    for refusal in refusals.into_iter().flatten() {
670        match refusal {
671            TokenRejection::InsufficientScope => insufficient_scope = true,
672            TokenRejection::Invalid(reason) => {
673                first_reason.get_or_insert(reason);
674            }
675            // `Missing` is unreachable for a non-blank candidate. Recorded as a
676            // refusal all the same: whatever the validator says that is not
677            // `Ok` must never read as acceptance.
678            TokenRejection::Missing => {
679                first_reason.get_or_insert_with(|| {
680                    InvalidToken::new(InvalidTokenKind::Other, "no credential presented")
681                });
682            }
683        }
684    }
685    if insufficient_scope {
686        return Err(TokenRejection::InsufficientScope);
687    }
688    // Unreachable in practice (every non-blank candidate left a refusal),
689    // and still a refusal, never an acceptance.
690    Err(TokenRejection::Invalid(first_reason.unwrap_or_else(|| {
691        InvalidToken::new(
692            InvalidTokenKind::Other,
693            "no candidate credential was accepted",
694        )
695    })))
696}
697
698#[cfg(test)]
699mod tests {
700    use std::sync::Arc;
701
702    use super::*;
703    use crate::testing;
704
705    const STATIC: &str = "static-secret";
706
707    /// A validator whose JWKS endpoint refuses connections: anything that
708    /// reaches key lookup fails, so a test that succeeds against it proves the
709    /// request never needed the authorization server.
710    fn unreachable_validator() -> Arc<OAuthValidator> {
711        Arc::new(OAuthValidator::new(&testing::resolved_config("http://127.0.0.1:1/jwks")).unwrap())
712    }
713
714    async fn live_validator() -> (testing::FakeJwksServer, Arc<OAuthValidator>) {
715        let jwks = testing::spawn_jwks_server("200 OK", testing::jwks_body()).await;
716        let v = Arc::new(OAuthValidator::new(&testing::resolved_config(&jwks.url)).unwrap());
717        (jwks, v)
718    }
719
720    fn unscoped_token() -> String {
721        testing::mint(
722            testing::KEY_A_PEM,
723            testing::KID_A,
724            &serde_json::json!({
725                "iss": testing::ISSUER, "aud": testing::AUDIENCE, "sub": "user-2",
726                "exp": testing::now() + 3600, "scope": "openid profile",
727            }),
728        )
729    }
730
731    fn expired_token() -> String {
732        testing::mint(
733            testing::KEY_A_PEM,
734            testing::KID_A,
735            &serde_json::json!({
736                "iss": testing::ISSUER, "aud": testing::AUDIENCE,
737                "exp": testing::now() - 3600, "scope": "mcp:read",
738            }),
739        )
740    }
741
742    #[tokio::test]
743    async fn a_static_match_wins_without_touching_oauth() {
744        let v = unreachable_validator();
745        assert_eq!(
746            authenticate([STATIC], Some(STATIC), Some(&v)).await,
747            Ok(Credential::StaticToken)
748        );
749        assert_eq!(
750            authenticate([STATIC], Some(STATIC), None).await,
751            Ok(Credential::StaticToken)
752        );
753    }
754
755    #[tokio::test]
756    async fn an_oauth_match_returns_the_authorized_token() {
757        let (jwks, v) = live_validator().await;
758        let token = testing::valid_token();
759        for static_token in [None, Some(STATIC)] {
760            match authenticate([token.as_str()], static_token, Some(&v)).await {
761                Ok(Credential::OAuth(t)) => {
762                    assert_eq!(t.subject.as_deref(), Some("user-1"));
763                    assert!(t.has_scope("mcp:read"));
764                }
765                other => panic!("expected an OAuth credential, got {other:?}"),
766            }
767        }
768        assert!(jwks.hits.load(std::sync::atomic::Ordering::SeqCst) >= 1);
769    }
770
771    #[tokio::test]
772    async fn a_valid_token_lacking_scope_is_insufficient_scope() {
773        let (_jwks, v) = live_validator().await;
774        let token = unscoped_token();
775        assert_eq!(
776            authenticate([token.as_str()], Some(STATIC), Some(&v)).await,
777            Err(TokenRejection::InsufficientScope)
778        );
779    }
780
781    #[tokio::test]
782    async fn no_candidate_is_missing_whatever_is_configured() {
783        let v = unreachable_validator();
784        for (static_token, oauth) in [
785            (Some(STATIC), None),
786            (None, Some(&*v)),
787            (Some(STATIC), Some(&*v)),
788            (None, None),
789        ] {
790            assert_eq!(
791                authenticate(std::iter::empty(), static_token, oauth).await,
792                Err(TokenRejection::Missing),
793                "static={static_token:?} oauth={}",
794                oauth.is_some()
795            );
796        }
797    }
798
799    #[tokio::test]
800    async fn blank_candidates_count_as_absent() {
801        let v = unreachable_validator();
802        assert_eq!(
803            authenticate(["", "   ", "\t"], Some(STATIC), Some(&v)).await,
804            Err(TokenRejection::Missing)
805        );
806        // A blank candidate before a good one does not mask it.
807        assert_eq!(
808            authenticate(["", " ", STATIC], Some(STATIC), Some(&v)).await,
809            Ok(Credential::StaticToken)
810        );
811    }
812
813    #[tokio::test]
814    async fn a_blank_static_token_never_matches() {
815        // `Some("")` is "not configured", not "matches the empty credential".
816        assert_eq!(
817            authenticate([""], Some(""), None).await,
818            Err(TokenRejection::Missing)
819        );
820        crate::token::assert_invalid(
821            authenticate(["x"], Some(""), None).await,
822            InvalidTokenKind::NoMechanism,
823            "no credential mechanism is configured",
824            "",
825        );
826    }
827
828    #[tokio::test]
829    async fn an_invalid_credential_carries_the_first_reason() {
830        let (_jwks, v) = live_validator().await;
831        let expired = expired_token();
832        match authenticate([expired.as_str(), "not-a-jwt"], Some(STATIC), Some(&v)).await {
833            Err(TokenRejection::Invalid(reason)) => {
834                assert!(reason.starts_with("token rejected:"), "{reason}");
835            }
836            other => panic!("expected Invalid, got {other:?}"),
837        }
838        match authenticate(["not-a-jwt", expired.as_str()], Some(STATIC), Some(&v)).await {
839            Err(TokenRejection::Invalid(reason)) => {
840                assert!(reason.starts_with("credential is not a JWT"), "{reason}");
841            }
842            other => panic!("expected Invalid, got {other:?}"),
843        }
844    }
845
846    #[tokio::test]
847    async fn static_only_refuses_a_jwt_without_validating_it() {
848        let token = testing::valid_token();
849        crate::token::assert_invalid(
850            authenticate([token.as_str()], Some(STATIC), None).await,
851            InvalidTokenKind::StaticTokenMismatch,
852            "credential does not match the static token",
853            "",
854        );
855    }
856
857    #[tokio::test]
858    async fn oauth_only_refuses_the_static_value() {
859        let v = unreachable_validator();
860        match authenticate([STATIC], None, Some(&v)).await {
861            Err(TokenRejection::Invalid(reason)) => {
862                assert!(reason.starts_with("credential is not a JWT"), "{reason}");
863            }
864            other => panic!("expected Invalid, got {other:?}"),
865        }
866    }
867
868    #[tokio::test]
869    async fn neither_mechanism_configured_accepts_nothing() {
870        crate::token::assert_invalid(
871            authenticate([STATIC], None, None).await,
872            InvalidTokenKind::NoMechanism,
873            "no credential mechanism is configured",
874            "",
875        );
876    }
877
878    /// A validator with no refetch cooldown, so any unknown `kid` that reaches
879    /// key lookup WOULD refetch — the hit counter then shows whether it did.
880    async fn eager_refetch_validator() -> (testing::FakeJwksServer, OAuthValidator) {
881        let jwks = testing::spawn_jwks_server("200 OK", testing::jwks_body()).await;
882        let v = OAuthValidator::build(
883            &testing::resolved_config(&jwks.url),
884            std::time::Duration::ZERO,
885        )
886        .unwrap();
887        (jwks, v)
888    }
889
890    /// A well-formed token signed by some other issuer's key, under a `kid`
891    /// this validator has never seen (a proxy's own JWT, say).
892    fn foreign_token() -> String {
893        testing::mint(
894            testing::KEY_B_PEM,
895            "proxy-key",
896            &serde_json::json!({
897                "iss": "https://proxy.example.test/", "aud": "proxy",
898                "exp": testing::now() + 3600,
899            }),
900        )
901    }
902
903    fn hits(jwks: &testing::FakeJwksServer) -> usize {
904        jwks.hits.load(std::sync::atomic::Ordering::SeqCst)
905    }
906
907    #[tokio::test]
908    async fn a_foreign_kid_does_not_trigger_a_refetch_when_another_candidate_is_cached() {
909        let (jwks, v) = eager_refetch_validator().await;
910        let valid = testing::valid_token();
911        let foreign = foreign_token();
912        // Warm the cache.
913        assert!(v.validate(&valid).await.is_ok());
914        assert_eq!(hits(&jwks), 1);
915
916        // The foreign JWT first, as a proxy-added `Authorization` header would
917        // be: the cached candidate decides the request with no fetch at all.
918        for candidates in [
919            [foreign.as_str(), valid.as_str()],
920            [valid.as_str(), foreign.as_str()],
921        ] {
922            assert!(matches!(
923                authenticate(candidates, None, Some(&v)).await,
924                Ok(Credential::OAuth(_))
925            ));
926        }
927        assert_eq!(hits(&jwks), 1, "no refetch for the foreign kid");
928
929        // With nothing else acceptable, the unknown kid still gets its refetch
930        // (it could be a genuinely rotated key) and is then refused.
931        match authenticate([foreign.as_str(), "garbage"], None, Some(&v)).await {
932            Err(TokenRejection::Invalid(reason)) => {
933                assert!(reason.contains("proxy-key"), "{reason}");
934            }
935            other => panic!("expected Invalid, got {other:?}"),
936        }
937        assert_eq!(hits(&jwks), 2);
938    }
939
940    #[tokio::test]
941    async fn a_cold_cache_still_fetches_for_the_only_candidate() {
942        let (jwks, v) = eager_refetch_validator().await;
943        let valid = testing::valid_token();
944        assert_eq!(hits(&jwks), 0);
945        assert!(matches!(
946            authenticate([valid.as_str()], None, Some(&v)).await,
947            Ok(Credential::OAuth(_))
948        ));
949        assert_eq!(hits(&jwks), 1);
950        // The reported reason is still the FIRST candidate's, even though the
951        // second was decided in the cache-only pass and the first only after
952        // its refetch.
953        let (_jwks, v) = eager_refetch_validator().await;
954        match authenticate([foreign_token().as_str(), "not-a-jwt"], None, Some(&v)).await {
955            Err(TokenRejection::Invalid(reason)) => {
956                assert!(reason.contains("proxy-key"), "{reason}");
957            }
958            other => panic!("expected Invalid, got {other:?}"),
959        }
960    }
961
962    /// A bad candidate in one position must never mask a good
963    /// one in another, in either order and for either mechanism.
964    #[tokio::test]
965    async fn mixed_candidates_any_success_wins_in_either_order() {
966        let (_jwks, v) = live_validator().await;
967        let valid = testing::valid_token();
968        let unscoped = unscoped_token();
969        let expired = expired_token();
970
971        for candidates in [
972            vec![expired.as_str(), STATIC],
973            vec![STATIC, expired.as_str()],
974            vec!["garbage", STATIC],
975            vec![unscoped.as_str(), STATIC],
976        ] {
977            assert_eq!(
978                authenticate(candidates.iter().copied(), Some(STATIC), Some(&v)).await,
979                Ok(Credential::StaticToken),
980                "{candidates:.30?}"
981            );
982        }
983        for candidates in [
984            vec!["wrong-static", valid.as_str()],
985            vec![valid.as_str(), "wrong-static"],
986            vec![unscoped.as_str(), valid.as_str()],
987            vec![expired.as_str(), valid.as_str()],
988        ] {
989            assert!(
990                matches!(
991                    authenticate(candidates.iter().copied(), Some(STATIC), Some(&v)).await,
992                    Ok(Credential::OAuth(_))
993                ),
994                "{candidates:.30?}"
995            );
996        }
997        // No success: a scope-lacking valid token outranks any invalid one.
998        for candidates in [
999            vec![unscoped.as_str(), "garbage"],
1000            vec!["garbage", unscoped.as_str()],
1001            vec![expired.as_str(), unscoped.as_str()],
1002        ] {
1003            assert_eq!(
1004                authenticate(candidates.iter().copied(), Some(STATIC), Some(&v)).await,
1005                Err(TokenRejection::InsufficientScope),
1006                "{candidates:.30?}"
1007            );
1008        }
1009    }
1010
1011    // ── StaticTokens / authenticate_with_static_tokens ──────────────────────
1012
1013    fn rotation() -> StaticTokens {
1014        StaticTokens::new()
1015            .with(Some("current"), "key-current")
1016            .and_then(|t| t.with(Some("next"), "key-next"))
1017            .and_then(|t| t.with(None, "key-unlabeled"))
1018            .unwrap()
1019    }
1020
1021    fn label_of(
1022        result: Result<(Credential, Option<StaticTokenMatch>), TokenRejection>,
1023    ) -> Option<String> {
1024        match result {
1025            Ok((Credential::StaticToken, Some(m))) => m.label().map(str::to_string),
1026            other => panic!("expected a static match, got {other:?}"),
1027        }
1028    }
1029
1030    /// A one-entry set and `authenticate` with the same token give the same
1031    /// outcome — credential, refusal and refusal reason — for every shape of
1032    /// request, with and without OAuth.
1033    #[tokio::test]
1034    async fn a_one_entry_set_is_identical_to_the_single_token() {
1035        let (_jwks, live) = live_validator().await;
1036        let valid = testing::valid_token();
1037        let unscoped = unscoped_token();
1038        let expired = expired_token();
1039        let single = StaticTokens::single(STATIC).unwrap();
1040        let candidate_sets: Vec<Vec<&str>> = vec![
1041            vec![],
1042            vec!["", " "],
1043            vec![STATIC],
1044            vec!["wrong"],
1045            vec!["wrong", STATIC],
1046            vec![valid.as_str()],
1047            vec![unscoped.as_str()],
1048            vec![expired.as_str(), "garbage"],
1049            vec!["static-secret "],
1050        ];
1051        for oauth in [None, Some(&*live)] {
1052            for candidates in &candidate_sets {
1053                let old = authenticate(candidates.iter().copied(), Some(STATIC), oauth).await;
1054                let new = authenticate_with_static_tokens(
1055                    candidates.iter().copied(),
1056                    Some(&single),
1057                    oauth,
1058                )
1059                .await;
1060                match (&old, &new) {
1061                    (Ok(Credential::StaticToken), Ok((Credential::StaticToken, Some(m)))) => {
1062                        assert_eq!(m.label(), None);
1063                    }
1064                    (Ok(Credential::OAuth(a)), Ok((Credential::OAuth(b), None))) => {
1065                        assert_eq!(a.subject, b.subject);
1066                    }
1067                    (Err(a), Err(b)) => assert_eq!(a, b, "{candidates:.30?}"),
1068                    _ => panic!("{candidates:.30?}: {old:?} vs {new:?}"),
1069                }
1070            }
1071        }
1072        // No set, and an empty set, are "no static token", as `None` is.
1073        for set in [None, Some(&StaticTokens::new())] {
1074            crate::token::assert_invalid(
1075                authenticate_with_static_tokens(["x"], set, None).await,
1076                InvalidTokenKind::NoMechanism,
1077                "no credential mechanism is configured",
1078                "",
1079            );
1080        }
1081    }
1082
1083    #[tokio::test]
1084    async fn every_entry_is_accepted_and_named() {
1085        let set = rotation();
1086        let v = unreachable_validator();
1087        for (secret, label) in [
1088            ("key-current", Some("current")),
1089            ("key-next", Some("next")),
1090            ("key-unlabeled", None),
1091        ] {
1092            for oauth in [None, Some(&*v)] {
1093                let result = authenticate_with_static_tokens([secret], Some(&set), oauth).await;
1094                assert_eq!(label_of(result).as_deref(), label, "{secret}");
1095            }
1096            // Behind a junk candidate.
1097            let result = authenticate_with_static_tokens(["junk", secret], Some(&set), None).await;
1098            assert_eq!(label_of(result).as_deref(), label);
1099        }
1100        // Several matching candidates: the first one decides the label.
1101        let result =
1102            authenticate_with_static_tokens(["key-next", "key-current"], Some(&set), None).await;
1103        assert_eq!(label_of(result).as_deref(), Some("next"));
1104    }
1105
1106    #[tokio::test]
1107    async fn a_wrong_token_is_refused() {
1108        let set = rotation();
1109        for candidate in ["key-", "key-current ", "KEY-CURRENT", "key-nextx", "other"] {
1110            crate::token::assert_invalid(
1111                authenticate_with_static_tokens([candidate], Some(&set), None).await,
1112                InvalidTokenKind::StaticTokenMismatch,
1113                "credential does not match any static token",
1114                candidate,
1115            );
1116        }
1117        assert_eq!(
1118            authenticate_with_static_tokens([" "], Some(&set), None).await,
1119            Err(TokenRejection::Missing)
1120        );
1121        // With OAuth the validator's reason wins, as with one token.
1122        let v = unreachable_validator();
1123        match authenticate_with_static_tokens(["other"], Some(&set), Some(&v)).await {
1124            Err(TokenRejection::Invalid(reason)) => {
1125                assert!(reason.starts_with("credential is not a JWT"), "{reason}");
1126            }
1127            other => panic!("expected Invalid, got {other:?}"),
1128        }
1129    }
1130
1131    #[test]
1132    fn a_blank_secret_is_refused_at_construction() {
1133        for blank in ["", " ", "\t\n", "   "] {
1134            assert_eq!(
1135                StaticTokens::single(blank).unwrap_err(),
1136                StaticTokensError::BlankSecret { index: 0 }
1137            );
1138        }
1139        assert_eq!(
1140            StaticTokens::single("a")
1141                .unwrap()
1142                .with(Some("x"), " ")
1143                .unwrap_err(),
1144            StaticTokensError::BlankSecret { index: 1 }
1145        );
1146    }
1147
1148    #[test]
1149    fn duplicates_and_bad_labels_are_refused() {
1150        let base = || StaticTokens::single("one").unwrap();
1151        assert_eq!(
1152            base().with(Some("again"), "one").unwrap_err(),
1153            StaticTokensError::DuplicateSecret { index: 1, first: 0 }
1154        );
1155        let labeled = StaticTokens::new().with(Some("a"), "one").unwrap();
1156        assert_eq!(
1157            labeled.clone().with(Some("a"), "two").unwrap_err(),
1158            StaticTokensError::DuplicateLabel {
1159                index: 1,
1160                first: 0,
1161                label: "a".into()
1162            }
1163        );
1164        // Unlabeled entries may repeat; distinct labels are fine.
1165        let ok = labeled
1166            .with(None, "two")
1167            .and_then(|t| t.with(None, "three"))
1168            .and_then(|t| t.with(Some("b"), "four"))
1169            .unwrap();
1170        assert_eq!(ok.len(), 4);
1171        assert!(!ok.is_empty() && StaticTokens::new().is_empty());
1172        assert_eq!(
1173            ok.labels().collect::<Vec<_>>(),
1174            [Some("a"), None, None, Some("b")]
1175        );
1176
1177        let longest = "l".repeat(StaticTokens::MAX_LABEL_LEN);
1178        assert!(base().with(Some(&longest), "two").is_ok());
1179        assert!(base().with(Some("!~"), "two").is_ok());
1180        let too_long = "l".repeat(StaticTokens::MAX_LABEL_LEN + 1);
1181        for bad in [
1182            "",
1183            "has space",
1184            "tab\t",
1185            "new\nline",
1186            "caf\u{e9}",
1187            "\u{1b}[31m",
1188            &too_long,
1189        ] {
1190            assert_eq!(
1191                base().with(Some(bad), "two").unwrap_err(),
1192                StaticTokensError::InvalidLabel { index: 1 },
1193                "{bad:?}"
1194            );
1195        }
1196        // No error text carries a secret.
1197        let err = StaticTokens::single("hunter2")
1198            .and_then(|t| t.with(Some("again"), "hunter2"))
1199            .unwrap_err();
1200        assert!(!err.to_string().contains("hunter2") && !format!("{err:?}").contains("hunter2"));
1201    }
1202
1203    #[test]
1204    fn debug_never_prints_a_secret() {
1205        let set = StaticTokens::new()
1206            .with(Some("current"), "hunter2-current")
1207            .and_then(|t| t.with(None, "hunter2-other"))
1208            .unwrap();
1209        let rendered = format!("{set:?} {set:#?}");
1210        assert!(!rendered.contains("hunter2"), "{rendered}");
1211        assert!(rendered.contains("current") && rendered.contains("<redacted>"));
1212        assert!(rendered.contains("len: 2"), "{rendered}");
1213        let matched = StaticTokenMatch {
1214            label: Some("current".into()),
1215        };
1216        assert!(!format!("{matched:?}").contains("hunter2"));
1217    }
1218
1219    fn comparisons() -> usize {
1220        STATIC_COMPARISONS.with(std::cell::Cell::get)
1221    }
1222
1223    fn reset_comparisons() {
1224        STATIC_COMPARISONS.with(|n| n.set(0));
1225    }
1226
1227    /// Not a timing measurement: an instrumented count proving there is no
1228    /// early exit — every candidate is compared with every entry, whichever
1229    /// entry matches, including the first.
1230    #[tokio::test(flavor = "current_thread")]
1231    async fn every_entry_is_compared_even_when_the_first_matches() {
1232        let set = rotation();
1233        for (candidates, matched) in [
1234            (vec!["key-current"], Some(Some("current"))),
1235            (vec!["key-unlabeled"], Some(None)),
1236            (vec!["nothing"], None),
1237            (vec!["key-current", "junk"], Some(Some("current"))),
1238            (vec!["junk", "key-next"], Some(Some("next"))),
1239        ] {
1240            reset_comparisons();
1241            let result =
1242                authenticate_with_static_tokens(candidates.iter().copied(), Some(&set), None).await;
1243            assert_eq!(
1244                comparisons(),
1245                candidates.len() * set.len(),
1246                "{candidates:?}"
1247            );
1248            match matched {
1249                Some(label) => assert_eq!(label_of(result).as_deref(), label),
1250                None => assert!(result.is_err()),
1251            }
1252        }
1253        // The single-token API goes through the same comparison.
1254        reset_comparisons();
1255        assert!(
1256            authenticate([STATIC, "junk"], Some(STATIC), None)
1257                .await
1258                .is_ok()
1259        );
1260        assert_eq!(comparisons(), 2);
1261    }
1262
1263    #[test]
1264    fn find_static_picks_the_first_matching_candidate() {
1265        let secrets = ["a1", "b22", "c333"];
1266        assert_eq!(find_static(&["c333"], &secrets), Some(2));
1267        assert_eq!(find_static(&["a1"], &secrets), Some(0));
1268        assert_eq!(find_static(&["b22", "a1"], &secrets), Some(1));
1269        assert_eq!(find_static(&["zz"], &secrets), None);
1270        assert_eq!(find_static(&["a1"], &[]), None);
1271    }
1272}