use crate::challenge::is_header_value;
use crate::token::TokenRejection;
use crate::validator::OAuthValidator;
pub const DEFAULT_STATIC_CHALLENGE: &str = "Bearer error=\"invalid_token\"";
pub(crate) const BARE_INSUFFICIENT_SCOPE_CHALLENGE: &str = "Bearer error=\"insufficient_scope\"";
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Refusal {
pub status: u16,
pub www_authenticate: Option<String>,
}
pub fn refusal(rejection: &TokenRejection, oauth: Option<&OAuthValidator>) -> Refusal {
refusal_with_static_challenge(rejection, oauth, Some(DEFAULT_STATIC_CHALLENGE))
}
pub fn refusal_with_static_challenge(
rejection: &TokenRejection,
oauth: Option<&OAuthValidator>,
static_challenge: Option<&str>,
) -> Refusal {
let oauth_challenges = oauth.map(|v| {
(
v.invalid_token_challenge(),
v.insufficient_scope_challenge(),
)
});
let (status, challenge) = select(
rejection,
oauth_challenges
.as_ref()
.map(|(i, s)| (i.as_str(), s.as_str())),
static_challenge.map(|c| {
if is_header_value(c) {
c
} else {
DEFAULT_STATIC_CHALLENGE
}
}),
);
Refusal {
status,
www_authenticate: challenge.map(str::to_owned),
}
}
pub fn refusal_for_scopes(
rejection: &TokenRejection,
oauth: Option<&OAuthValidator>,
scopes: &[&str],
description: Option<&str>,
) -> Refusal {
let oauth_challenges = oauth.map(|v| {
(
v.invalid_token_challenge(),
v.insufficient_scope_challenge_for(&v.scopes_with_floor(scopes), description),
)
});
let (status, challenge) = select(
rejection,
oauth_challenges
.as_ref()
.map(|(i, s)| (i.as_str(), s.as_str())),
Some(DEFAULT_STATIC_CHALLENGE),
);
let challenge = match (rejection, oauth) {
(TokenRejection::InsufficientScope, None) => Some(BARE_INSUFFICIENT_SCOPE_CHALLENGE),
_ => challenge,
};
Refusal {
status,
www_authenticate: challenge.map(str::to_owned),
}
}
pub(crate) fn select<'c, C: ?Sized>(
rejection: &TokenRejection,
oauth_challenges: Option<(&'c C, &'c C)>,
static_challenge: Option<&'c C>,
) -> (u16, Option<&'c C>) {
let status = match rejection {
TokenRejection::InsufficientScope => 403,
_ => 401,
};
let challenge = match (oauth_challenges, rejection) {
(Some((_, insufficient)), TokenRejection::InsufficientScope) => Some(insufficient),
(Some((invalid, _)), _) => Some(invalid),
(None, _) => static_challenge,
};
(status, challenge)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn status_follows_rfc_6750() {
let cases = [
(TokenRejection::Missing, 401),
(TokenRejection::Invalid("x".into()), 401),
(TokenRejection::InsufficientScope, 403),
];
for (rejection, status) in cases {
assert_eq!(refusal(&rejection, None).status, status, "{rejection:?}");
}
}
#[test]
fn a_static_challenge_that_is_not_a_header_value_falls_back_to_the_default() {
for bad in [
"Bearer realm=\"x\"\r\nSet-Cookie: a=b",
"Bearer\nx",
"Bearer \u{7f}",
"Bearer réalm",
] {
assert_eq!(
refusal_with_static_challenge(&TokenRejection::Missing, None, Some(bad))
.www_authenticate
.as_deref(),
Some(DEFAULT_STATIC_CHALLENGE),
"{bad:?}"
);
}
assert_eq!(
refusal_with_static_challenge(&TokenRejection::Missing, None, Some("A\tb c"))
.www_authenticate
.as_deref(),
Some("A\tb c")
);
}
#[test]
fn a_validator_whose_challenge_is_not_a_header_value_builds_with_fallbacks() {
let rejections = [
TokenRejection::Missing,
TokenRejection::Invalid("x".into()),
TokenRejection::InsufficientScope,
];
let mut cfg = crate::testing::resolved_config("http://127.0.0.1:1/jwks");
cfg.resource = "https://api.example.test/v1\r\nX-Injected: 1".into();
let v = OAuthValidator::new(&cfg).expect("a hand-edited config still builds");
assert!(v.challenge_fell_back());
let got: Vec<_> = rejections
.iter()
.map(|r| refusal(r, Some(&v)).www_authenticate.unwrap())
.collect();
assert_eq!(
got,
[
"Bearer error=\"invalid_token\", scope=\"mcp:read mcp:write\"",
"Bearer error=\"invalid_token\", scope=\"mcp:read mcp:write\"",
"Bearer error=\"insufficient_scope\", scope=\"mcp:read\"",
]
);
let mut cfg = crate::testing::resolved_config("http://127.0.0.1:1/jwks");
cfg.scopes_supported = vec!["read\u{0}".into()];
let v = OAuthValidator::new(&cfg).unwrap();
assert_eq!(
refusal(&TokenRejection::Missing, Some(&v))
.www_authenticate
.unwrap(),
"Bearer error=\"invalid_token\""
);
let v = OAuthValidator::new(&crate::testing::resolved_config("http://127.0.0.1:1/jwks"))
.unwrap();
assert!(!v.challenge_fell_back());
for rejection in &rejections {
let challenge = refusal(rejection, Some(&v)).www_authenticate.unwrap();
assert!(is_header_value(&challenge), "{challenge}");
assert!(challenge.contains("resource_metadata="), "{challenge}");
}
}
#[test]
fn refusal_for_scopes_differs_from_refusal_only_in_the_403_challenge() {
let v = OAuthValidator::new(&crate::testing::resolved_config("http://127.0.0.1:1/jwks"))
.unwrap();
for rejection in [TokenRejection::Missing, TokenRejection::Invalid("x".into())] {
assert_eq!(
refusal_for_scopes(&rejection, Some(&v), &["mcp:write"], Some("d")),
refusal(&rejection, Some(&v))
);
assert_eq!(
refusal_for_scopes(&rejection, None, &["mcp:write"], None),
refusal(&rejection, None)
);
}
let r = refusal_for_scopes(
&TokenRejection::InsufficientScope,
Some(&v),
&["mcp:write", "mcp:read"],
Some("write needed"),
);
assert_eq!(r.status, 403);
assert_eq!(
r.www_authenticate.as_deref(),
Some(
"Bearer error=\"insufficient_scope\", scope=\"mcp:read mcp:write\", \
resource_metadata=\"https://kb.example.test/.well-known/oauth-protected-resource/mcp\", \
error_description=\"write needed\""
)
);
assert_eq!(
refusal_for_scopes(&TokenRejection::InsufficientScope, Some(&v), &[], None),
refusal(&TokenRejection::InsufficientScope, Some(&v))
);
let r = refusal_for_scopes(&TokenRejection::InsufficientScope, None, &["x"], None);
assert_eq!(r.status, 403);
assert_eq!(
r.www_authenticate.as_deref(),
Some("Bearer error=\"insufficient_scope\"")
);
}
#[test]
fn static_challenge_is_the_default_unless_overridden() {
for rejection in [
TokenRejection::Missing,
TokenRejection::Invalid("x".into()),
TokenRejection::InsufficientScope,
] {
assert_eq!(
refusal(&rejection, None).www_authenticate.as_deref(),
Some(DEFAULT_STATIC_CHALLENGE)
);
assert_eq!(
refusal_with_static_challenge(&rejection, None, Some("Custom x"))
.www_authenticate
.as_deref(),
Some("Custom x")
);
assert_eq!(
refusal_with_static_challenge(&rejection, None, None).www_authenticate,
None
);
}
}
}