use std::borrow::Cow;
use crate::{TokenType, core::platform::MaybeSendSync};
pub(crate) fn escape_quoted(s: &str) -> Cow<'_, str> {
let needs_work = s
.chars()
.any(|c| c == '"' || c == '\\' || (c.is_control() && c != '\t'));
if !needs_work {
return Cow::Borrowed(s);
}
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
c if c.is_control() && c != '\t' => {}
c => out.push(c),
}
}
Cow::Owned(out)
}
fn is_tchar(c: char) -> bool {
c.is_ascii_alphanumeric() || "!#$%&'*+-.^_`|~".contains(c)
}
#[derive(Debug, Clone)]
pub enum ChallengeParam {
Quoted(&'static str, String),
Token(&'static str, String),
}
impl ChallengeParam {
#[must_use]
pub fn format(&self) -> String {
match self {
Self::Quoted(key, value) => format!(r#"{}="{}""#, key, escape_quoted(value)),
Self::Token(key, value) => {
let value: String = value.chars().filter(|c| is_tchar(*c)).collect();
format!("{key}={value}")
}
}
}
}
#[derive(Debug, Clone)]
pub enum TokenValidationError {
Client(TokenErrorCode),
Server(http::StatusCode),
}
impl TokenValidationError {
#[must_use]
pub fn suggested_status(&self) -> http::StatusCode {
match self {
Self::Client(code) => code.suggested_status(),
Self::Server(status) => *status,
}
}
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
strum::IntoStaticStr,
strum::AsRefStr,
strum::Display,
strum::EnumString,
)]
#[strum(serialize_all = "snake_case")]
#[non_exhaustive]
pub enum TokenErrorCode {
InvalidRequest,
InvalidToken,
InsufficientScope,
#[strum(serialize = "invalid_dpop_proof")]
InvalidDPoPProof,
#[strum(serialize = "use_dpop_nonce")]
UseDPoPNonce,
InsufficientUserAuthentication,
}
impl TokenErrorCode {
#[must_use]
pub fn as_str(&self) -> &'static str {
self.into()
}
#[must_use]
pub fn suggested_status(&self) -> http::StatusCode {
match self {
Self::InvalidRequest => http::StatusCode::BAD_REQUEST,
Self::InvalidToken
| Self::InvalidDPoPProof
| Self::UseDPoPNonce
| Self::InsufficientUserAuthentication => http::StatusCode::UNAUTHORIZED,
Self::InsufficientScope => http::StatusCode::FORBIDDEN,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct InsufficientScope {
pub scope: Option<String>,
}
impl InsufficientScope {
#[must_use]
pub fn new(scope: impl Into<String>) -> Self {
Self {
scope: Some(scope.into()),
}
}
}
impl ToRfc6750Error for InsufficientScope {
fn attempted_scheme(&self) -> Option<TokenType> {
None
}
fn token_error(&self) -> TokenValidationError {
TokenValidationError::Client(TokenErrorCode::InsufficientScope)
}
fn error_description(&self) -> Option<String> {
Some("The access token has insufficient scope for the requested resource".to_string())
}
fn required_scope(&self) -> Option<String> {
self.scope.clone()
}
}
#[derive(Debug, Clone, Default)]
pub struct InsufficientUserAuthentication {
pub acr_values: Option<String>,
pub max_age: Option<u64>,
}
impl ToRfc6750Error for InsufficientUserAuthentication {
fn attempted_scheme(&self) -> Option<TokenType> {
None
}
fn token_error(&self) -> TokenValidationError {
TokenValidationError::Client(TokenErrorCode::InsufficientUserAuthentication)
}
fn error_description(&self) -> Option<String> {
Some("A higher authentication level is required to access this resource".to_string())
}
fn extra_params(&self) -> Vec<ChallengeParam> {
let mut params = Vec::new();
if let Some(acr) = &self.acr_values {
params.push(ChallengeParam::Quoted("acr_values", acr.clone()));
}
if let Some(max_age) = self.max_age {
params.push(ChallengeParam::Token("max_age", max_age.to_string()));
}
params
}
}
pub trait ToRfc6750Error: std::fmt::Debug + MaybeSendSync {
fn attempted_scheme(&self) -> Option<TokenType>;
fn token_error(&self) -> TokenValidationError;
fn error_description(&self) -> Option<String>;
fn extra_params(&self) -> Vec<ChallengeParam> {
Vec::new()
}
fn required_scope(&self) -> Option<String> {
None
}
}
impl ToRfc6750Error for crate::core::jwt::validator::JwtValidationError {
fn attempted_scheme(&self) -> Option<TokenType> {
None
}
fn token_error(&self) -> TokenValidationError {
match self {
crate::core::jwt::validator::JwtValidationError::JtiCheck { .. } => {
TokenValidationError::Server(http::StatusCode::INTERNAL_SERVER_ERROR)
}
_ => TokenValidationError::Client(TokenErrorCode::InvalidToken),
}
}
fn error_description(&self) -> Option<String> {
use crate::core::jwt::validator::JwtValidationError as E;
match self {
E::Parse { .. } => Some("The access token is malformed".to_string()),
E::Signature { .. } => Some("The access token signature is invalid".to_string()),
E::UnsignedToken => Some("The access token is unsigned".to_string()),
E::DisallowedAlgorithm { .. } => {
Some("The access token uses an unsupported signature algorithm".to_string())
}
E::UnrecognizedCriticalHeader { .. } => Some(
"The access token contains unrecognized critical header parameters".to_string(),
),
E::Expired { .. } => Some("The access token expired".to_string()),
E::NotYetValid { .. } => Some("The access token is not yet valid".to_string()),
E::IssuedInFuture { .. } => {
Some("The access token was issued in the future".to_string())
}
E::TokenTooOld { .. } => Some("The access token is too old".to_string()),
E::InvalidTokenType { .. } => Some("The access token type is invalid".to_string()),
E::ClaimMismatch { claim, .. } => {
Some(format!("The access token '{claim}' claim is invalid"))
}
E::RequiredClaimMissing { claim } => Some(format!(
"The access token is missing the required '{claim}' claim"
)),
E::JtiNotUnique => {
Some("The access token 'jti' claim value was previously seen".to_string())
}
E::JtiCheck { .. } => None,
E::ExtraClaims { .. } => {
Some("The access token does not contain the required claims".to_string())
}
E::JtiTooLong { .. } => Some("The access token 'jti' claim is too long".to_string()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn token_error_code_str_roundtrip() {
for (code, s) in [
(TokenErrorCode::InvalidRequest, "invalid_request"),
(TokenErrorCode::InvalidToken, "invalid_token"),
(TokenErrorCode::InsufficientScope, "insufficient_scope"),
(TokenErrorCode::InvalidDPoPProof, "invalid_dpop_proof"),
(TokenErrorCode::UseDPoPNonce, "use_dpop_nonce"),
(
TokenErrorCode::InsufficientUserAuthentication,
"insufficient_user_authentication",
),
] {
assert_eq!(code.as_str(), s);
assert_eq!(code.to_string(), s, "Display matches the RFC code");
assert_eq!(s.parse::<TokenErrorCode>().unwrap(), code);
}
assert!("not_a_code".parse::<TokenErrorCode>().is_err());
}
#[test]
fn escape_quoted_escapes_quote_and_backslash() {
assert_eq!(escape_quoted(r#"a"b\c"#), r#"a\"b\\c"#);
}
#[test]
fn escape_quoted_clean_input_is_borrowed() {
assert!(matches!(escape_quoted("clean value"), Cow::Borrowed(_)));
}
#[test]
fn escape_quoted_strips_control_chars_but_keeps_tab_and_unicode() {
let out = escape_quoted("a\r\nb\u{0007}c\td\u{00e9}");
assert_eq!(out, "abc\td\u{00e9}");
assert!(!out.contains(['\r', '\n']));
}
#[test]
fn token_param_strips_non_token_chars() {
assert_eq!(
ChallengeParam::Token("max_age", "60".to_string()).format(),
"max_age=60"
);
let injected = ChallengeParam::Token("max_age", "1\r\n2".to_string()).format();
assert_eq!(injected, "max_age=12");
assert!(!injected.contains(['\r', '\n']));
}
}