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}