use std::collections::HashSet;
use std::fmt;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::de::DeserializeOwned;
use serde_json::{Map, Value};
use crate::config::KeyNamingBuf;
pub(crate) const MAX_TOKEN_BYTES: usize = 16 * 1024;
pub(crate) const MAX_LOGGED_CHARS: usize = 128;
const FAR_FUTURE_SECS: u64 = 4_102_444_800;
#[derive(Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct AuthorizedToken {
pub subject: Option<String>,
pub principal: Option<String>,
pub scopes: Vec<String>,
pub issuer: String,
pub audiences: Vec<String>,
pub expires_at: SystemTime,
pub issued_at: Option<SystemTime>,
pub client_id: Option<String>,
pub jti: Option<String>,
claims: Arc<Map<String, Value>>,
}
impl fmt::Debug for AuthorizedToken {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AuthorizedToken")
.field("subject", &self.subject)
.field("principal", &self.principal)
.field("scopes", &self.scopes)
.field("issuer", &crate::jwks::debug_url(&self.issuer))
.field("audiences", &self.audiences)
.field("expires_at", &self.expires_at)
.field("issued_at", &self.issued_at)
.field("client_id", &self.client_id)
.field("jti", &self.jti)
.field("claims", &self.claims.keys().collect::<Vec<_>>())
.finish()
}
}
const MAX_TIMESTAMP_SECS: u64 = 253_402_300_799;
fn numeric_date(value: &Value) -> Option<SystemTime> {
let secs = numeric_date_secs(value)?;
Some(UNIX_EPOCH + Duration::from_secs(secs))
}
pub(crate) fn numeric_date_secs(value: &Value) -> Option<u64> {
let secs = match value.as_u64() {
Some(secs) => secs,
None => {
let f = value.as_f64()?;
if !(f.is_finite() && f >= 0.0 && f < u64::MAX as f64) {
return None;
}
f.round() as u64
}
};
Some(secs.min(MAX_TIMESTAMP_SECS))
}
pub(crate) fn client_id_of(claims: &Map<String, Value>) -> Option<&str> {
["client_id", "azp"].into_iter().find_map(|name| {
claims
.get(name)
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
})
}
fn audiences_of(claims: &Map<String, Value>) -> Vec<String> {
match claims.get("aud") {
Some(Value::String(s)) => vec![s.clone()],
Some(Value::Array(items)) => items
.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect(),
_ => Vec::new(),
}
}
impl AuthorizedToken {
pub(crate) fn from_verified_claims(
claims: Map<String, Value>,
subject: Option<String>,
principal: Option<String>,
scopes: Vec<String>,
) -> Self {
let string_claim = |name: &str| {
claims
.get(name)
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string)
};
Self {
subject,
principal,
scopes,
issuer: claims
.get("iss")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
audiences: audiences_of(&claims),
expires_at: claims
.get("exp")
.and_then(numeric_date)
.unwrap_or(UNIX_EPOCH),
issued_at: claims.get("iat").and_then(numeric_date),
client_id: client_id_of(&claims).map(str::to_string),
jti: string_claim("jti"),
claims: Arc::new(claims),
}
}
pub fn new(
subject: Option<String>,
principal: Option<String>,
scopes: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
let mut deduped: Vec<String> = Vec::new();
for scope in scopes {
let scope = scope.into();
let scope = scope.trim();
if !scope.is_empty() && !deduped.iter().any(|s| s == scope) {
deduped.push(scope.to_string());
}
}
Self {
subject,
principal,
scopes: deduped,
issuer: String::new(),
audiences: Vec::new(),
expires_at: UNIX_EPOCH + Duration::from_secs(FAR_FUTURE_SECS),
issued_at: None,
client_id: None,
jti: None,
claims: Arc::new(Map::new()),
}
}
pub fn claims(&self) -> &Map<String, Value> {
&self.claims
}
pub fn claims_as<T: DeserializeOwned>(&self) -> Result<T, serde_json::Error> {
serde_json::from_value(Value::Object((*self.claims).clone()))
}
#[must_use]
pub fn with_claims(mut self, claims: Map<String, Value>) -> Self {
self.claims = Arc::new(claims);
self
}
#[must_use]
pub fn with_issuer(mut self, issuer: impl Into<String>) -> Self {
self.issuer = issuer.into();
self
}
#[must_use]
pub fn with_audiences(
mut self,
audiences: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
self.audiences = audiences.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn with_expires_at(mut self, expires_at: SystemTime) -> Self {
self.expires_at = expires_at;
self
}
#[must_use]
pub fn with_issued_at(mut self, issued_at: SystemTime) -> Self {
self.issued_at = Some(issued_at);
self
}
#[must_use]
pub fn with_client_id(mut self, client_id: impl Into<String>) -> Self {
self.client_id = Some(client_id.into());
self
}
#[must_use]
pub fn with_jti(mut self, jti: impl Into<String>) -> Self {
self.jti = Some(jti.into());
self
}
pub fn has_scope(&self, scope: &str) -> bool {
self.scopes.iter().any(|s| s == scope)
}
pub fn require_scopes(&self, required: &[&str]) -> Result<(), MissingScopes> {
let missing = missing_scopes(&self.scopes, required.iter().copied());
if missing.is_empty() {
return Ok(());
}
Err(MissingScopes {
required: required.iter().map(|s| (*s).to_string()).collect(),
missing: missing.into_iter().map(str::to_string).collect(),
})
}
}
pub(crate) fn missing_scopes<'r>(
present: &[String],
required: impl IntoIterator<Item = &'r str>,
) -> Vec<&'r str> {
required
.into_iter()
.filter(|required| !present.iter().any(|p| p == required))
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("insufficient scope: missing {}", .missing.join(" "))]
#[non_exhaustive]
pub struct MissingScopes {
required: Vec<String>,
missing: Vec<String>,
}
impl MissingScopes {
pub fn required(&self) -> &[String] {
&self.required
}
pub fn missing(&self) -> &[String] {
&self.missing
}
}
impl From<MissingScopes> for TokenRejection {
fn from(_: MissingScopes) -> Self {
TokenRejection::InsufficientScope
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum TokenRejection {
#[error("missing credential")]
Missing,
#[error("invalid token")]
Invalid(InvalidToken),
#[error("insufficient scope")]
InsufficientScope,
}
impl TokenRejection {
pub(crate) fn invalid(kind: InvalidTokenKind, detail: impl Into<String>) -> Self {
Self::Invalid(InvalidToken::new(kind, detail))
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct InvalidToken {
kind: InvalidTokenKind,
detail: String,
}
impl InvalidToken {
pub fn new(kind: InvalidTokenKind, detail: impl Into<String>) -> Self {
Self {
kind,
detail: detail.into(),
}
}
pub fn kind(&self) -> InvalidTokenKind {
self.kind
}
pub fn detail(&self) -> &str {
&self.detail
}
pub fn as_str(&self) -> &str {
&self.detail
}
}
impl PartialEq for InvalidToken {
fn eq(&self, other: &Self) -> bool {
self.detail == other.detail
}
}
impl Eq for InvalidToken {}
impl std::error::Error for InvalidToken {}
#[cfg(feature = "serde")]
#[cfg_attr(docsrs, doc(cfg(feature = "serde")))]
impl serde::Serialize for InvalidToken {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.kind.as_str())
}
}
impl From<InvalidToken> for String {
fn from(invalid: InvalidToken) -> Self {
invalid.detail
}
}
impl fmt::Display for InvalidToken {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.detail)
}
}
impl From<String> for InvalidToken {
fn from(detail: String) -> Self {
Self::new(InvalidTokenKind::Other, detail)
}
}
impl From<&str> for InvalidToken {
fn from(detail: &str) -> Self {
Self::new(InvalidTokenKind::Other, detail)
}
}
impl std::ops::Deref for InvalidToken {
type Target = str;
fn deref(&self) -> &str {
&self.detail
}
}
impl PartialEq<str> for InvalidToken {
fn eq(&self, other: &str) -> bool {
self.detail == other
}
}
impl PartialEq<&str> for InvalidToken {
fn eq(&self, other: &&str) -> bool {
self.detail == *other
}
}
impl PartialEq<String> for InvalidToken {
fn eq(&self, other: &String) -> bool {
self.detail == *other
}
}
impl PartialEq<InvalidToken> for str {
fn eq(&self, other: &InvalidToken) -> bool {
self == other.detail
}
}
impl PartialEq<InvalidToken> for &str {
fn eq(&self, other: &InvalidToken) -> bool {
*self == other.detail
}
}
impl PartialEq<InvalidToken> for String {
fn eq(&self, other: &InvalidToken) -> bool {
*self == other.detail
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum InvalidTokenKind {
TooLarge,
NotJwt,
MalformedHeader,
CriticalHeader,
AlgorithmNotAllowed,
TypeNotAllowed,
KeyNotFound,
KeySetUnavailable,
MalformedToken,
BadSignature,
Expired,
NotYetValid,
WrongIssuer,
WrongAudience,
MissingClaim,
MalformedClaim,
SenderConstrained,
ClientNotAllowed,
TokenTooOld,
ClaimMismatch,
StaticTokenMismatch,
NoMechanism,
OAuthTokenRequired,
StaticTokenRequired,
Other,
}
impl InvalidTokenKind {
pub fn as_str(self) -> &'static str {
match self {
Self::TooLarge => "too_large",
Self::NotJwt => "not_jwt",
Self::MalformedHeader => "malformed_header",
Self::CriticalHeader => "critical_header",
Self::AlgorithmNotAllowed => "algorithm_not_allowed",
Self::TypeNotAllowed => "type_not_allowed",
Self::KeyNotFound => "key_not_found",
Self::KeySetUnavailable => "key_set_unavailable",
Self::MalformedToken => "malformed_token",
Self::BadSignature => "bad_signature",
Self::Expired => "expired",
Self::NotYetValid => "not_yet_valid",
Self::WrongIssuer => "wrong_issuer",
Self::WrongAudience => "wrong_audience",
Self::MissingClaim => "missing_claim",
Self::MalformedClaim => "malformed_claim",
Self::SenderConstrained => "sender_constrained",
Self::ClientNotAllowed => "client_not_allowed",
Self::TokenTooOld => "token_too_old",
Self::ClaimMismatch => "claim_mismatch",
Self::StaticTokenMismatch => "static_token_mismatch",
Self::NoMechanism => "no_mechanism",
Self::OAuthTokenRequired => "oauth_token_required",
Self::StaticTokenRequired => "static_token_required",
Self::Other => "other",
}
}
}
impl fmt::Display for InvalidTokenKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
pub(crate) fn extract_scopes(claims: &Map<String, Value>, claim_names: &[String]) -> Vec<String> {
let mut seen = HashSet::new();
let mut out = Vec::new();
let mut push = |s: &str| {
let s = s.trim();
if !s.is_empty() && seen.insert(s.to_string()) {
out.push(s.to_string());
}
};
for name in claim_names {
match claims.get(name) {
Some(Value::String(s)) => s.split_whitespace().for_each(&mut push),
Some(Value::Array(items)) => items.iter().filter_map(Value::as_str).for_each(&mut push),
_ => {}
}
}
out
}
pub(crate) fn extract_principal(
claims: &Map<String, Value>,
claim_names: &[String],
) -> Option<String> {
claim_names.iter().find_map(|name| match claims.get(name) {
Some(Value::String(s)) if !s.trim().is_empty() => Some(s.clone()),
_ => None,
})
}
pub(crate) fn check_typ(
typ: Option<&str>,
require_at_jwt: bool,
naming: &KeyNamingBuf,
) -> Result<(), TokenRejection> {
let Some(raw) = typ else {
return if require_at_jwt {
Err(TokenRejection::invalid(
InvalidTokenKind::TypeNotAllowed,
format!(
"token header has no typ and {} is on",
naming.key("require_at_jwt")
),
))
} else {
Ok(())
};
};
let lower = raw.trim().to_ascii_lowercase();
let media = lower.strip_prefix("application/").unwrap_or(&lower);
match media {
"at+jwt" => Ok(()),
"jwt" if !require_at_jwt => Ok(()),
_ => Err(TokenRejection::invalid(
InvalidTokenKind::TypeNotAllowed,
format!(
"token typ {:?} is not accepted as an access token{}",
for_log(raw),
if require_at_jwt {
format!(" ({} is on)", naming.key("require_at_jwt"))
} else {
String::new()
}
),
)),
}
}
#[cfg(test)]
#[track_caller]
pub(crate) fn assert_invalid<T: fmt::Debug>(
result: Result<T, TokenRejection>,
kind: InvalidTokenKind,
detail: &str,
context: &str,
) {
match result {
Err(TokenRejection::Invalid(invalid)) => {
assert_eq!(invalid.kind(), kind, "{context}");
assert_eq!(invalid.detail(), detail, "{context}");
}
other => panic!("expected Invalid({kind:?}), got {other:?} {context}"),
}
}
pub(crate) fn for_log(s: &str) -> String {
let mut out: String = s.chars().take(MAX_LOGGED_CHARS).collect();
if s.chars().count() > MAX_LOGGED_CHARS {
out.push('…');
}
out
}
pub(crate) fn scopes_for_log(scopes: &[String]) -> Vec<String> {
scopes.iter().map(|s| for_log(s)).collect()
}
pub(crate) fn for_log_field(s: &str) -> String {
let bounded = for_log(s);
let mut out = String::with_capacity(bounded.len());
for c in bounded.chars() {
if c.is_ascii_graphic() || c == ' ' || c == '…' {
out.push(c);
} else {
out.extend(c.escape_unicode());
}
}
out
}
pub(crate) fn describe_kid(kid: Option<&str>) -> String {
match kid {
Some(kid) => format!("{:?}", for_log(kid)),
None => "(none)".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_fills_the_metadata_fields_with_documented_test_defaults() {
let t = AuthorizedToken::new(Some("sub-1".into()), None, ["a"]);
assert_eq!(t.issuer, "");
assert!(t.audiences.is_empty());
assert_eq!(t.issued_at, None);
assert_eq!(t.client_id, None);
assert_eq!(t.jti, None);
assert!(t.claims().is_empty());
assert_eq!(
t.expires_at,
UNIX_EPOCH + Duration::from_secs(4_102_444_800)
);
assert!(t.expires_at > SystemTime::now());
}
#[test]
fn builders_set_their_field_and_with_claims_leaves_the_rest_alone() {
let claims = serde_json::json!({"groups": ["g1"], "n": 1});
let claims = claims.as_object().unwrap().clone();
let exp = UNIX_EPOCH + Duration::from_secs(1_000);
let t = AuthorizedToken::new(Some("sub-1".into()), Some("p".into()), ["a"])
.with_claims(claims.clone())
.with_issuer("https://issuer.example.test")
.with_audiences(["aud-1", "aud-2"])
.with_expires_at(exp)
.with_issued_at(UNIX_EPOCH)
.with_client_id("client-1")
.with_jti("jti-1");
assert_eq!(t.claims(), &claims);
assert_eq!(t.issuer, "https://issuer.example.test");
assert_eq!(t.audiences, ["aud-1", "aud-2"]);
assert_eq!(t.expires_at, exp);
assert_eq!(t.issued_at, Some(UNIX_EPOCH));
assert_eq!(t.client_id.as_deref(), Some("client-1"));
assert_eq!(t.jti.as_deref(), Some("jti-1"));
assert_eq!(t.subject.as_deref(), Some("sub-1"));
assert_eq!(t.scopes, ["a"]);
let only = AuthorizedToken::new(None, None, Vec::<String>::new()).with_claims(
serde_json::json!({"iss": "x", "jti": "y"})
.as_object()
.unwrap()
.clone(),
);
assert_eq!(only.issuer, "");
assert_eq!(only.jti, None);
}
#[test]
fn equality_and_clone_cover_the_claims() {
let a = AuthorizedToken::new(None, None, ["a"])
.with_claims(serde_json::json!({"k": 1}).as_object().unwrap().clone());
assert_eq!(a.clone(), a);
assert_ne!(a, AuthorizedToken::new(None, None, ["a"]));
}
#[test]
fn numeric_date_reads_like_jsonwebtoken_and_saturates_and_never_panics() {
let secs = |s: u64| Some(UNIX_EPOCH + Duration::from_secs(s));
let max = secs(MAX_TIMESTAMP_SECS);
assert_eq!(numeric_date(&serde_json::json!(10)), secs(10));
assert_eq!(numeric_date(&serde_json::json!(1.4)), secs(1));
assert_eq!(numeric_date(&serde_json::json!(1.5)), secs(2));
assert_eq!(numeric_date(&serde_json::json!(0.4)), secs(0));
assert_eq!(numeric_date(&serde_json::json!(u64::MAX)), max);
assert_eq!(numeric_date(&serde_json::json!(i64::MAX as u64 + 1)), max);
assert_eq!(numeric_date(&serde_json::json!(1e19)), max);
assert_eq!(numeric_date(&serde_json::json!(-1)), None);
assert_eq!(numeric_date(&serde_json::json!(-0.4)), None);
assert_eq!(numeric_date(&serde_json::json!(1e30)), None);
assert_eq!(numeric_date(&serde_json::json!("10")), None);
}
#[test]
fn require_scopes_is_all_of_and_names_what_is_missing() {
let t = AuthorizedToken::new(None, None, ["a", "b"]);
assert_eq!(t.require_scopes(&[]), Ok(()));
assert_eq!(t.require_scopes(&["a"]), Ok(()));
assert_eq!(t.require_scopes(&["b", "a"]), Ok(()));
let missing = t.require_scopes(&["a", "c", "b", "d"]).unwrap_err();
assert_eq!(missing.required(), ["a", "c", "b", "d"]);
assert_eq!(missing.missing(), ["c", "d"]);
assert_eq!(missing.to_string(), "insufficient scope: missing c d");
assert_eq!(
TokenRejection::from(missing),
TokenRejection::InsufficientScope
);
for never in ["A", "a ", "", "a b"] {
assert_eq!(
t.require_scopes(&[never]).unwrap_err().missing(),
[never],
"{never:?}"
);
}
}
#[test]
fn require_scopes_matches_exactly_what_the_validator_extracts() {
let claims = serde_json::json!({
"scope": "a b\tc",
"scp": ["d", "e f", 7],
});
let names = vec!["scope".to_string(), "scp".to_string()];
let scopes = extract_scopes(claims.as_object().unwrap(), &names);
let t = AuthorizedToken::new(None, None, scopes.clone());
for required in [
vec!["a", "b", "c", "d"],
vec!["e f"],
vec!["e"],
vec!["7"],
vec!["a", "x"],
] {
let by_token = t
.require_scopes(&required)
.map_err(|m| m.missing().to_vec());
let by_rule = missing_scopes(&scopes, required.iter().copied());
assert_eq!(
by_token.is_ok(),
by_rule.is_empty(),
"{required:?}: {by_token:?} vs {by_rule:?}"
);
}
assert!(t.require_scopes(&["a", "b", "c", "d", "e f"]).is_ok());
assert_eq!(
t.require_scopes(&["e", "7"]).unwrap_err().missing(),
["e", "7"]
);
}
#[test]
fn authorized_token_new_dedupes_scopes_like_a_validation() {
let t = AuthorizedToken::new(Some("sub-1".into()), None, ["b", " a", "b", "", "a", "c"]);
assert_eq!(t.subject.as_deref(), Some("sub-1"));
assert_eq!(t.principal, None);
assert_eq!(t.scopes, ["b", "a", "c"]);
assert!(t.has_scope("a"));
assert!(!t.has_scope(""));
let none = AuthorizedToken::new(None, None, Vec::<String>::new());
assert!(none.scopes.is_empty());
}
#[test]
fn a_rejection_displays_its_category_but_never_the_reason() {
let secret_reason = "token rejected: InvalidAudience";
let invalid = TokenRejection::Invalid(secret_reason.into());
assert_eq!(invalid.to_string(), "invalid token");
assert!(!invalid.to_string().contains("InvalidAudience"));
assert!(format!("{invalid:?}").contains(secret_reason));
assert_eq!(TokenRejection::Missing.to_string(), "missing credential");
assert_eq!(
TokenRejection::InsufficientScope.to_string(),
"insufficient scope"
);
let boxed: Box<dyn std::error::Error + Send + Sync> = Box::new(invalid);
assert_eq!(boxed.to_string(), "invalid token");
}
#[test]
fn logged_values_are_truncated() {
let long = "x".repeat(MAX_LOGGED_CHARS * 3);
assert_eq!(for_log(&long).chars().count(), MAX_LOGGED_CHARS + 1);
assert_eq!(for_log("short"), "short");
}
#[test]
fn span_field_values_are_truncated_and_escaped() {
assert_eq!(for_log_field("kid-1 A"), "kid-1 A");
assert_eq!(
for_log_field("\u{1b}[31mx\u{7}\r\n\u{202e}é"),
"\\u{1b}[31mx\\u{7}\\u{d}\\u{a}\\u{202e}\\u{e9}"
);
let hostile = "\u{1b}".repeat(5 * 1024);
let shown = for_log_field(&hostile);
assert_eq!(shown, format!("{}…", "\\u{1b}".repeat(MAX_LOGGED_CHARS)));
assert!(for_log_field(&"\u{10ffff}".repeat(1000)).len() <= MAX_LOGGED_CHARS * 10 + 3);
}
#[test]
fn typ_rejection_reasons_name_the_setting_per_key_naming() {
let typ = |detail: &str| {
Err(TokenRejection::Invalid(InvalidToken::new(
InvalidTokenKind::TypeNotAllowed,
detail,
)))
};
let dotted = KeyNamingBuf::Dotted("mcp.oauth".into());
assert_eq!(
check_typ(None, true, &dotted),
typ("token header has no typ and mcp.oauth.require_at_jwt is on")
);
assert_eq!(
check_typ(Some("JWT"), true, &dotted),
typ("token typ \"JWT\" is not accepted as an access token \
(mcp.oauth.require_at_jwt is on)")
);
assert_eq!(
check_typ(Some("dpop+jwt"), false, &dotted),
typ("token typ \"dpop+jwt\" is not accepted as an access token")
);
let env = KeyNamingBuf::Env("APP_OAUTH_".into());
assert_eq!(
check_typ(None, true, &env),
typ("token header has no typ and APP_OAUTH_REQUIRE_AT_JWT is on")
);
for (t, require) in [(None, true), (Some("JWT"), true), (Some("dpop+jwt"), false)] {
let Err(TokenRejection::Invalid(invalid)) = check_typ(t, require, &dotted) else {
panic!("{t:?} passed");
};
assert_eq!(invalid.kind(), InvalidTokenKind::TypeNotAllowed);
}
}
#[test]
#[allow(clippy::op_ref, clippy::cmp_owned)]
fn invalid_token_keeps_the_string_uses_compiling() {
let from_str = TokenRejection::Invalid("bad token".into());
let from_string = TokenRejection::Invalid(String::from("bad token").into());
assert_eq!(from_str, from_string);
let TokenRejection::Invalid(reason) = &from_str else {
panic!("not Invalid")
};
assert_eq!(reason.kind(), InvalidTokenKind::Other);
assert_eq!(reason.detail(), "bad token");
assert_eq!(reason.to_string(), "bad token");
assert_eq!(format!("refused: {reason}"), "refused: bad token");
assert!(reason.contains("bad"));
assert!(reason.starts_with("bad "));
assert_eq!(reason.len(), 9);
let as_str: &str = reason;
assert_eq!(as_str, "bad token");
assert!(reason == "bad token");
assert!(*reason == "bad token");
assert!(reason.clone() == "bad token");
assert!(&**reason == "bad token");
assert!(reason != "other");
assert!(matches!(from_str, TokenRejection::Invalid(_)));
assert!(matches!(&from_str, TokenRejection::Invalid(r) if r.contains("bad")));
assert_eq!(
InvalidToken::new(InvalidTokenKind::Expired, "bad token"),
InvalidToken::from("bad token")
);
assert_ne!(
InvalidToken::new(InvalidTokenKind::Expired, "bad token"),
InvalidToken::new(InvalidTokenKind::Expired, "other")
);
assert_eq!(reason.as_str(), "bad token");
let owned = String::from("bad token");
assert!(*reason == owned);
assert!(owned == *reason);
assert!("bad token" == *reason);
assert!("bad token" == reason.clone());
let s: String = reason.clone().into();
assert_eq!(s, "bad token");
let boxed: Box<dyn std::error::Error> = reason.clone().into();
assert_eq!(boxed.to_string(), "bad token");
assert!(format!("{from_str:?}").contains("bad token"));
let n = 3;
let formatted = TokenRejection::Invalid(format!("{n} candidates").into());
assert!(matches!(formatted, TokenRejection::Invalid(r) if r == "3 candidates"));
}
#[test]
fn every_invalid_token_kind_label_is_distinct_snake_case() {
use InvalidTokenKind as K;
let all = [
K::TooLarge,
K::NotJwt,
K::MalformedHeader,
K::CriticalHeader,
K::AlgorithmNotAllowed,
K::TypeNotAllowed,
K::KeyNotFound,
K::KeySetUnavailable,
K::MalformedToken,
K::BadSignature,
K::Expired,
K::NotYetValid,
K::WrongIssuer,
K::WrongAudience,
K::MissingClaim,
K::MalformedClaim,
K::SenderConstrained,
K::ClientNotAllowed,
K::TokenTooOld,
K::ClaimMismatch,
K::StaticTokenMismatch,
K::NoMechanism,
K::OAuthTokenRequired,
K::StaticTokenRequired,
K::Other,
];
let labels: HashSet<&str> = all.iter().map(|k| k.as_str()).collect();
assert_eq!(labels.len(), all.len());
for (kind, label) in all.iter().zip(all.iter().map(|k| k.as_str())) {
assert!(
label.bytes().all(|b| b.is_ascii_lowercase() || b == b'_'),
"{label}"
);
assert_eq!(kind.to_string(), label);
}
}
#[cfg(feature = "serde")]
#[test]
fn invalid_token_serializes_as_its_kind_label_never_the_detail() {
let reason = InvalidToken::new(
InvalidTokenKind::ClientNotAllowed,
"token client \"secret-client\" is not in oauth.allowed_client_ids",
);
let body = serde_json::json!({ "reason": reason });
assert_eq!(body, serde_json::json!({ "reason": "client_not_allowed" }));
assert!(!body.to_string().contains("secret-client"), "{body}");
let expired = InvalidToken::new(InvalidTokenKind::Expired, "token rejected: expired");
assert_eq!(
serde_json::to_string(&expired).unwrap(),
"\"expired\"",
"a derived response type gets the label too"
);
}
}