use crate::{
error::ToRfc6750Error,
validator::{ValidationResult, metadata::ValidatorMetadata},
};
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Rejection {
pub status: http::StatusCode,
pub www_authenticate: Vec<String>,
pub dpop_nonce: Option<String>,
}
impl Rejection {
#[must_use]
pub fn apply(&self, builder: http::response::Builder) -> http::response::Builder {
let mut builder = builder.status(self.status);
for challenge in &self.www_authenticate {
builder = builder.header(http::header::WWW_AUTHENTICATE, challenge.as_str());
}
if let Some(nonce) = &self.dpop_nonce {
builder = builder.header("DPoP-Nonce", nonce.as_str());
}
builder
}
}
impl ValidatorMetadata {
#[must_use]
pub fn rejection(&self, error: &dyn ToRfc6750Error, scope: Option<&str>) -> Rejection {
Rejection {
status: error.token_error().suggested_status(),
www_authenticate: self.challenges(Some(error), scope, None),
dpop_nonce: None,
}
}
}
impl<C, E: ToRfc6750Error> ValidationResult<C, E> {
#[must_use]
pub fn rejection(
&self,
metadata: &ValidatorMetadata,
scope: Option<&str>,
) -> Option<Rejection> {
let mut rejection = match &self.outcome {
Ok(Some(_)) => return None,
Ok(None) => Rejection {
status: http::StatusCode::UNAUTHORIZED,
www_authenticate: metadata.unauthenticated_challenges(scope),
dpop_nonce: None,
},
Err(error) => metadata.rejection(error, scope),
};
rejection.dpop_nonce.clone_from(&self.dpop_nonce);
Some(rejection)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
TokenType,
error::{InsufficientScope, TokenErrorCode, TokenValidationError},
validator::ValidatedRequest,
};
#[derive(Debug)]
struct TestError(TokenValidationError);
impl TestError {
fn client(code: TokenErrorCode) -> Self {
Self(TokenValidationError::Client(code))
}
fn server() -> Self {
Self(TokenValidationError::Server(http::StatusCode::BAD_GATEWAY))
}
}
impl ToRfc6750Error for TestError {
fn attempted_scheme(&self) -> Option<TokenType> {
None
}
fn token_error(&self) -> TokenValidationError {
self.0.clone()
}
fn error_description(&self) -> Option<String> {
None
}
}
fn meta() -> ValidatorMetadata {
ValidatorMetadata::builder().realm("api").build()
}
fn validated() -> ValidatedRequest<()> {
ValidatedRequest {
issuer: None,
subject: None,
audience: Vec::new(),
jti: None,
issued_at: None,
expiration: None,
cnf: None,
claims: (),
introspection_jwt: None,
}
}
#[test]
fn metadata_rejection_client_error() {
let rejection = meta().rejection(&TestError::client(TokenErrorCode::InvalidToken), None);
assert_eq!(rejection.status, http::StatusCode::UNAUTHORIZED);
assert_eq!(
rejection.www_authenticate,
vec![r#"Bearer realm="api", error="invalid_token""#]
);
assert_eq!(rejection.dpop_nonce, None);
}
#[test]
fn metadata_rejection_server_error_has_no_challenges() {
let rejection = meta().rejection(&TestError::server(), None);
assert_eq!(rejection.status, http::StatusCode::BAD_GATEWAY);
assert!(rejection.www_authenticate.is_empty());
}
#[test]
fn metadata_rejection_insufficient_scope_is_403_with_scope() {
let rejection = meta().rejection(&InsufficientScope::new("read write"), None);
assert_eq!(rejection.status, http::StatusCode::FORBIDDEN);
assert_eq!(rejection.www_authenticate.len(), 1);
assert!(rejection.www_authenticate[0].contains(r#"scope="read write""#));
assert!(
rejection.www_authenticate[0].contains(r#"error="insufficient_scope""#),
"challenge: {}",
rejection.www_authenticate[0]
);
}
#[test]
fn result_rejection_none_when_authenticated() {
let result: ValidationResult<(), TestError> = ValidationResult {
outcome: Ok(Some(validated())),
dpop_nonce: Some("fresh".to_string()),
};
assert!(result.rejection(&meta(), None).is_none());
}
#[test]
fn result_rejection_unauthenticated_is_401_without_error_details() {
let result: ValidationResult<(), TestError> = ValidationResult {
outcome: Ok(None),
dpop_nonce: None,
};
let rejection = result.rejection(&meta(), Some("read")).unwrap();
assert_eq!(rejection.status, http::StatusCode::UNAUTHORIZED);
assert_eq!(
rejection.www_authenticate,
vec![r#"Bearer realm="api", scope="read""#]
);
}
#[test]
fn result_rejection_carries_dpop_nonce() {
let result: ValidationResult<(), TestError> = ValidationResult {
outcome: Err(TestError::client(TokenErrorCode::UseDPoPNonce)),
dpop_nonce: Some("fresh".to_string()),
};
let rejection = result.rejection(&meta(), None).unwrap();
assert_eq!(rejection.status, http::StatusCode::UNAUTHORIZED);
assert_eq!(rejection.dpop_nonce.as_deref(), Some("fresh"));
}
#[test]
fn apply_sets_status_all_challenges_and_nonce() {
let mut metadata = meta();
metadata.dpop_supported = Some(true);
let result: ValidationResult<(), TestError> = ValidationResult {
outcome: Err(TestError::client(TokenErrorCode::InvalidToken)),
dpop_nonce: Some("fresh".to_string()),
};
let rejection = result.rejection(&metadata, None).unwrap();
let response = rejection.apply(http::Response::builder()).body(()).unwrap();
assert_eq!(response.status(), http::StatusCode::UNAUTHORIZED);
let challenges: Vec<_> = response
.headers()
.get_all(http::header::WWW_AUTHENTICATE)
.iter()
.collect();
assert_eq!(challenges.len(), 2, "one Bearer and one DPoP challenge");
assert_eq!(
response.headers().get("DPoP-Nonce").unwrap(),
&http::HeaderValue::from_static("fresh")
);
}
}