use std::time::{Duration, UNIX_EPOCH};
use super::*;
fn at(secs: u64) -> SystemTime {
UNIX_EPOCH + Duration::from_secs(secs)
}
fn scopes(s: &str) -> ScopeSet {
ScopeSet::parse(s).unwrap()
}
fn record(scope: &str, resource: &[&str]) -> ConsentRecord {
ConsentRecord {
consent_id: "consent-1".into(),
client_id: ClientId::new("app"),
subject: "user-1".into(),
scope: scopes(scope),
resource: resource.iter().map(|r| r.to_string()).collect(),
granted_at: at(1_000),
authentication: None,
}
}
#[test]
fn a_narrower_request_is_covered() {
let r = record("read write", &[]);
assert!(r.covers(&scopes("read"), &[], RequestedDetails::none()));
assert!(r.covers(&scopes("read write"), &[], RequestedDetails::none()));
assert!(r.covers(&ScopeSet::empty(), &[], RequestedDetails::none()));
}
#[test]
fn one_extra_scope_token_is_not_covered() {
let r = record("read", &[]);
assert!(!r.covers(&scopes("read write"), &[], RequestedDetails::none()));
assert!(!r.covers(&scopes("admin"), &[], RequestedDetails::none()));
}
#[test]
fn a_resource_the_consent_never_named_is_not_covered() {
let none = record("read", &[]);
assert!(!none.covers(
&scopes("read"),
&["https://rs.example/".to_string()],
RequestedDetails::none()
));
let one = record("read", &["https://rs.example/"]);
assert!(one.covers(
&scopes("read"),
&["https://rs.example/".to_string()],
RequestedDetails::none()
));
assert!(!one.covers(
&scopes("read"),
&["https://other.example/".to_string()],
RequestedDetails::none()
));
let two = record("read", &["https://rs.example/", "https://other.example/"]);
assert!(two.covers(
&scopes("read"),
&["https://other.example/".to_string()],
RequestedDetails::none()
));
}
#[cfg(feature = "rar")]
#[test]
fn a_request_carrying_authorization_details_is_never_covered() {
let details = crate::rar::AuthorizationDetails::parse(
r#"[{"type":"payment","identifier":"IBAN-1","amount":"50"}]"#,
)
.expect("the fixture parses");
let r = record("read", &[]);
assert!(r.covers(&scopes("read"), &[], RequestedDetails::none()));
assert!(
!r.covers(&scopes("read"), &[], RequestedDetails::of(&details)),
"a remembered consent recorded no authorization detail, so it covers none"
);
}
#[cfg(feature = "rar")]
#[test]
fn an_empty_details_array_is_the_same_as_asking_for_none() {
let empty = crate::rar::AuthorizationDetails::none();
let r = record("read", &[]);
assert!(r.covers(&scopes("read"), &[], RequestedDetails::of(&empty)));
}
#[test]
fn extend_accumulates_and_keeps_the_identity_of_the_consent() {
let mut r = record("read", &["https://rs.example/"]);
r.extend(&scopes("write"), &["https://other.example/".to_string()]);
assert_eq!(r.scope, scopes("read write"));
assert_eq!(
r.resource,
vec![
"https://rs.example/".to_string(),
"https://other.example/".to_string()
]
);
assert_eq!(&*r.consent_id, "consent-1");
assert_eq!(r.granted_at, at(1_000));
}
#[test]
fn extend_by_what_is_already_covered_is_a_no_op() {
let mut r = record("read write", &["https://rs.example/"]);
let before = r.clone();
r.extend(&scopes("read"), &["https://rs.example/".to_string()]);
assert_eq!(r, before);
}
#[test]
fn a_request_with_neither_parameter_requires_nothing() {
let req = AuthenticationRequirement::from_pairs([("client_id", "app"), ("scope", "read")])
.expect("no step-up parameters is not an error");
assert!(req.is_empty());
assert_eq!(req, AuthenticationRequirement::none());
}
#[test]
fn acr_values_is_a_space_delimited_ordered_list() {
let req =
AuthenticationRequirement::from_pairs([("acr_values", " urn:mace:silver phr ")]).unwrap();
assert_eq!(
req.acr_values,
vec![Box::<str>::from("urn:mace:silver"), Box::<str>::from("phr")]
);
assert!(!req.is_empty());
}
#[test]
fn acr_values_is_bounded_and_refuses_rather_than_truncating() {
let at_cap = vec!["phr"; MAX_ACR_VALUES].join(" ");
let req = AuthenticationRequirement::from_pairs([("acr_values", at_cap.as_str())])
.expect("exactly the cap is a legal request");
assert_eq!(req.acr_values.len(), MAX_ACR_VALUES);
let over = vec!["phr"; MAX_ACR_VALUES + 1].join(" ");
let err = AuthenticationRequirement::from_pairs([("acr_values", over.as_str())])
.expect_err("one past the cap must be refused, not truncated");
assert_eq!(err.error, ErrorCode::InvalidRequest);
let spaced = format!("{}{}", " ".repeat(1000), at_cap.replace(' ', " "));
let req = AuthenticationRequirement::from_pairs([("acr_values", spaced.as_str())])
.expect("separators are not classes");
assert_eq!(req.acr_values.len(), MAX_ACR_VALUES);
}
#[test]
fn max_age_zero_is_a_requirement_and_not_an_absence() {
let req = AuthenticationRequirement::from_pairs([("max_age", "0")]).unwrap();
assert_eq!(req.max_age, Some(Duration::ZERO));
assert!(!req.is_empty());
}
#[test]
fn a_malformed_max_age_is_invalid_request() {
for bad in ["", "-1", "soon", "60s", "1.5", "9999999999999999999999"] {
let err = AuthenticationRequirement::from_pairs([("max_age", bad)])
.expect_err("a max_age that is not a number of seconds must be refused");
assert_eq!(err.error, ErrorCode::InvalidRequest, "max_age={bad:?}");
}
}
#[test]
fn a_repeated_parameter_keeps_the_first_occurrence() {
let req = AuthenticationRequirement::from_pairs([
("acr_values", "strong"),
("acr_values", "weak"),
("max_age", "60"),
("max_age", "86400"),
])
.unwrap();
assert_eq!(req.acr_values, vec![Box::<str>::from("strong")]);
assert_eq!(req.max_age, Some(Duration::from_secs(60)));
}
#[test]
fn an_empty_requirement_is_satisfied_by_anything() {
let req = AuthenticationRequirement::none();
assert_eq!(req.satisfied_by(None, at(5_000)), Ok(()));
assert_eq!(
req.satisfied_by(Some(&Authentication::at(at(1))), at(5_000)),
Ok(())
);
}
#[test]
fn an_unreported_authentication_satisfies_nothing() {
let fresh = AuthenticationRequirement {
acr_values: Vec::new(),
max_age: Some(Duration::from_secs(60)),
};
assert_eq!(
fresh.satisfied_by(None, at(5_000)),
Err(StepUpFailure::NotReported)
);
let strong = AuthenticationRequirement {
acr_values: vec!["phr".into()],
max_age: None,
};
assert_eq!(
strong.satisfied_by(None, at(5_000)),
Err(StepUpFailure::NotReported)
);
}
#[test]
fn max_age_is_enforced_against_auth_time_at_the_boundary() {
let req = AuthenticationRequirement {
acr_values: Vec::new(),
max_age: Some(Duration::from_secs(300)),
};
let auth = Authentication::at(at(1_000));
assert_eq!(req.satisfied_by(Some(&auth), at(1_300)), Ok(()));
assert_eq!(
req.satisfied_by(Some(&auth), at(1_301)),
Err(StepUpFailure::Stale)
);
let now_only = AuthenticationRequirement {
acr_values: Vec::new(),
max_age: Some(Duration::ZERO),
};
assert_eq!(now_only.satisfied_by(Some(&auth), at(1_000)), Ok(()));
assert_eq!(
now_only.satisfied_by(Some(&auth), at(1_001)),
Err(StepUpFailure::Stale)
);
}
#[test]
fn an_auth_time_in_the_future_reads_as_no_elapsed_time() {
let req = AuthenticationRequirement {
acr_values: Vec::new(),
max_age: Some(Duration::ZERO),
};
let auth = Authentication::at(at(2_000));
assert_eq!(req.satisfied_by(Some(&auth), at(1_000)), Ok(()));
}
#[test]
fn any_requested_acr_satisfies_the_request() {
let req = AuthenticationRequirement {
acr_values: vec!["phr".into(), "mfa".into()],
max_age: None,
};
let mfa = Authentication::at(at(1_000)).with_acr("mfa");
assert_eq!(req.satisfied_by(Some(&mfa), at(1_000)), Ok(()));
let pwd = Authentication::at(at(1_000)).with_acr("pwd");
assert_eq!(
req.satisfied_by(Some(&pwd), at(1_000)),
Err(StepUpFailure::AcrNotMet)
);
let bare = Authentication::at(at(1_000));
assert_eq!(
req.satisfied_by(Some(&bare), at(1_000)),
Err(StepUpFailure::AcrNotMet)
);
}
#[test]
fn acr_comparison_is_exact() {
let req = AuthenticationRequirement {
acr_values: vec!["PHR".into()],
max_age: None,
};
let lower = Authentication::at(at(1_000)).with_acr("phr");
assert_eq!(
req.satisfied_by(Some(&lower), at(1_000)),
Err(StepUpFailure::AcrNotMet)
);
}
#[test]
fn staleness_is_reported_before_the_class_mismatch() {
let req = AuthenticationRequirement {
acr_values: vec!["phr".into()],
max_age: Some(Duration::from_secs(60)),
};
let old_and_wrong = Authentication::at(at(1_000)).with_acr("pwd");
assert_eq!(
req.satisfied_by(Some(&old_and_wrong), at(9_000)),
Err(StepUpFailure::Stale)
);
}
#[test]
fn every_failure_is_insufficient_user_authentication() {
for failure in [
StepUpFailure::NotReported,
StepUpFailure::Stale,
StepUpFailure::AcrNotMet,
] {
let err = failure.error_response();
assert_eq!(err.error, ErrorCode::InsufficientUserAuthentication);
assert_eq!(
err.error_description.as_deref(),
Some(failure.description()),
"{failure}"
);
let text = failure.description();
assert!(!text.contains("1970"), "{text}");
}
assert_eq!(
ErrorCode::InsufficientUserAuthentication.as_str(),
"insufficient_user_authentication"
);
}
#[test]
fn the_challenge_carries_the_error_and_both_parameters() {
let challenge = step_up_challenge(
"Bearer",
&["phr".into(), "mfa".into()],
Some(Duration::from_secs(300)),
);
assert_eq!(
challenge,
"Bearer error=\"insufficient_user_authentication\", \
error_description=\"the user authentication does not meet the requirements of this \
resource\", acr_values=\"phr mfa\", max_age=\"300\""
);
}
#[test]
fn the_challenge_omits_what_was_not_asked_for() {
let challenge = step_up_challenge("DPoP", &[], None);
assert_eq!(
challenge,
"DPoP error=\"insufficient_user_authentication\", \
error_description=\"the user authentication does not meet the requirements of this \
resource\""
);
assert!(!challenge.contains("acr_values"));
assert!(!challenge.contains("max_age"));
}
#[test]
fn the_challenge_escapes_quotes_in_an_acr_value() {
let challenge = step_up_challenge("Bearer", &["a\"b\\c".into()], None);
assert!(
challenge.contains("acr_values=\"a\\\"b\\\\c\""),
"{challenge}"
);
assert_eq!(challenge.matches("error=").count(), 1);
}
#[test]
fn the_challenge_cannot_carry_a_header_break() {
let challenge = step_up_challenge("Bearer", &["a\r\nX-Evil: 1".into()], None);
assert!(!challenge.contains('\r'), "{challenge:?}");
assert!(!challenge.contains('\n'), "{challenge:?}");
assert!(!challenge.contains('\u{7f}'), "{challenge:?}");
assert!(
challenge.contains("acr_values=\"aX-Evil: 1\""),
"{challenge}"
);
}
#[test]
fn the_challenge_scheme_is_a_token() {
let challenge = step_up_challenge("Bea rer\r\nX-Evil: 1\"", &["phr".into()], None);
assert!(challenge.starts_with("BearerX-Evil1 error="), "{challenge}");
assert!(!challenge.contains('\r'), "{challenge:?}");
assert!(!challenge.contains('\n'), "{challenge:?}");
assert_eq!(challenge.matches('"').count(), 6, "{challenge}");
}
#[test]
fn the_challenge_keeps_everything_that_is_spellable() {
let value = "urn:acr:\tsecure level-3 \u{e9}\u{7ff}";
let challenge = step_up_challenge("Bearer", &[value.into()], None);
assert!(
challenge.contains(&format!("acr_values=\"{value}\"")),
"{challenge}"
);
}