use crate::constants::{auth_schemes, oauth_errors};
use crate::{AuthplaneError, VerifierError};
const HTTP_STATUS_BAD_REQUEST: u16 = 400;
const HTTP_STATUS_UNAUTHORIZED: u16 = 401;
const HTTP_STATUS_FORBIDDEN: u16 = 403;
const HTTP_STATUS_SERVICE_UNAVAILABLE: u16 = 503;
pub fn www_authenticate(error: &VerifierError, realm: &str) -> String {
www_authenticate_with_resource_metadata(error, realm, "")
}
pub fn www_authenticate_with_resource_metadata(
error: &VerifierError,
realm: &str,
resource_metadata_url: &str,
) -> String {
let scheme = if is_dpop_error(error) {
auth_schemes::DPOP
} else {
auth_schemes::BEARER
};
let error_params = challenge_error_for(error).map(|code| (code, error.to_string()));
build_challenge(
scheme,
realm,
error_params
.as_ref()
.map(|(code, description)| (*code, description.as_str())),
resource_metadata_url,
)
}
pub fn www_authenticate_for_missing_credentials(
realm: &str,
resource_metadata_url: &str,
) -> String {
build_challenge(auth_schemes::BEARER, realm, None, resource_metadata_url)
}
fn build_challenge(
scheme: &str,
realm: &str,
error: Option<(&str, &str)>,
resource_metadata_url: &str,
) -> String {
let mut parts: Vec<String> = Vec::new();
if !realm.is_empty() {
parts.push(format!("realm=\"{}\"", escape_quoted(realm)));
}
if let Some((code, description)) = error {
parts.push(format!("error=\"{code}\""));
parts.push(format!(
"error_description=\"{}\"",
escape_quoted(description)
));
}
if !resource_metadata_url.is_empty() {
parts.push(format!(
"resource_metadata=\"{}\"",
escape_quoted(resource_metadata_url)
));
}
if parts.is_empty() {
scheme.to_string()
} else {
format!("{scheme} {}", parts.join(", "))
}
}
pub fn is_dpop_error(error: &VerifierError) -> bool {
error.www_authenticate_scheme_is_dpop()
}
pub fn http_status(error: &VerifierError) -> u16 {
match error {
VerifierError::InsufficientScope { .. } => HTTP_STATUS_FORBIDDEN,
VerifierError::MetadataUnavailable { .. } | VerifierError::JwksUnavailable { .. } => {
HTTP_STATUS_SERVICE_UNAVAILABLE
}
_ => HTTP_STATUS_UNAUTHORIZED,
}
}
pub fn http_status_for_auth_error(error: &AuthplaneError) -> u16 {
match error {
AuthplaneError::ConsentRequired(_) => HTTP_STATUS_UNAUTHORIZED,
AuthplaneError::CircuitOpen => HTTP_STATUS_SERVICE_UNAVAILABLE,
AuthplaneError::Auth(inner) => inner.status_code.unwrap_or(HTTP_STATUS_BAD_REQUEST),
}
}
fn challenge_error_for(error: &VerifierError) -> Option<&'static str> {
match error {
VerifierError::InsufficientScope { .. } => Some(oauth_errors::INSUFFICIENT_SCOPE),
VerifierError::TokenMissing
| VerifierError::TokenExpired
| VerifierError::InvalidSignature { .. }
| VerifierError::InvalidClaims { .. }
| VerifierError::TokenRevoked => Some(oauth_errors::INVALID_TOKEN),
VerifierError::MetadataUnavailable { .. } | VerifierError::JwksUnavailable { .. } => None,
VerifierError::DpopProofMissing
| VerifierError::DpopReplayDetected
| VerifierError::DpopBindingMismatch { .. }
| VerifierError::DpopNotSupported => Some(oauth_errors::INVALID_TOKEN),
VerifierError::DpopMultipleProofs => Some(oauth_errors::INVALID_DPOP_PROOF),
}
}
fn escape_quoted(input: &str) -> String {
input.replace('\\', "\\\\").replace('"', "\\\"")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{AuthError, AuthplaneError, ConsentRequiredError};
#[test]
fn www_authenticate_token_missing_is_invalid_token() {
let header = www_authenticate(&VerifierError::TokenMissing, "api");
assert!(header.starts_with("Bearer "));
assert!(header.contains("realm=\"api\""));
assert!(header.contains("error=\"invalid_token\""));
assert!(header.contains("error_description=\""));
}
#[test]
fn www_authenticate_insufficient_scope_uses_that_code() {
let header = www_authenticate(
&VerifierError::InsufficientScope {
required: "tools/admin".to_string(),
available: vec!["tools/read".to_string()],
},
"",
);
assert!(header.contains("error=\"insufficient_scope\""));
assert!(!header.contains("realm="));
}
#[test]
fn www_authenticate_jwks_unavailable_omits_error() {
let header = www_authenticate(
&VerifierError::JwksUnavailable {
message: "boom".to_string(),
},
"api",
);
assert!(header.contains("realm=\"api\""));
assert!(!header.contains("error="));
}
#[test]
fn www_authenticate_escapes_quotes_in_description() {
let header = www_authenticate(
&VerifierError::InvalidClaims {
message: "token \"bad\"".to_string(),
},
"",
);
assert!(header.contains("\\\""));
}
#[test]
fn http_status_maps_insufficient_scope_to_403() {
assert_eq!(
http_status(&VerifierError::InsufficientScope {
required: "x".to_string(),
available: vec![],
}),
403
);
}
#[test]
fn http_status_maps_metadata_unavailable_to_503() {
assert_eq!(
http_status(&VerifierError::MetadataUnavailable {
message: "n/a".to_string(),
}),
503
);
assert_eq!(
http_status(&VerifierError::JwksUnavailable {
message: "n/a".to_string(),
}),
503
);
}
#[test]
fn http_status_maps_auth_failures_to_401() {
assert_eq!(http_status(&VerifierError::TokenMissing), 401);
assert_eq!(http_status(&VerifierError::TokenExpired), 401);
assert_eq!(http_status(&VerifierError::TokenRevoked), 401);
assert_eq!(
http_status(&VerifierError::InvalidSignature {
message: "x".to_string(),
}),
401
);
assert_eq!(
http_status(&VerifierError::InvalidClaims {
message: "x".to_string(),
}),
401
);
}
#[test]
fn http_status_for_auth_error_prefers_consent_required_to_401() {
let err = AuthplaneError::from(ConsentRequiredError {
message: "need consent".to_string(),
code: "consent_required".to_string(),
status_code: Some(403),
service_id: "drive".to_string(),
cause_detail: "approval".to_string(),
consent_url: None,
});
assert_eq!(http_status_for_auth_error(&err), 401);
}
#[test]
fn http_status_for_auth_error_preserves_upstream_status() {
let err = AuthplaneError::Auth(AuthError {
message: "bad".to_string(),
code: "invalid_grant".to_string(),
status_code: Some(400),
});
assert_eq!(http_status_for_auth_error(&err), 400);
}
#[test]
fn www_authenticate_dpop_bound_failures_use_dpop_scheme() {
for err in [
VerifierError::DpopProofMissing,
VerifierError::DpopReplayDetected,
VerifierError::DpopBindingMismatch {
message: "mismatch".to_string(),
},
] {
let header = www_authenticate(&err, "api");
assert!(
header.starts_with("DPoP "),
"expected DPoP scheme for {err:?}, got {header:?}"
);
}
}
#[test]
fn www_authenticate_dpop_not_supported_uses_bearer_scheme_with_invalid_token() {
let header = www_authenticate(&VerifierError::DpopNotSupported, "api");
assert!(
header.starts_with("Bearer "),
"DpopNotSupported must retry as Bearer, got {header:?}"
);
assert!(header.contains("error=\"invalid_token\""));
}
#[test]
fn www_authenticate_dpop_proof_missing_uses_dpop_scheme_and_invalid_token() {
let header = www_authenticate(&VerifierError::DpopProofMissing, "api");
assert!(
header.starts_with("DPoP "),
"expected DPoP scheme, got {header:?}"
);
assert!(header.contains("error=\"invalid_token\""));
}
#[test]
fn www_authenticate_dpop_binding_mismatch_uses_dpop_scheme_and_invalid_token() {
let header = www_authenticate(
&VerifierError::DpopBindingMismatch {
message: "cnf.jkt mismatch".to_string(),
},
"",
);
assert!(header.starts_with("DPoP"));
assert!(header.contains("error=\"invalid_token\""));
}
#[test]
fn www_authenticate_dpop_replay_uses_dpop_scheme_and_invalid_token() {
let header = www_authenticate(&VerifierError::DpopReplayDetected, "");
assert!(header.starts_with("DPoP"));
assert!(header.contains("error=\"invalid_token\""));
}
#[test]
fn www_authenticate_dpop_multiple_proofs_uses_dpop_scheme_and_invalid_dpop_proof() {
let header = www_authenticate(&VerifierError::DpopMultipleProofs, "");
assert!(header.starts_with("DPoP"));
assert!(header.contains("error=\"invalid_dpop_proof\""));
assert_eq!(http_status(&VerifierError::DpopMultipleProofs), 401);
}
const PRM_URL: &str = "https://api.example.com/.well-known/oauth-protected-resource/mcp";
#[test]
fn www_authenticate_with_resource_metadata_appends_a_quoted_string() {
let header =
www_authenticate_with_resource_metadata(&VerifierError::TokenExpired, "api", PRM_URL);
assert_eq!(
header,
format!(
"Bearer realm=\"api\", error=\"invalid_token\", \
error_description=\"token has expired\", resource_metadata=\"{PRM_URL}\""
)
);
}
#[test]
fn www_authenticate_with_resource_metadata_keeps_the_dpop_scheme() {
let header =
www_authenticate_with_resource_metadata(&VerifierError::DpopProofMissing, "", PRM_URL);
assert!(header.starts_with("DPoP error=\"invalid_token\""));
assert!(header.ends_with(&format!("resource_metadata=\"{PRM_URL}\"")));
}
#[test]
fn www_authenticate_with_resource_metadata_survives_a_service_side_failure() {
let header = www_authenticate_with_resource_metadata(
&VerifierError::JwksUnavailable {
message: "boom".to_string(),
},
"",
PRM_URL,
);
assert_eq!(header, format!("Bearer resource_metadata=\"{PRM_URL}\""));
}
#[test]
fn www_authenticate_without_resource_metadata_emits_no_such_parameter() {
let header = www_authenticate(&VerifierError::TokenExpired, "api");
assert!(!header.contains("resource_metadata"));
let header = www_authenticate_with_resource_metadata(&VerifierError::TokenExpired, "", "");
assert_eq!(
header,
"Bearer error=\"invalid_token\", error_description=\"token has expired\""
);
}
#[test]
fn www_authenticate_escapes_quotes_in_resource_metadata() {
let header = www_authenticate_with_resource_metadata(
&VerifierError::TokenMissing,
"",
"https://api.example.com/.well-known/oauth-protected-resource/a\"b",
);
assert!(header.ends_with(
"resource_metadata=\"https://api.example.com/.well-known/oauth-protected-resource/a\\\"b\""
));
}
#[test]
fn www_authenticate_for_missing_credentials_has_realm_and_resource_metadata_only() {
assert_eq!(
www_authenticate_for_missing_credentials("api", PRM_URL),
format!("Bearer realm=\"api\", resource_metadata=\"{PRM_URL}\"")
);
assert_eq!(
www_authenticate_for_missing_credentials("", PRM_URL),
format!("Bearer resource_metadata=\"{PRM_URL}\"")
);
assert_eq!(
www_authenticate_for_missing_credentials("api", ""),
"Bearer realm=\"api\""
);
assert_eq!(www_authenticate_for_missing_credentials("", ""), "Bearer");
}
#[test]
fn http_status_for_auth_error_defaults_to_400_when_status_missing() {
let err = AuthplaneError::Auth(AuthError {
message: "bad".to_string(),
code: "invalid_grant".to_string(),
status_code: None,
});
assert_eq!(http_status_for_auth_error(&err), 400);
}
}