Skip to main content

oauth_resource_server/
token.rs

1//! What a validation produces: the accepted token ([`AuthorizedToken`]) or the
2//! reason it was refused ([`TokenRejection`]), plus the claim readers and the
3//! header `typ` check that feed them.
4
5use std::collections::HashSet;
6use std::fmt;
7use std::sync::Arc;
8use std::time::{Duration, SystemTime, UNIX_EPOCH};
9
10use serde::de::DeserializeOwned;
11use serde_json::{Map, Value};
12
13use crate::config::KeyNamingBuf;
14
15/// Cap on a presented credential. Real access tokens are well under 8 KiB even
16/// with group claims; anything larger is refused before it is base64-decoded.
17pub(crate) const MAX_TOKEN_BYTES: usize = 16 * 1024;
18
19/// Cap on any token-derived string that reaches a log line (`kid`, `typ`,
20/// principal). A signed claim is trustworthy but not necessarily short, and an
21/// unverified header field is neither.
22pub(crate) const MAX_LOGGED_CHARS: usize = 128;
23
24/// What [`AuthorizedToken::new`] puts in `expires_at`: 2100-01-01T00:00:00Z.
25const FAR_FUTURE_SECS: u64 = 4_102_444_800;
26
27/// A successfully validated access token. The axum middleware (feature `axum`)
28/// inserts it into request extensions, so handlers can read who called and with
29/// which scopes.
30///
31/// The scopes come from the one place that actually verified them, so a handler
32/// enforcing a finer-grained scope (say, a write scope on some routes) should ask
33/// [`AuthorizedToken::has_scope`] rather than re-parse the header.
34///
35/// # The verified claims
36///
37/// Besides the fields below, the token keeps the whole claim set the signature
38/// covered, read with [`AuthorizedToken::claims`] (raw) or
39/// [`AuthorizedToken::claims_as`] (into your own type), for anything this crate
40/// has no field for: `groups`, `roles`, `email`, a tenant id. There is no need to
41/// decode the JWT a second time in a handler. The claims are stored once and
42/// shared (`Arc`), so cloning a token is cheap. Their size is bounded by the
43/// credential cap: a token is refused above 16 KiB before it is decoded, so
44/// the stored claims cannot outgrow that (their parsed in-memory form is a small
45/// multiple of it).
46///
47/// # Debug
48///
49/// `Debug` prints every field except the claim *values*: the claims appear as
50/// their names only. Claims routinely carry personal data (`email`, `name`,
51/// group memberships) and a `{token:?}` in a log line or a panic message must
52/// not leak it. `subject`, `principal` and `client_id` are printed, as they
53/// always have been (`subject`, `principal`) or are identifiers meant for logs.
54/// Read values deliberately through [`AuthorizedToken::claims`].
55#[derive(Clone, PartialEq, Eq)]
56#[non_exhaustive]
57pub struct AuthorizedToken {
58    /// The token's `sub`, verbatim, when it carried one as a string. It is
59    /// signed, so it is safe to key per-user decisions on (it is bounded only
60    /// by the 16 KiB credential cap); this crate's own log lines truncate it.
61    pub subject: Option<String>,
62    /// The first present, non-empty string claim of
63    /// [`crate::OAuthConfig::principal_claims`], verbatim — who the request is
64    /// from, for logs and attribution. Never the token itself. Which claim it
65    /// came from depends on config, so key authorization on
66    /// [`AuthorizedToken::subject`] rather than on this.
67    pub principal: Option<String>,
68    /// The union of every [`crate::OAuthConfig::scope_claims`] claim, in
69    /// first-seen order, deduplicated.
70    pub scopes: Vec<String>,
71    /// The token's `iss`: the exact string that matched the configured issuer.
72    /// Empty on a token built with [`AuthorizedToken::new`].
73    pub issuer: String,
74    /// The token's `aud`, normalized to a list whether the token carried a
75    /// single string or an array (non-string entries are dropped). On a
76    /// validated token at least one entry is a configured audience. Empty on a
77    /// token built with [`AuthorizedToken::new`].
78    pub audiences: Vec<String>,
79    /// The token's `exp`. A validated token was not expired at the moment of
80    /// validation (within the configured leeway), so a long-lived connection,
81    /// such as an SSE stream, can close itself at this instant. A fractional
82    /// `exp` is rounded to whole seconds exactly as the validation rounded it, and
83    /// one later than 9999-12-31T23:59:59Z saturates to that instant. A token built
84    /// with [`AuthorizedToken::new`] gets 2100-01-01T00:00:00Z.
85    pub expires_at: SystemTime,
86    /// The token's `iat`, when it carried a valid NumericDate. Checked only
87    /// when [`crate::OAuthConfig::max_token_age_secs`] is set. Rounded and saturated like
88    /// [`expires_at`](Self::expires_at); `None` when `iat` is absent or not a
89    /// non-negative number (the token is still accepted then). `None` on a token built with [`AuthorizedToken::new`].
90    pub issued_at: Option<SystemTime>,
91    /// The OAuth client the token was issued to: `client_id` (RFC 9068 §2.2),
92    /// else `azp`, the first that is a non-empty string. The
93    /// [`crate::OAuthConfig::allowed_client_ids`] check reads the same claims
94    /// more strictly: there, a `client_id` that is present but empty or not a
95    /// string refuses the token instead of falling through to `azp`. `None` when the token
96    /// carries neither, and on a token built with [`AuthorizedToken::new`].
97    pub client_id: Option<String>,
98    /// The token's `jti`, when it carried one as a non-empty string. `None` on
99    /// a token built with [`AuthorizedToken::new`].
100    pub jti: Option<String>,
101    claims: Arc<Map<String, Value>>,
102}
103
104impl fmt::Debug for AuthorizedToken {
105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106        f.debug_struct("AuthorizedToken")
107            .field("subject", &self.subject)
108            .field("principal", &self.principal)
109            .field("scopes", &self.scopes)
110            // The configured issuer, which may carry userinfo.
111            .field("issuer", &crate::jwks::debug_url(&self.issuer))
112            .field("audiences", &self.audiences)
113            .field("expires_at", &self.expires_at)
114            .field("issued_at", &self.issued_at)
115            .field("client_id", &self.client_id)
116            .field("jti", &self.jti)
117            // Names only: the values may be personal data.
118            .field("claims", &self.claims.keys().collect::<Vec<_>>())
119            .finish()
120    }
121}
122
123/// The latest time a token timestamp maps to: 9999-12-31T23:59:59Z. A claim later
124/// than this (`jsonwebtoken` accepts an `exp` up to `u64::MAX`, more than
125/// `SystemTime` can hold on every platform) saturates here instead of failing,
126/// so a validly signed, far-future `exp` never reads as already expired.
127const MAX_TIMESTAMP_SECS: u64 = 253_402_300_799;
128
129/// The token's own timestamp claim as a point in time, read the way
130/// `jsonwebtoken` reads `exp` and `nbf` (its `numeric_type` deserializer): a
131/// non-negative integer, or a finite non-negative float rounded to the nearest
132/// whole second (half away from zero). Anything later than
133/// [`MAX_TIMESTAMP_SECS`] saturates to it. `None` for anything else (a string, a
134/// negative, a non-finite or out-of-`u64` number). Never panics.
135fn numeric_date(value: &Value) -> Option<SystemTime> {
136    let secs = numeric_date_secs(value)?;
137    Some(UNIX_EPOCH + Duration::from_secs(secs))
138}
139
140/// [`numeric_date`] as whole seconds since the epoch, saturated the same way.
141pub(crate) fn numeric_date_secs(value: &Value) -> Option<u64> {
142    let secs = match value.as_u64() {
143        Some(secs) => secs,
144        None => {
145            let f = value.as_f64()?;
146            if !(f.is_finite() && f >= 0.0 && f < u64::MAX as f64) {
147                return None;
148            }
149            f.round() as u64
150        }
151    };
152    Some(secs.min(MAX_TIMESTAMP_SECS))
153}
154
155/// The OAuth client a token was issued to: `client_id` (RFC 9068 §2.2), else
156/// `azp`, the first that is a non-empty string. The one reading of it, shared
157/// by [`AuthorizedToken::client_id`] and the `allowed_client_ids` check.
158pub(crate) fn client_id_of(claims: &Map<String, Value>) -> Option<&str> {
159    ["client_id", "azp"].into_iter().find_map(|name| {
160        claims
161            .get(name)
162            .and_then(Value::as_str)
163            .filter(|s| !s.is_empty())
164    })
165}
166
167/// `aud` as a list: a string is one audience, an array contributes its string
168/// entries.
169fn audiences_of(claims: &Map<String, Value>) -> Vec<String> {
170    match claims.get("aud") {
171        Some(Value::String(s)) => vec![s.clone()],
172        Some(Value::Array(items)) => items
173            .iter()
174            .filter_map(Value::as_str)
175            .map(str::to_string)
176            .collect(),
177        _ => Vec::new(),
178    }
179}
180
181impl AuthorizedToken {
182    /// Fill every field from a token's claims, with `subject`, `principal` and
183    /// `scopes` already extracted by the caller. Only called on claims a
184    /// signature verification produced. An `exp` that cannot be read (which
185    /// the decoder has already refused, as it is required) would read as the
186    /// epoch, i.e. expired, never as "never expires".
187    pub(crate) fn from_verified_claims(
188        claims: Map<String, Value>,
189        subject: Option<String>,
190        principal: Option<String>,
191        scopes: Vec<String>,
192    ) -> Self {
193        let string_claim = |name: &str| {
194            claims
195                .get(name)
196                .and_then(Value::as_str)
197                .filter(|s| !s.is_empty())
198                .map(str::to_string)
199        };
200        Self {
201            subject,
202            principal,
203            scopes,
204            issuer: claims
205                .get("iss")
206                .and_then(Value::as_str)
207                .unwrap_or_default()
208                .to_string(),
209            audiences: audiences_of(&claims),
210            expires_at: claims
211                .get("exp")
212                .and_then(numeric_date)
213                .unwrap_or(UNIX_EPOCH),
214            issued_at: claims.get("iat").and_then(numeric_date),
215            client_id: client_id_of(&claims).map(str::to_string),
216            jti: string_claim("jti"),
217            claims: Arc::new(claims),
218        }
219    }
220
221    /// Build a token record from parts, with `scopes` deduplicated in
222    /// first-seen order (blank entries dropped), as a validation produces them.
223    ///
224    /// This verifies nothing — it is a plain value constructor for code that
225    /// needs an `AuthorizedToken` without a validation, chiefly tests that place
226    /// one in request extensions. [`crate::OAuthValidator::validate`] is the only
227    /// source of a token that was actually checked. The struct is
228    /// `#[non_exhaustive]`, so a field added later gets a default here rather
229    /// than breaking callers.
230    ///
231    /// The fields beyond the three arguments get test-friendly defaults: empty
232    /// `issuer`, `audiences` and [`claims`](Self::claims), `None` for
233    /// `issued_at`, `client_id` and `jti`, and an `expires_at` of
234    /// 2100-01-01T00:00:00Z. The far-future expiry is deliberate: a handler
235    /// that closes a stream when the token expires must not see a fixture as
236    /// already expired. Set any of them with the `with_*` builders.
237    pub fn new(
238        subject: Option<String>,
239        principal: Option<String>,
240        scopes: impl IntoIterator<Item = impl Into<String>>,
241    ) -> Self {
242        let mut deduped: Vec<String> = Vec::new();
243        for scope in scopes {
244            let scope = scope.into();
245            let scope = scope.trim();
246            if !scope.is_empty() && !deduped.iter().any(|s| s == scope) {
247                deduped.push(scope.to_string());
248            }
249        }
250        Self {
251            subject,
252            principal,
253            scopes: deduped,
254            issuer: String::new(),
255            audiences: Vec::new(),
256            expires_at: UNIX_EPOCH + Duration::from_secs(FAR_FUTURE_SECS),
257            issued_at: None,
258            client_id: None,
259            jti: None,
260            claims: Arc::new(Map::new()),
261        }
262    }
263
264    /// The verified claim set, exactly as the token carried it: every claim the
265    /// signature covered, including the ones with their own field here.
266    /// Empty for a token built with [`AuthorizedToken::new`] unless
267    /// [`with_claims`](Self::with_claims) was used.
268    ///
269    /// # Security
270    ///
271    /// Claims are trustworthy only on a token a validation produced; one from
272    /// [`AuthorizedToken::new`] holds whatever the caller put there. Claim
273    /// values can be personal data, so [`Debug`](fmt::Debug) does not print them.
274    ///
275    /// # Examples
276    ///
277    /// ```
278    /// use oauth_resource_server::AuthorizedToken;
279    /// use serde_json::json;
280    ///
281    /// let token = AuthorizedToken::new(Some("user-1".into()), None, ["api:read"])
282    ///     .with_claims(json!({"groups": ["admins", "dev"]}).as_object().unwrap().clone());
283    /// let in_admins = token.claims()["groups"]
284    ///     .as_array()
285    ///     .is_some_and(|g| g.iter().any(|v| v == "admins"));
286    /// assert!(in_admins);
287    /// ```
288    pub fn claims(&self) -> &Map<String, Value> {
289        &self.claims
290    }
291
292    /// The verified claim set deserialized into your own type, for a typed view
293    /// of the claims your application cares about. Unknown claims are ignored
294    /// unless your type says otherwise (`deny_unknown_fields`). The claim map is
295    /// cloned to deserialize it, so call this once per request, not per field.
296    ///
297    /// # Errors
298    ///
299    /// [`serde_json::Error`] when the claims do not fit `T`, for example a
300    /// required field the token did not carry or one of the wrong type.
301    ///
302    /// # Examples
303    ///
304    /// ```
305    /// use oauth_resource_server::AuthorizedToken;
306    /// use serde::Deserialize;
307    /// use serde_json::json;
308    ///
309    /// #[derive(Deserialize)]
310    /// struct Claims {
311    ///     email: String,
312    ///     #[serde(default)]
313    ///     groups: Vec<String>,
314    /// }
315    ///
316    /// let token = AuthorizedToken::new(None, None, ["api:read"]).with_claims(
317    ///     json!({"email": "ada@example.com", "groups": ["admins"]})
318    ///         .as_object()
319    ///         .unwrap()
320    ///         .clone(),
321    /// );
322    /// let claims: Claims = token.claims_as().unwrap();
323    /// assert_eq!(claims.email, "ada@example.com");
324    /// assert_eq!(claims.groups, ["admins"]);
325    /// ```
326    pub fn claims_as<T: DeserializeOwned>(&self) -> Result<T, serde_json::Error> {
327        serde_json::from_value(Value::Object((*self.claims).clone()))
328    }
329
330    /// Replace the claim set, for building a token in a test. Only the claim
331    /// set changes: the typed fields ([`issuer`](Self::issuer),
332    /// [`client_id`](Self::client_id), …) are not re-derived from it, so set
333    /// them with their own `with_*` builders. Verifies nothing.
334    #[must_use]
335    pub fn with_claims(mut self, claims: Map<String, Value>) -> Self {
336        self.claims = Arc::new(claims);
337        self
338    }
339
340    /// Set [`issuer`](Self::issuer), for building a token in a test.
341    #[must_use]
342    pub fn with_issuer(mut self, issuer: impl Into<String>) -> Self {
343        self.issuer = issuer.into();
344        self
345    }
346
347    /// Set [`audiences`](Self::audiences), for building a token in a test.
348    #[must_use]
349    pub fn with_audiences(
350        mut self,
351        audiences: impl IntoIterator<Item = impl Into<String>>,
352    ) -> Self {
353        self.audiences = audiences.into_iter().map(Into::into).collect();
354        self
355    }
356
357    /// Set [`expires_at`](Self::expires_at), for building a token in a test.
358    #[must_use]
359    pub fn with_expires_at(mut self, expires_at: SystemTime) -> Self {
360        self.expires_at = expires_at;
361        self
362    }
363
364    /// Set [`issued_at`](Self::issued_at), for building a token in a test.
365    #[must_use]
366    pub fn with_issued_at(mut self, issued_at: SystemTime) -> Self {
367        self.issued_at = Some(issued_at);
368        self
369    }
370
371    /// Set [`client_id`](Self::client_id), for building a token in a test.
372    #[must_use]
373    pub fn with_client_id(mut self, client_id: impl Into<String>) -> Self {
374        self.client_id = Some(client_id.into());
375        self
376    }
377
378    /// Set [`jti`](Self::jti), for building a token in a test.
379    #[must_use]
380    pub fn with_jti(mut self, jti: impl Into<String>) -> Self {
381        self.jti = Some(jti.into());
382        self
383    }
384
385    /// Whether the token carries `scope` (exact, case-sensitive match, RFC 6749
386    /// §3.3). The single place that answers the question, so callers never
387    /// hand-roll a `.iter().any()` over `scopes`.
388    ///
389    /// # Examples
390    ///
391    /// ```
392    /// use oauth_resource_server::AuthorizedToken;
393    ///
394    /// let token = AuthorizedToken::new(Some("user-1".into()), None, ["api:read", "api:write"]);
395    /// assert!(token.has_scope("api:write"));
396    /// assert!(!token.has_scope("API:WRITE"));
397    /// ```
398    pub fn has_scope(&self, scope: &str) -> bool {
399        self.scopes.iter().any(|s| s == scope)
400    }
401
402    /// Whether the token carries EVERY scope in `required` (all-of): `Ok(())`,
403    /// or the [`MissingScopes`] naming what it lacks. An empty `required`
404    /// passes every token.
405    ///
406    /// The same matching the validator's own `required_scopes` check runs, on
407    /// the same [`scopes`](Self::scopes) (every configured scope claim, a
408    /// string split on whitespace, an array taken element by element), so a
409    /// route asking for a scope here and a validator requiring it agree
410    /// exactly: an exact, case-sensitive comparison per scope (RFC 6749 §3.3).
411    /// An entry that is blank or contains whitespace can never be carried, so
412    /// it is always missing.
413    ///
414    /// This is the per-route or per-operation check on top of the
415    /// validator's floor; the layers' `require_scopes`, the `axum` feature's
416    /// `RequireScopes` and `Scoped` extractor, and the `mcp` feature all run
417    /// it. To answer a refusal with a 403 whose challenge names these scopes,
418    /// see [`crate::refusal_for_scopes`]; `?` converts the error into
419    /// [`TokenRejection::InsufficientScope`].
420    ///
421    /// # Errors
422    ///
423    /// [`MissingScopes`] when at least one entry of `required` is not among
424    /// the token's scopes.
425    ///
426    /// # Examples
427    ///
428    /// ```
429    /// use oauth_resource_server::AuthorizedToken;
430    ///
431    /// let token = AuthorizedToken::new(None, None, ["docs:read"]);
432    /// assert!(token.require_scopes(&["docs:read"]).is_ok());
433    ///
434    /// let missing = token.require_scopes(&["docs:read", "docs:write"]).unwrap_err();
435    /// assert_eq!(missing.required(), ["docs:read", "docs:write"]);
436    /// assert_eq!(missing.missing(), ["docs:write"]);
437    /// ```
438    pub fn require_scopes(&self, required: &[&str]) -> Result<(), MissingScopes> {
439        let missing = missing_scopes(&self.scopes, required.iter().copied());
440        if missing.is_empty() {
441            return Ok(());
442        }
443        Err(MissingScopes {
444            required: required.iter().map(|s| (*s).to_string()).collect(),
445            missing: missing.into_iter().map(str::to_string).collect(),
446        })
447    }
448}
449
450/// The entries of `required` that `present` does not carry, in order: the one
451/// scope-matching rule, shared by the validator's `required_scopes` check and
452/// [`AuthorizedToken::require_scopes`] (exact, case-sensitive, all-of).
453pub(crate) fn missing_scopes<'r>(
454    present: &[String],
455    required: impl IntoIterator<Item = &'r str>,
456) -> Vec<&'r str> {
457    required
458        .into_iter()
459        .filter(|required| !present.iter().any(|p| p == required))
460        .collect()
461}
462
463/// A token that is valid but lacks scopes a route or operation requires; see
464/// [`AuthorizedToken::require_scopes`]. It answers with 403
465/// `insufficient_scope`: convert it with `?` (or `From`) into
466/// [`TokenRejection::InsufficientScope`], and build the response with
467/// [`crate::refusal_for_scopes`] so the challenge names what was required.
468///
469/// Scopes are not secret; `Display` names the missing ones.
470///
471/// `#[non_exhaustive]`: read it through its accessors.
472#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
473#[error("insufficient scope: missing {}", .missing.join(" "))]
474#[non_exhaustive]
475pub struct MissingScopes {
476    required: Vec<String>,
477    missing: Vec<String>,
478}
479
480impl MissingScopes {
481    /// Every scope that was required, in the order given.
482    pub fn required(&self) -> &[String] {
483        &self.required
484    }
485
486    /// The required scopes the token does not carry, in the order given;
487    /// never empty.
488    pub fn missing(&self) -> &[String] {
489        &self.missing
490    }
491}
492
493impl From<MissingScopes> for TokenRejection {
494    fn from(_: MissingScopes) -> Self {
495        TokenRejection::InsufficientScope
496    }
497}
498
499/// Why a bearer credential was refused, and — crucially — with which HTTP status.
500///
501/// The split is the whole point: RFC 6750 distinguishes "this token is not good"
502/// (401 `invalid_token`, go get a new one) from "this token is fine but not
503/// sufficient" (403 `insufficient_scope`). A client that gets 401 for an
504/// insufficient-scope token will loop through the authorization flow forever and
505/// land back on the same refusal.
506///
507/// | Variant | Status | `WWW-Authenticate` (with OAuth configured) |
508/// |---|---|---|
509/// | [`Missing`](Self::Missing) | 401 | [`crate::OAuthValidator::invalid_token_challenge`] |
510/// | [`Invalid`](Self::Invalid) | 401 | [`crate::OAuthValidator::invalid_token_challenge`] |
511/// | [`InsufficientScope`](Self::InsufficientScope) | 403 | [`crate::OAuthValidator::insufficient_scope_challenge`] |
512///
513/// `#[non_exhaustive]`: treat a variant this code does not know as a 401.
514///
515/// It implements [`std::error::Error`], so `?` carries it into
516/// `Box<dyn Error>` or `anyhow`. `Display` deliberately renders the category
517/// only — `missing credential`, `invalid token`, `insufficient scope` — and
518/// never [`Invalid`](Self::Invalid)'s reason, so a careless `format!("{e}")`
519/// in a response body cannot tell a caller which check failed. The reason is
520/// reachable through the variant itself ([`InvalidToken`]) and through
521/// `Debug`, for logs.
522///
523/// ```
524/// use oauth_resource_server::{InvalidToken, InvalidTokenKind, TokenRejection};
525///
526/// let e = TokenRejection::Invalid(InvalidToken::new(
527///     InvalidTokenKind::WrongAudience,
528///     "token rejected: InvalidAudience",
529/// ));
530/// assert_eq!(e.to_string(), "invalid token");
531/// assert!(format!("{e:?}").contains("InvalidAudience"));
532/// ```
533#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
534#[non_exhaustive]
535pub enum TokenRejection {
536    /// 401: the request carried no credential at all. Separate from `Invalid` so
537    /// a server can log it quietly — every OAuth client's first request looks
538    /// like this — not because the response differs (it should not: a missing
539    /// credential gets the same `invalid_token` challenge as a bad one).
540    #[error("missing credential")]
541    Missing,
542    /// 401 `invalid_token`: malformed, unsigned, wrong issuer/audience/type,
543    /// expired, or signed by a key we could not obtain. The
544    /// [`InvalidToken`] says which check failed: its
545    /// [`kind`](InvalidToken::kind) is stable and matchable (a metrics label
546    /// via [`InvalidTokenKind::as_str`]), its [`detail`](InvalidToken::detail)
547    /// is for logs only — never return it to the caller, since telling an
548    /// unauthenticated client exactly which check failed is a free oracle.
549    #[error("invalid token")]
550    Invalid(InvalidToken),
551    /// 403 `insufficient_scope`: signature, issuer, audience and expiry all
552    /// passed, but the token does not carry every required scope.
553    #[error("insufficient scope")]
554    InsufficientScope,
555}
556
557impl TokenRejection {
558    /// `Invalid` of `kind`: the one constructor every refusal this crate makes
559    /// goes through, so each site names its kind.
560    pub(crate) fn invalid(kind: InvalidTokenKind, detail: impl Into<String>) -> Self {
561        Self::Invalid(InvalidToken::new(kind, detail))
562    }
563}
564
565/// Why a credential was refused as [`TokenRejection::Invalid`]: a stable,
566/// matchable [`kind`](Self::kind) and a log-only [`detail`](Self::detail).
567///
568/// `Display` (and `Deref<Target = str>`) is the detail — the same reason text
569/// `Invalid` carried as a `String` before 0.2.0 — so logging it, calling
570/// `str` methods on it and comparing it with a string literal keep working.
571/// Match on [`kind`](Self::kind), never on the text: the kind a given refusal
572/// carries is part of this crate's semver contract, its wording is not.
573///
574/// Equality compares the **detail only** — between two `InvalidToken`s as
575/// with a `str` or `String` — so a 0.1-style
576/// `assert_eq!(r, TokenRejection::Invalid("..".into()))` keeps passing
577/// against a refusal the crate made. Assert [`kind`](Self::kind) separately
578/// when the kind matters. With the `serde` feature it serializes as its
579/// kind's [`as_str`](InvalidTokenKind::as_str) label (`"expired"`), never
580/// the detail, so a serialized refusal cannot leak the log-only text.
581///
582/// # Security
583///
584/// The detail names the check that failed and can quote token-derived text
585/// (truncated). Log it; never put it in a response body, where it would be a
586/// free oracle for an unauthenticated caller. The kind's
587/// [`as_str`](InvalidTokenKind::as_str) label is low-cardinality and meant for
588/// metrics and logs; this crate's own responses never carry it either.
589///
590/// # Examples
591///
592/// ```
593/// use oauth_resource_server::{InvalidToken, InvalidTokenKind, TokenRejection};
594///
595/// fn metrics_label(rejection: &TokenRejection) -> &'static str {
596///     match rejection {
597///         TokenRejection::Missing => "missing",
598///         TokenRejection::Invalid(invalid) => invalid.kind().as_str(),
599///         TokenRejection::InsufficientScope => "insufficient_scope",
600///         _ => "other",
601///     }
602/// }
603///
604/// let expired = TokenRejection::Invalid(InvalidToken::new(
605///     InvalidTokenKind::Expired,
606///     "token rejected: ExpiredSignature",
607/// ));
608/// assert_eq!(metrics_label(&expired), "expired");
609///
610/// // The 0.1 shapes still compile: a string converts (kind `Other`), and the
611/// // reason reads as a `str`.
612/// let legacy = TokenRejection::Invalid("custom refusal".into());
613/// if let TokenRejection::Invalid(reason) = &legacy {
614///     assert_eq!(reason.kind(), InvalidTokenKind::Other);
615///     assert!(reason.contains("custom"));
616///     assert_eq!(reason, "custom refusal");
617///     assert_eq!(reason.to_string(), "custom refusal");
618/// }
619/// ```
620///
621/// A `String` expression needs `.into()`: `Invalid(format!(..))` does not
622/// compile, `Invalid(format!(..).into())` does.
623///
624/// ```compile_fail
625/// # use oauth_resource_server::TokenRejection;
626/// let _ = TokenRejection::Invalid(format!("{} candidates", 3));
627/// ```
628// Equality is hand-written, on the detail only (see the type docs). If `Hash`
629// is ever added it must hash the detail only too, or `a == b` would no longer
630// imply `hash(a) == hash(b)`.
631#[derive(Debug, Clone)]
632#[non_exhaustive]
633pub struct InvalidToken {
634    kind: InvalidTokenKind,
635    detail: String,
636}
637
638impl InvalidToken {
639    /// A refusal of `kind`, with `detail` for the log. Every refusal this
640    /// crate makes is built here; an application building its own (in a test,
641    /// or for a check of its own) can name a kind the same way.
642    pub fn new(kind: InvalidTokenKind, detail: impl Into<String>) -> Self {
643        Self {
644            kind,
645            detail: detail.into(),
646        }
647    }
648
649    /// Which check refused the token: stable and matchable, see
650    /// [`InvalidTokenKind`].
651    pub fn kind(&self) -> InvalidTokenKind {
652        self.kind
653    }
654
655    /// The human-readable reason, for logs only (see the type's `# Security`).
656    /// Its wording may change in any release.
657    pub fn detail(&self) -> &str {
658        &self.detail
659    }
660
661    /// The same as [`detail`](Self::detail), under the name 0.1's `String`
662    /// reason offered (`reason.as_str()`).
663    pub fn as_str(&self) -> &str {
664        &self.detail
665    }
666}
667
668/// Detail only; see the type docs.
669impl PartialEq for InvalidToken {
670    fn eq(&self, other: &Self) -> bool {
671        self.detail == other.detail
672    }
673}
674
675impl Eq for InvalidToken {}
676
677/// The detail, so `std::error::Error` consumers (`Box<dyn Error>`, `?` into
678/// `anyhow`) take an `InvalidToken` as they took the `String`. Log-only, like
679/// the detail itself.
680impl std::error::Error for InvalidToken {}
681
682/// Serializes as the kind's [`as_str`](InvalidTokenKind::as_str) label — a
683/// string such as `"expired"` — never the [`detail`](InvalidToken::detail):
684/// a `#[derive(Serialize)]` response type or `json!({"reason": reason})`
685/// would otherwise carry the log-only text into a body. Serialize
686/// `reason.detail()` explicitly where the detail is wanted, in a log record.
687#[cfg(feature = "serde")]
688#[cfg_attr(docsrs, doc(cfg(feature = "serde")))]
689impl serde::Serialize for InvalidToken {
690    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
691        serializer.serialize_str(self.kind.as_str())
692    }
693}
694
695/// The detail, as the 0.1 `String` was.
696impl From<InvalidToken> for String {
697    fn from(invalid: InvalidToken) -> Self {
698        invalid.detail
699    }
700}
701
702impl fmt::Display for InvalidToken {
703    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
704        f.write_str(&self.detail)
705    }
706}
707
708/// Kind [`InvalidTokenKind::Other`]: what a 0.1-style
709/// `TokenRejection::Invalid(reason.into())` builds.
710impl From<String> for InvalidToken {
711    fn from(detail: String) -> Self {
712        Self::new(InvalidTokenKind::Other, detail)
713    }
714}
715
716/// Kind [`InvalidTokenKind::Other`]: what a 0.1-style
717/// `TokenRejection::Invalid("reason".into())` builds.
718impl From<&str> for InvalidToken {
719    fn from(detail: &str) -> Self {
720        Self::new(InvalidTokenKind::Other, detail)
721    }
722}
723
724/// The [`detail`](InvalidToken::detail), so `str` methods (`contains`,
725/// `starts_with`, ...) keep working on a 0.1-style `Invalid(reason)` binding.
726impl std::ops::Deref for InvalidToken {
727    type Target = str;
728
729    fn deref(&self) -> &str {
730        &self.detail
731    }
732}
733
734/// Compares the [`detail`](InvalidToken::detail) only.
735impl PartialEq<str> for InvalidToken {
736    fn eq(&self, other: &str) -> bool {
737        self.detail == other
738    }
739}
740
741/// Compares the [`detail`](InvalidToken::detail) only.
742impl PartialEq<&str> for InvalidToken {
743    fn eq(&self, other: &&str) -> bool {
744        self.detail == *other
745    }
746}
747
748/// Compares the [`detail`](InvalidToken::detail) only.
749impl PartialEq<String> for InvalidToken {
750    fn eq(&self, other: &String) -> bool {
751        self.detail == *other
752    }
753}
754
755/// Compares the [`detail`](InvalidToken::detail) only.
756impl PartialEq<InvalidToken> for str {
757    fn eq(&self, other: &InvalidToken) -> bool {
758        self == other.detail
759    }
760}
761
762/// Compares the [`detail`](InvalidToken::detail) only.
763impl PartialEq<InvalidToken> for &str {
764    fn eq(&self, other: &InvalidToken) -> bool {
765        *self == other.detail
766    }
767}
768
769/// Compares the [`detail`](InvalidToken::detail) only.
770impl PartialEq<InvalidToken> for String {
771    fn eq(&self, other: &InvalidToken) -> bool {
772        *self == other.detail
773    }
774}
775
776/// Which check refused a token — see [`InvalidToken::kind`].
777///
778/// Each kind names one family of checks, and [`as_str`](Self::as_str) gives it
779/// a stable, low-cardinality `snake_case` label for metrics and alerting (for
780/// example, [`KeySetUnavailable`](Self::KeySetUnavailable) is an
781/// authorization-server outage, not junk traffic). Every kind is a 401
782/// `invalid_token`; the kind changes nothing about the response.
783///
784/// `#[non_exhaustive]`: a kind may be added in a minor release, so match with a
785/// wildcard arm. Which kind an existing refusal carries, and each kind's label,
786/// are stable; the [`InvalidToken::detail`] text is not.
787///
788/// # Examples
789///
790/// ```
791/// use oauth_resource_server::{InvalidToken, InvalidTokenKind};
792///
793/// /// Whether a refusal points at the authorization server rather than at the
794/// /// caller — worth an alert of its own.
795/// fn is_idp_trouble(invalid: &InvalidToken) -> bool {
796///     match invalid.kind() {
797///         InvalidTokenKind::KeySetUnavailable => true,
798///         InvalidTokenKind::Expired | InvalidTokenKind::BadSignature => false,
799///         _ => false,
800///     }
801/// }
802///
803/// let outage = InvalidToken::new(InvalidTokenKind::KeySetUnavailable, "JWKS refresh failed");
804/// assert!(is_idp_trouble(&outage));
805/// assert_eq!(outage.kind().as_str(), "key_set_unavailable");
806/// ```
807#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
808#[non_exhaustive]
809pub enum InvalidTokenKind {
810    /// The credential is over the 16 KiB cap; refused before it is decoded.
811    TooLarge,
812    /// The credential is not three dot-separated segments: a mistyped static
813    /// token, or an opaque (non-JWT) access token.
814    NotJwt,
815    /// The JWS protected header is not a base64url JSON object this crate can
816    /// read (including an `alg` it does not know at all, such as `none`).
817    MalformedHeader,
818    /// The header lists critical extensions (`crit`, RFC 7515 §4.1.11), none of
819    /// which this crate supports.
820    CriticalHeader,
821    /// The header's `alg` is not in the configured allowlist.
822    AlgorithmNotAllowed,
823    /// The header's `typ` is not an access-token type (or is absent or `JWT`
824    /// while `require_at_jwt` is on).
825    TypeNotAllowed,
826    /// No held key matches the token's `kid` and `alg` while the key set is
827    /// healthy: not in it even after a refetch, or unknown while the
828    /// unknown-`kid` refetch cooldown runs and the last refresh succeeded.
829    KeyNotFound,
830    /// The key set could not be loaded: discovery or the JWKS fetch failed,
831    /// including during the refetch cooldown when the last refresh failed or
832    /// no key is held at all. An authorization-server (or network) outage,
833    /// not the caller's fault.
834    KeySetUnavailable,
835    /// The header checks passed but the rest does not decode: the payload is
836    /// not base64url JSON (an object), or the signature is not base64url.
837    MalformedToken,
838    /// The signature does not verify with the selected key (or the key could
839    /// not be used to verify it).
840    BadSignature,
841    /// `exp` is in the past (beyond the configured leeway).
842    Expired,
843    /// `nbf` is in the future (beyond the configured leeway), or, with
844    /// `max_token_age_secs` set, `iat` is.
845    NotYetValid,
846    /// `iss` is not exactly the configured issuer (including an `iss` array).
847    WrongIssuer,
848    /// No `aud` entry is an accepted audience.
849    WrongAudience,
850    /// A claim the checks need is absent: `exp`, `iss` or `aud`; `iat` with
851    /// `max_token_age_secs` set; a `required_claims` entry.
852    MissingClaim,
853    /// A claim the checks need is present but unreadable: an `exp`, `iss` or
854    /// `aud` of the wrong type, an `nbf` (or, with `max_token_age_secs` set,
855    /// an `iat`) that is not a NumericDate.
856    MalformedClaim,
857    /// The token carries `cnf` (a DPoP or mTLS sender constraint), which this
858    /// crate cannot verify and so refuses as a bearer token.
859    SenderConstrained,
860    /// `allowed_client_ids` is set and the token's client (`client_id`, else
861    /// `azp`) is absent or not listed.
862    ClientNotAllowed,
863    /// `max_token_age_secs` is set and the token was issued (`iat`) longer ago
864    /// than that, plus the leeway.
865    TokenTooOld,
866    /// A `required_claims` entry is present in the token with another value
867    /// (and, for an array claim, not among its elements).
868    ClaimMismatch,
869    /// Only static tokens are configured (no OAuth validator) and no
870    /// credential is one of them.
871    StaticTokenMismatch,
872    /// Neither a static token nor an OAuth validator is configured.
873    NoMechanism,
874    /// A credential was accepted, but the handler needs an OAuth access token
875    /// (the axum `AuthorizedToken` extractor) and got a static token.
876    OAuthTokenRequired,
877    /// A credential was accepted, but the handler needs a static token (the
878    /// axum `StaticTokenMatch` extractor) and got an OAuth access token.
879    StaticTokenRequired,
880    /// Anything else: every [`InvalidToken`] built from a `String` or `&str`,
881    /// and a decoder failure this crate cannot classify more precisely.
882    Other,
883}
884
885impl InvalidTokenKind {
886    /// A stable, lowercase `snake_case` label (`"expired"`, `"bad_signature"`,
887    /// `"key_set_unavailable"`, ...), for a metrics label or a log field. It
888    /// does not change between releases for an existing kind.
889    pub fn as_str(self) -> &'static str {
890        match self {
891            Self::TooLarge => "too_large",
892            Self::NotJwt => "not_jwt",
893            Self::MalformedHeader => "malformed_header",
894            Self::CriticalHeader => "critical_header",
895            Self::AlgorithmNotAllowed => "algorithm_not_allowed",
896            Self::TypeNotAllowed => "type_not_allowed",
897            Self::KeyNotFound => "key_not_found",
898            Self::KeySetUnavailable => "key_set_unavailable",
899            Self::MalformedToken => "malformed_token",
900            Self::BadSignature => "bad_signature",
901            Self::Expired => "expired",
902            Self::NotYetValid => "not_yet_valid",
903            Self::WrongIssuer => "wrong_issuer",
904            Self::WrongAudience => "wrong_audience",
905            Self::MissingClaim => "missing_claim",
906            Self::MalformedClaim => "malformed_claim",
907            Self::SenderConstrained => "sender_constrained",
908            Self::ClientNotAllowed => "client_not_allowed",
909            Self::TokenTooOld => "token_too_old",
910            Self::ClaimMismatch => "claim_mismatch",
911            Self::StaticTokenMismatch => "static_token_mismatch",
912            Self::NoMechanism => "no_mechanism",
913            Self::OAuthTokenRequired => "oauth_token_required",
914            Self::StaticTokenRequired => "static_token_required",
915            Self::Other => "other",
916        }
917    }
918}
919
920impl fmt::Display for InvalidTokenKind {
921    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
922        f.write_str(self.as_str())
923    }
924}
925
926/// The union of every configured scope claim, in first-seen order, deduplicated.
927///
928/// Every claim is read in every shape: a string is split on whitespace (RFC 9068
929/// §2.2.3's `scope`, and Entra ID's / Hydra's string `scp`), an array contributes
930/// each string element whole (Authelia's and Okta's `scp`). Anything else — a
931/// number, an object, a claim the token does not have — contributes nothing rather
932/// than failing the token, since the only consequence of "no scopes found" is the
933/// 403 for a missing required scope, which is the correct answer anyway.
934pub(crate) fn extract_scopes(claims: &Map<String, Value>, claim_names: &[String]) -> Vec<String> {
935    let mut seen = HashSet::new();
936    let mut out = Vec::new();
937    let mut push = |s: &str| {
938        let s = s.trim();
939        if !s.is_empty() && seen.insert(s.to_string()) {
940            out.push(s.to_string());
941        }
942    };
943    for name in claim_names {
944        match claims.get(name) {
945            Some(Value::String(s)) => s.split_whitespace().for_each(&mut push),
946            Some(Value::Array(items)) => items.iter().filter_map(Value::as_str).for_each(&mut push),
947            _ => {}
948        }
949    }
950    out
951}
952
953/// The first present, non-empty string claim among `claim_names`, verbatim.
954/// Truncate it (`for_log`) where it is logged, never here.
955pub(crate) fn extract_principal(
956    claims: &Map<String, Value>,
957    claim_names: &[String],
958) -> Option<String> {
959    claim_names.iter().find_map(|name| match claims.get(name) {
960        Some(Value::String(s)) if !s.trim().is_empty() => Some(s.clone()),
961        _ => None,
962    })
963}
964
965/// RFC 9068 §2.1 / §4: the header `typ` of a JWT access token is `at+jwt`
966/// (`application/at+jwt` is the same media type, RFC 7515 §4.1.9, compared
967/// case-insensitively). Many servers still emit `JWT` or nothing (Authentik, Entra
968/// ID, Okta, Keycloak by default), so those pass unless `require_at_jwt` is on —
969/// which an operator whose AS does emit `at+jwt` (Authelia, Kanidm) should turn on,
970/// since it is the one check that tells an access token from an ID token minted
971/// for the same client. Any OTHER explicit type (`dpop+jwt`, `logout+jwt`,
972/// `secevent+jwt`...) is a different kind of JWT and is always refused.
973///
974/// `naming` only shapes the (log-only) rejection reason.
975pub(crate) fn check_typ(
976    typ: Option<&str>,
977    require_at_jwt: bool,
978    naming: &KeyNamingBuf,
979) -> Result<(), TokenRejection> {
980    let Some(raw) = typ else {
981        return if require_at_jwt {
982            Err(TokenRejection::invalid(
983                InvalidTokenKind::TypeNotAllowed,
984                format!(
985                    "token header has no typ and {} is on",
986                    naming.key("require_at_jwt")
987                ),
988            ))
989        } else {
990            Ok(())
991        };
992    };
993    let lower = raw.trim().to_ascii_lowercase();
994    let media = lower.strip_prefix("application/").unwrap_or(&lower);
995    match media {
996        "at+jwt" => Ok(()),
997        "jwt" if !require_at_jwt => Ok(()),
998        _ => Err(TokenRejection::invalid(
999            InvalidTokenKind::TypeNotAllowed,
1000            format!(
1001                "token typ {:?} is not accepted as an access token{}",
1002                for_log(raw),
1003                if require_at_jwt {
1004                    format!(" ({} is on)", naming.key("require_at_jwt"))
1005                } else {
1006                    String::new()
1007                }
1008            ),
1009        )),
1010    }
1011}
1012
1013/// Test helper: `result` is an `Invalid` of `kind` with exactly `detail`.
1014/// `InvalidToken`'s equality compares the detail only, so a test that means
1015/// the kind too says so through this.
1016#[cfg(test)]
1017#[track_caller]
1018pub(crate) fn assert_invalid<T: fmt::Debug>(
1019    result: Result<T, TokenRejection>,
1020    kind: InvalidTokenKind,
1021    detail: &str,
1022    context: &str,
1023) {
1024    match result {
1025        Err(TokenRejection::Invalid(invalid)) => {
1026            assert_eq!(invalid.kind(), kind, "{context}");
1027            assert_eq!(invalid.detail(), detail, "{context}");
1028        }
1029        other => panic!("expected Invalid({kind:?}), got {other:?} {context}"),
1030    }
1031}
1032
1033/// Truncate a token-derived string for a log line. See [`MAX_LOGGED_CHARS`].
1034pub(crate) fn for_log(s: &str) -> String {
1035    let mut out: String = s.chars().take(MAX_LOGGED_CHARS).collect();
1036    if s.chars().count() > MAX_LOGGED_CHARS {
1037        out.push('…');
1038    }
1039    out
1040}
1041
1042/// A token's scopes for a log field, each through [`for_log`]: they come
1043/// from the (signed) token, but a scope may still be any length up to the
1044/// credential cap.
1045pub(crate) fn scopes_for_log(scopes: &[String]) -> Vec<String> {
1046    scopes.iter().map(|s| for_log(s)).collect()
1047}
1048
1049/// A token-derived string for a tracing span field (the unverified header's
1050/// `kid` and `alg`): [`for_log`]'s truncation, then every character outside
1051/// printable ASCII escaped as `\u{..}`. A field is written verbatim by some
1052/// subscribers (a JSON formatter, an OpenTelemetry exporter) rather than
1053/// through `Debug`, so the escaping is done here: no control character, ANSI
1054/// escape or bidi override from an attacker's header reaches a terminal or a
1055/// log store raw. At most `MAX_LOGGED_CHARS` input characters, each at most
1056/// ten output bytes, plus the ellipsis.
1057pub(crate) fn for_log_field(s: &str) -> String {
1058    let bounded = for_log(s);
1059    let mut out = String::with_capacity(bounded.len());
1060    for c in bounded.chars() {
1061        if c.is_ascii_graphic() || c == ' ' || c == '…' {
1062            out.push(c);
1063        } else {
1064            out.extend(c.escape_unicode());
1065        }
1066    }
1067    out
1068}
1069
1070/// A `kid` for a log line or rejection reason: quoted and truncated, or `(none)`.
1071pub(crate) fn describe_kid(kid: Option<&str>) -> String {
1072    match kid {
1073        Some(kid) => format!("{:?}", for_log(kid)),
1074        None => "(none)".to_string(),
1075    }
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080    use super::*;
1081
1082    #[test]
1083    fn new_fills_the_metadata_fields_with_documented_test_defaults() {
1084        let t = AuthorizedToken::new(Some("sub-1".into()), None, ["a"]);
1085        assert_eq!(t.issuer, "");
1086        assert!(t.audiences.is_empty());
1087        assert_eq!(t.issued_at, None);
1088        assert_eq!(t.client_id, None);
1089        assert_eq!(t.jti, None);
1090        assert!(t.claims().is_empty());
1091        // 2100-01-01T00:00:00Z: a fixture is never already expired.
1092        assert_eq!(
1093            t.expires_at,
1094            UNIX_EPOCH + Duration::from_secs(4_102_444_800)
1095        );
1096        assert!(t.expires_at > SystemTime::now());
1097    }
1098
1099    #[test]
1100    fn builders_set_their_field_and_with_claims_leaves_the_rest_alone() {
1101        let claims = serde_json::json!({"groups": ["g1"], "n": 1});
1102        let claims = claims.as_object().unwrap().clone();
1103        let exp = UNIX_EPOCH + Duration::from_secs(1_000);
1104        let t = AuthorizedToken::new(Some("sub-1".into()), Some("p".into()), ["a"])
1105            .with_claims(claims.clone())
1106            .with_issuer("https://issuer.example.test")
1107            .with_audiences(["aud-1", "aud-2"])
1108            .with_expires_at(exp)
1109            .with_issued_at(UNIX_EPOCH)
1110            .with_client_id("client-1")
1111            .with_jti("jti-1");
1112        assert_eq!(t.claims(), &claims);
1113        assert_eq!(t.issuer, "https://issuer.example.test");
1114        assert_eq!(t.audiences, ["aud-1", "aud-2"]);
1115        assert_eq!(t.expires_at, exp);
1116        assert_eq!(t.issued_at, Some(UNIX_EPOCH));
1117        assert_eq!(t.client_id.as_deref(), Some("client-1"));
1118        assert_eq!(t.jti.as_deref(), Some("jti-1"));
1119        assert_eq!(t.subject.as_deref(), Some("sub-1"));
1120        assert_eq!(t.scopes, ["a"]);
1121        // `with_claims` alone does not derive typed fields from the map.
1122        let only = AuthorizedToken::new(None, None, Vec::<String>::new()).with_claims(
1123            serde_json::json!({"iss": "x", "jti": "y"})
1124                .as_object()
1125                .unwrap()
1126                .clone(),
1127        );
1128        assert_eq!(only.issuer, "");
1129        assert_eq!(only.jti, None);
1130    }
1131
1132    #[test]
1133    fn equality_and_clone_cover_the_claims() {
1134        let a = AuthorizedToken::new(None, None, ["a"])
1135            .with_claims(serde_json::json!({"k": 1}).as_object().unwrap().clone());
1136        assert_eq!(a.clone(), a);
1137        assert_ne!(a, AuthorizedToken::new(None, None, ["a"]));
1138    }
1139
1140    #[test]
1141    fn numeric_date_reads_like_jsonwebtoken_and_saturates_and_never_panics() {
1142        let secs = |s: u64| Some(UNIX_EPOCH + Duration::from_secs(s));
1143        let max = secs(MAX_TIMESTAMP_SECS);
1144        assert_eq!(numeric_date(&serde_json::json!(10)), secs(10));
1145        // Rounded to the nearest second, half away from zero, as jsonwebtoken does.
1146        assert_eq!(numeric_date(&serde_json::json!(1.4)), secs(1));
1147        assert_eq!(numeric_date(&serde_json::json!(1.5)), secs(2));
1148        assert_eq!(numeric_date(&serde_json::json!(0.4)), secs(0));
1149        assert_eq!(numeric_date(&serde_json::json!(u64::MAX)), max);
1150        assert_eq!(numeric_date(&serde_json::json!(i64::MAX as u64 + 1)), max);
1151        assert_eq!(numeric_date(&serde_json::json!(1e19)), max);
1152        assert_eq!(numeric_date(&serde_json::json!(-1)), None);
1153        assert_eq!(numeric_date(&serde_json::json!(-0.4)), None);
1154        assert_eq!(numeric_date(&serde_json::json!(1e30)), None);
1155        assert_eq!(numeric_date(&serde_json::json!("10")), None);
1156    }
1157
1158    #[test]
1159    fn require_scopes_is_all_of_and_names_what_is_missing() {
1160        let t = AuthorizedToken::new(None, None, ["a", "b"]);
1161        assert_eq!(t.require_scopes(&[]), Ok(()));
1162        assert_eq!(t.require_scopes(&["a"]), Ok(()));
1163        assert_eq!(t.require_scopes(&["b", "a"]), Ok(()));
1164        let missing = t.require_scopes(&["a", "c", "b", "d"]).unwrap_err();
1165        assert_eq!(missing.required(), ["a", "c", "b", "d"]);
1166        assert_eq!(missing.missing(), ["c", "d"]);
1167        assert_eq!(missing.to_string(), "insufficient scope: missing c d");
1168        assert_eq!(
1169            TokenRejection::from(missing),
1170            TokenRejection::InsufficientScope
1171        );
1172        // Exact and case-sensitive; an entry no token can carry is missing.
1173        for never in ["A", "a ", "", "a b"] {
1174            assert_eq!(
1175                t.require_scopes(&[never]).unwrap_err().missing(),
1176                [never],
1177                "{never:?}"
1178            );
1179        }
1180    }
1181
1182    #[test]
1183    fn require_scopes_matches_exactly_what_the_validator_extracts() {
1184        // Every accepted claim shape, read by the validator's own reader:
1185        // `require_scopes` on the result agrees with `missing_scopes`, the
1186        // rule `verify` applies to the same list.
1187        let claims = serde_json::json!({
1188            "scope": "a  b\tc",
1189            "scp": ["d", "e f", 7],
1190        });
1191        let names = vec!["scope".to_string(), "scp".to_string()];
1192        let scopes = extract_scopes(claims.as_object().unwrap(), &names);
1193        let t = AuthorizedToken::new(None, None, scopes.clone());
1194        for required in [
1195            vec!["a", "b", "c", "d"],
1196            vec!["e f"],
1197            vec!["e"],
1198            vec!["7"],
1199            vec!["a", "x"],
1200        ] {
1201            let by_token = t
1202                .require_scopes(&required)
1203                .map_err(|m| m.missing().to_vec());
1204            let by_rule = missing_scopes(&scopes, required.iter().copied());
1205            assert_eq!(
1206                by_token.is_ok(),
1207                by_rule.is_empty(),
1208                "{required:?}: {by_token:?} vs {by_rule:?}"
1209            );
1210        }
1211        assert!(t.require_scopes(&["a", "b", "c", "d", "e f"]).is_ok());
1212        assert_eq!(
1213            t.require_scopes(&["e", "7"]).unwrap_err().missing(),
1214            ["e", "7"]
1215        );
1216    }
1217
1218    #[test]
1219    fn authorized_token_new_dedupes_scopes_like_a_validation() {
1220        let t = AuthorizedToken::new(Some("sub-1".into()), None, ["b", " a", "b", "", "a", "c"]);
1221        assert_eq!(t.subject.as_deref(), Some("sub-1"));
1222        assert_eq!(t.principal, None);
1223        assert_eq!(t.scopes, ["b", "a", "c"]);
1224        assert!(t.has_scope("a"));
1225        assert!(!t.has_scope(""));
1226        let none = AuthorizedToken::new(None, None, Vec::<String>::new());
1227        assert!(none.scopes.is_empty());
1228    }
1229
1230    #[test]
1231    fn a_rejection_displays_its_category_but_never_the_reason() {
1232        let secret_reason = "token rejected: InvalidAudience";
1233        let invalid = TokenRejection::Invalid(secret_reason.into());
1234        assert_eq!(invalid.to_string(), "invalid token");
1235        assert!(!invalid.to_string().contains("InvalidAudience"));
1236        assert!(format!("{invalid:?}").contains(secret_reason));
1237        assert_eq!(TokenRejection::Missing.to_string(), "missing credential");
1238        assert_eq!(
1239            TokenRejection::InsufficientScope.to_string(),
1240            "insufficient scope"
1241        );
1242        let boxed: Box<dyn std::error::Error + Send + Sync> = Box::new(invalid);
1243        assert_eq!(boxed.to_string(), "invalid token");
1244    }
1245
1246    #[test]
1247    fn logged_values_are_truncated() {
1248        let long = "x".repeat(MAX_LOGGED_CHARS * 3);
1249        assert_eq!(for_log(&long).chars().count(), MAX_LOGGED_CHARS + 1);
1250        assert_eq!(for_log("short"), "short");
1251    }
1252
1253    #[test]
1254    fn span_field_values_are_truncated_and_escaped() {
1255        assert_eq!(for_log_field("kid-1 A"), "kid-1 A");
1256        assert_eq!(
1257            for_log_field("\u{1b}[31mx\u{7}\r\n\u{202e}é"),
1258            "\\u{1b}[31mx\\u{7}\\u{d}\\u{a}\\u{202e}\\u{e9}"
1259        );
1260        let hostile = "\u{1b}".repeat(5 * 1024);
1261        let shown = for_log_field(&hostile);
1262        assert_eq!(shown, format!("{}…", "\\u{1b}".repeat(MAX_LOGGED_CHARS)));
1263        assert!(for_log_field(&"\u{10ffff}".repeat(1000)).len() <= MAX_LOGGED_CHARS * 10 + 3);
1264    }
1265
1266    #[test]
1267    fn typ_rejection_reasons_name_the_setting_per_key_naming() {
1268        let typ = |detail: &str| {
1269            Err(TokenRejection::Invalid(InvalidToken::new(
1270                InvalidTokenKind::TypeNotAllowed,
1271                detail,
1272            )))
1273        };
1274        let dotted = KeyNamingBuf::Dotted("mcp.oauth".into());
1275        assert_eq!(
1276            check_typ(None, true, &dotted),
1277            typ("token header has no typ and mcp.oauth.require_at_jwt is on")
1278        );
1279        assert_eq!(
1280            check_typ(Some("JWT"), true, &dotted),
1281            typ("token typ \"JWT\" is not accepted as an access token \
1282                 (mcp.oauth.require_at_jwt is on)")
1283        );
1284        assert_eq!(
1285            check_typ(Some("dpop+jwt"), false, &dotted),
1286            typ("token typ \"dpop+jwt\" is not accepted as an access token")
1287        );
1288        let env = KeyNamingBuf::Env("APP_OAUTH_".into());
1289        assert_eq!(
1290            check_typ(None, true, &env),
1291            typ("token header has no typ and APP_OAUTH_REQUIRE_AT_JWT is on")
1292        );
1293        // Equality is detail-only, so the kind is asserted on its own.
1294        for (t, require) in [(None, true), (Some("JWT"), true), (Some("dpop+jwt"), false)] {
1295            let Err(TokenRejection::Invalid(invalid)) = check_typ(t, require, &dotted) else {
1296                panic!("{t:?} passed");
1297            };
1298            assert_eq!(invalid.kind(), InvalidTokenKind::TypeNotAllowed);
1299        }
1300    }
1301
1302    /// The 0.1 uses of `Invalid(String)` that 0.2 keeps compiling, each as a
1303    /// consumer would write it.
1304    #[test]
1305    #[allow(clippy::op_ref, clippy::cmp_owned)]
1306    fn invalid_token_keeps_the_string_uses_compiling() {
1307        // Construction from a literal and from an owned `String`: kind `Other`.
1308        let from_str = TokenRejection::Invalid("bad token".into());
1309        let from_string = TokenRejection::Invalid(String::from("bad token").into());
1310        assert_eq!(from_str, from_string);
1311        let TokenRejection::Invalid(reason) = &from_str else {
1312            panic!("not Invalid")
1313        };
1314        assert_eq!(reason.kind(), InvalidTokenKind::Other);
1315        assert_eq!(reason.detail(), "bad token");
1316        // `Display` is the detail, byte for byte; `format!`/`to_string` too.
1317        assert_eq!(reason.to_string(), "bad token");
1318        assert_eq!(format!("refused: {reason}"), "refused: bad token");
1319        // `Deref<Target = str>`: `str` methods and `&str` coercion.
1320        assert!(reason.contains("bad"));
1321        assert!(reason.starts_with("bad "));
1322        assert_eq!(reason.len(), 9);
1323        let as_str: &str = reason;
1324        assert_eq!(as_str, "bad token");
1325        // `PartialEq<str>` and `PartialEq<&str>`, from a reference and a value.
1326        assert!(reason == "bad token");
1327        assert!(*reason == "bad token");
1328        assert!(reason.clone() == "bad token");
1329        assert!(&**reason == "bad token");
1330        assert!(reason != "other");
1331        // `matches!` on the variant, with and without a binding.
1332        assert!(matches!(from_str, TokenRejection::Invalid(_)));
1333        assert!(matches!(&from_str, TokenRejection::Invalid(r) if r.contains("bad")));
1334        // Equality between two `InvalidToken`s compares the detail only, so
1335        // a 0.1-style comparison with a hand-built reason keeps passing.
1336        assert_eq!(
1337            InvalidToken::new(InvalidTokenKind::Expired, "bad token"),
1338            InvalidToken::from("bad token")
1339        );
1340        assert_ne!(
1341            InvalidToken::new(InvalidTokenKind::Expired, "bad token"),
1342            InvalidToken::new(InvalidTokenKind::Expired, "other")
1343        );
1344        // `as_str`, `String` comparisons both ways, and `str`/`&str` on the left.
1345        assert_eq!(reason.as_str(), "bad token");
1346        let owned = String::from("bad token");
1347        assert!(*reason == owned);
1348        assert!(owned == *reason);
1349        assert!("bad token" == *reason);
1350        assert!("bad token" == reason.clone());
1351        // Into an owned `String`, and into a boxed error.
1352        let s: String = reason.clone().into();
1353        assert_eq!(s, "bad token");
1354        let boxed: Box<dyn std::error::Error> = reason.clone().into();
1355        assert_eq!(boxed.to_string(), "bad token");
1356        // `Debug` still carries the reason, for logs.
1357        assert!(format!("{from_str:?}").contains("bad token"));
1358        // `Invalid(format!(..))` alone no longer compiles; `.into()` does.
1359        let n = 3;
1360        let formatted = TokenRejection::Invalid(format!("{n} candidates").into());
1361        assert!(matches!(formatted, TokenRejection::Invalid(r) if r == "3 candidates"));
1362    }
1363
1364    #[test]
1365    fn every_invalid_token_kind_label_is_distinct_snake_case() {
1366        use InvalidTokenKind as K;
1367        let all = [
1368            K::TooLarge,
1369            K::NotJwt,
1370            K::MalformedHeader,
1371            K::CriticalHeader,
1372            K::AlgorithmNotAllowed,
1373            K::TypeNotAllowed,
1374            K::KeyNotFound,
1375            K::KeySetUnavailable,
1376            K::MalformedToken,
1377            K::BadSignature,
1378            K::Expired,
1379            K::NotYetValid,
1380            K::WrongIssuer,
1381            K::WrongAudience,
1382            K::MissingClaim,
1383            K::MalformedClaim,
1384            K::SenderConstrained,
1385            K::ClientNotAllowed,
1386            K::TokenTooOld,
1387            K::ClaimMismatch,
1388            K::StaticTokenMismatch,
1389            K::NoMechanism,
1390            K::OAuthTokenRequired,
1391            K::StaticTokenRequired,
1392            K::Other,
1393        ];
1394        let labels: HashSet<&str> = all.iter().map(|k| k.as_str()).collect();
1395        assert_eq!(labels.len(), all.len());
1396        for (kind, label) in all.iter().zip(all.iter().map(|k| k.as_str())) {
1397            assert!(
1398                label.bytes().all(|b| b.is_ascii_lowercase() || b == b'_'),
1399                "{label}"
1400            );
1401            assert_eq!(kind.to_string(), label);
1402        }
1403    }
1404
1405    #[cfg(feature = "serde")]
1406    #[test]
1407    fn invalid_token_serializes_as_its_kind_label_never_the_detail() {
1408        let reason = InvalidToken::new(
1409            InvalidTokenKind::ClientNotAllowed,
1410            "token client \"secret-client\" is not in oauth.allowed_client_ids",
1411        );
1412        let body = serde_json::json!({ "reason": reason });
1413        assert_eq!(body, serde_json::json!({ "reason": "client_not_allowed" }));
1414        assert!(!body.to_string().contains("secret-client"), "{body}");
1415        let expired = InvalidToken::new(InvalidTokenKind::Expired, "token rejected: expired");
1416        assert_eq!(
1417            serde_json::to_string(&expired).unwrap(),
1418            "\"expired\"",
1419            "a derived response type gets the label too"
1420        );
1421    }
1422}