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}