use super::*;
use crate::server::ServerConfig;
fn headers_with(auth: &str) -> HeaderMap {
let mut h = HeaderMap::new();
h.insert(header::AUTHORIZATION, HeaderValue::from_str(auth).unwrap());
h
}
fn pairs_of(input: &str) -> Vec<Pair<'_>> {
parse_pairs(input).unwrap_or_else(|_| panic!("this fixture is within the parameter cap"))
}
fn refusal(result: Result<Credentials, ErrorResponse>) -> ErrorResponse {
match result {
Err(e) => e,
Ok(creds) => panic!(
"expected a refusal, got credentials for client_id {:?}",
creds.client_id
),
}
}
#[test]
fn decoding_borrows_when_there_is_nothing_to_decode() {
assert!(matches!(
decode_component("abc123-_~"),
Cow::Borrowed("abc123-_~")
));
assert!(matches!(decode_component("a+b"), Cow::Owned(_)));
assert!(matches!(decode_component("a%20b"), Cow::Owned(_)));
}
#[test]
fn decodes_plus_and_percent_escapes() {
assert_eq!(decode_component("a+b"), "a b");
assert_eq!(decode_component("a%20b"), "a b");
assert_eq!(decode_component("%2D"), "-");
assert_eq!(decode_component("urn%3Aietf%3Aparams"), "urn:ietf:params");
}
#[test]
fn a_stray_percent_is_passed_through_not_fatal() {
assert_eq!(decode_component("100%"), "100%");
assert_eq!(decode_component("%zz"), "%zz");
}
#[test]
fn parse_pairs_keeps_present_but_empty_parameters() {
let pairs = pairs_of("a=1&b=&c");
assert_eq!(param(&pairs, "a"), Some("1"));
assert_eq!(param(&pairs, "b"), Some(""));
assert_eq!(param(&pairs, "c"), Some(""));
assert_eq!(param(&pairs, "d"), None);
}
#[test]
fn the_parameter_cap_is_exact_at_its_boundary() {
let at_the_cap = vec!["a=1"; MAX_FORM_PARAMETERS].join("&");
assert_eq!(
parse_pairs(&at_the_cap).map(|p| p.len()).ok(),
Some(MAX_FORM_PARAMETERS),
"a request with exactly MAX_FORM_PARAMETERS parameters is within the cap"
);
let one_over = vec!["a=1"; MAX_FORM_PARAMETERS + 1].join("&");
assert!(
parse_pairs(&one_over).is_err(),
"one parameter past the cap is refused"
);
}
#[test]
fn a_repeated_parameter_keeps_the_first() {
let pairs = pairs_of("grant_type=authorization_code&grant_type=client_credentials");
assert_eq!(param(&pairs, "grant_type"), Some("authorization_code"));
}
#[test]
fn basic_credentials_are_form_urldecoded_before_use() {
let raw = BASE64_STANDARD.encode("s6%42he:7Fjfp0%24ZM");
let headers = headers_with(&format!("Basic {raw}"));
assert!(basic_attempted(&headers));
let (id, secret) = decode_basic(&headers).expect("well-formed");
assert_eq!(id, "s6Bhe");
assert_eq!(secret, "7Fjfp0$ZM");
}
#[test]
fn a_password_containing_a_colon_survives() {
let raw = BASE64_STANDARD.encode("client:a:b:c");
let (id, secret) = decode_basic(&headers_with(&format!("Basic {raw}"))).expect("well-formed");
assert_eq!(id, "client");
assert_eq!(secret, "a:b:c");
}
#[test]
fn malformed_basic_is_invalid_client() {
for value in ["Basic !!!not-base64!!!", "Basic Y2xpZW50"] {
let err = decode_basic(&headers_with(value)).expect_err("must be refused");
assert_eq!(err.error, ErrorCode::InvalidClient);
}
}
#[test]
fn a_non_basic_authorization_header_is_not_an_attempt() {
assert!(!basic_attempted(&headers_with("Bearer abc")));
assert!(basic_attempted(&headers_with("basic abc")));
}
#[test]
fn two_authentication_methods_are_refused() {
let raw = BASE64_STANDARD.encode("client:secret");
let form = pairs_of("client_id=client&client_secret=secret");
let err = refusal(credentials(&headers_with(&format!("Basic {raw}")), &form));
assert_eq!(err.error, ErrorCode::InvalidRequest);
let form = pairs_of("client_id=client");
let err = refusal(credentials(&headers_with(&format!("Basic {raw}")), &form));
assert_eq!(err.error, ErrorCode::InvalidRequest);
}
#[cfg(feature = "par")]
#[test]
fn a_pushed_request_may_carry_client_id_alongside_basic() {
let raw = BASE64_STANDARD.encode("client:secret");
let headers = headers_with(&format!("Basic {raw}"));
let form = pairs_of("client_id=client&response_type=code");
assert_eq!(
refusal(credentials(&headers, &form)).error,
ErrorCode::InvalidRequest
);
let creds = pushed_request_credentials(&headers, &form).expect("RFC 9126 s2.1 allows this");
assert_eq!(creds.client_id, "client");
assert_eq!(creds.client_secret.as_deref(), Some("secret"));
let both = pairs_of("client_id=client&client_secret=secret");
assert_eq!(
refusal(pushed_request_credentials(&headers, &both)).error,
ErrorCode::InvalidRequest
);
}
#[test]
fn a_public_client_may_present_a_bare_client_id() {
let creds = credentials(&HeaderMap::new(), &pairs_of("client_id=public")).expect("ok");
assert_eq!(creds.client_id, "public");
assert_eq!(creds.client_secret, None);
}
#[test]
fn no_credentials_at_all_is_invalid_client() {
let err = refusal(credentials(&HeaderMap::new(), &pairs_of("grant_type=x")));
assert_eq!(err.error, ErrorCode::InvalidClient);
}
#[test]
fn routes_are_derived_from_the_advertised_urls() {
assert_eq!(
endpoint_path(
"https://as.example",
"token_endpoint",
"https://as.example/token"
)
.unwrap(),
"/token"
);
assert_eq!(
endpoint_path(
"https://as.example/tenant1",
"token_endpoint",
"https://as.example/tenant1/token"
)
.unwrap(),
"/tenant1/token"
);
let err = endpoint_path(
"https://as.example",
"token_endpoint",
"https://other.example/token",
)
.unwrap_err();
assert!(matches!(err, ServiceError::EndpointOutsideIssuer { .. }));
assert!(endpoint_path(
"https://as.example",
"token_endpoint",
"https://as.example.evil/token"
)
.is_err());
}
#[test]
fn the_issuer_origin_drops_the_path() {
assert_eq!(issuer_origin("https://as.example"), "https://as.example");
assert_eq!(
issuer_origin("https://as.example:8443/tenant1"),
"https://as.example:8443"
);
assert_eq!(
issuer_origin("https://as.example/a/b"),
"https://as.example"
);
}
#[test]
fn the_issuer_origin_does_not_slice_inside_a_character() {
assert_eq!(
issuer_origin("https://as.example/\u{e9}//"),
"https://as.example"
);
assert_eq!(
issuer_origin("https://as.example:8443/\u{1f600}/x///"),
"https://as.example:8443"
);
assert_eq!(issuer_origin("https://as.example//"), "https://as.example");
}
#[test]
fn the_verification_page_escapes_what_it_echoes() {
let html = verification_page("\"><script>alert(1)</script>", None, None, None);
assert!(!html.contains("<script>"), "{html}");
assert!(html.contains("<script>"), "{html}");
assert!(html.contains("""), "{html}");
}
#[test]
fn the_consent_stage_names_the_client_and_the_scope_and_escapes_both() {
let grant = DeviceGrant {
device_code: "dc".to_string(),
user_code: "WDJB-MJHT".to_string(),
client_id: ClientId::new("evil<client>"),
scope: ScopeSet::from_tokens(["read", "write"]).unwrap(),
state: DeviceGrantState::Pending,
created_at: std::time::SystemTime::UNIX_EPOCH,
expires_at: std::time::SystemTime::UNIX_EPOCH,
interval: std::time::Duration::from_secs(5),
last_poll_at: None,
};
let named = Some((grant, Some("<script>Totally Legit</script>".to_string())));
let html = verification_page("WDJB-MJHT", None, named.as_ref(), Some("tok&en"));
assert!(html.contains("Totally Legit"), "{html}");
assert!(!html.contains("<script>"), "{html}");
assert!(html.contains("<client>"), "{html}");
assert!(html.contains("read write"), "{html}");
assert!(html.contains("WDJB-MJHT"), "{html}");
assert!(html.contains("name=\"csrf_token\""), "{html}");
assert!(html.contains("tok&en"), "{html}");
assert!(html.contains("name=\"action\" value=\"approve\""), "{html}");
assert!(html.contains("name=\"action\" value=\"deny\""), "{html}");
}
#[test]
fn the_code_entry_stage_offers_no_approve_button() {
let html = verification_page("", None, None, Some("t"));
assert!(!html.contains("value=\"approve\""), "{html}");
assert!(html.contains("Continue"), "{html}");
}
#[test]
fn csrf_tokens_are_compared_in_constant_time_and_correctly() {
assert!(constant_time_eq("abc", "abc"));
assert!(!constant_time_eq("abc", "abd"));
assert!(!constant_time_eq("abc", ""));
assert!(!constant_time_eq("", "abc"));
assert!(constant_time_eq("", ""));
assert!(!constant_time_eq("abc", "abc "));
assert!(!constant_time_eq("abc", &"abc".repeat(1000)));
}
#[test]
fn only_a_same_origin_submission_passes_the_origin_check() {
let origin = "https://as.example";
let with = |name: &'static str, value: &str| {
let mut h = HeaderMap::new();
h.insert(name, HeaderValue::from_str(value).unwrap());
h
};
assert!(same_origin(&with("origin", origin), origin));
assert!(same_origin(&with("sec-fetch-site", "same-origin"), origin));
assert!(!same_origin(&with("sec-fetch-site", "cross-site"), origin));
assert!(!same_origin(&with("sec-fetch-site", "same-site"), origin));
assert!(!same_origin(&with("sec-fetch-site", "none"), origin));
assert!(!same_origin(
&with("origin", "https://attacker.example"),
origin
));
assert!(!same_origin(
&with("origin", "https://as.example.evil"),
origin
));
assert!(!same_origin(&HeaderMap::new(), origin));
let mut both = with("sec-fetch-site", "cross-site");
both.insert("origin", HeaderValue::from_static("https://as.example"));
assert!(!same_origin(&both, origin));
}
#[test]
fn only_a_form_urlencoded_body_is_accepted() {
let ct = |value: &str| {
let mut h = HeaderMap::new();
h.insert(header::CONTENT_TYPE, HeaderValue::from_str(value).unwrap());
h
};
assert!(is_form_urlencoded(&ct("application/x-www-form-urlencoded")));
assert!(is_form_urlencoded(&ct(
"application/x-www-form-urlencoded; charset=UTF-8"
)));
assert!(is_form_urlencoded(&ct("APPLICATION/X-WWW-FORM-URLENCODED")));
assert!(!is_form_urlencoded(&ct("text/plain")));
assert!(!is_form_urlencoded(&ct("application/json")));
assert!(!is_form_urlencoded(&ct("multipart/form-data")));
assert!(!is_form_urlencoded(&HeaderMap::new()));
}
#[test]
fn a_router_refuses_to_publish_a_path_it_would_shadow() {
let mut config = ServerConfig::new("https://as.example", "https://as.example/device");
config.token_endpoint = Some("https://as.example/same".to_string());
config.introspection_endpoint = Some("https://as.example/same".to_string());
let server = Arc::new(AuthorizationServer::new(
config,
crate::store::MemoryStorage::new(),
));
let err = ServiceBuilder::new(server).build().unwrap_err();
assert!(matches!(err, ServiceError::DuplicatePath { .. }), "{err}");
}
#[cfg(feature = "jwt-p256")]
#[test]
fn a_jwks_uri_off_the_issuer_refuses_to_build() {
let mut config = ServerConfig::new("https://as.example", "https://as.example/device");
config.access_token_format = crate::jwt::AccessTokenFormat::Jwt(Box::new(
crate::jwt::JwtConfig::new(
crate::jwt::EcdsaP256Key::generate("k1"),
"https://rs.example",
)
.with_jwks_uri("https://keys.example/jwks"),
));
let server = Arc::new(AuthorizationServer::new(
config,
crate::store::MemoryStorage::new(),
));
let err = ServiceBuilder::new(server).build().unwrap_err();
assert!(
matches!(err, ServiceError::EndpointOutsideIssuer { endpoint, .. } if endpoint == "jwks_uri"),
"{err}"
);
}
#[cfg(feature = "jwt-p256")]
#[test]
fn a_jwks_uri_colliding_with_another_endpoint_refuses_to_build() {
let mut config = ServerConfig::new("https://as.example", "https://as.example/device");
config.access_token_format = crate::jwt::AccessTokenFormat::Jwt(Box::new(
crate::jwt::JwtConfig::new(
crate::jwt::EcdsaP256Key::generate("k1"),
"https://rs.example",
)
.with_jwks_uri("https://as.example/token"),
));
let server = Arc::new(AuthorizationServer::new(
config,
crate::store::MemoryStorage::new(),
));
let err = ServiceBuilder::new(server).build().unwrap_err();
assert!(matches!(err, ServiceError::DuplicatePath { .. }), "{err}");
}
#[test]
fn a_verification_uri_off_the_issuer_is_not_an_error() {
let config = ServerConfig::new("https://as.example", "https://accounts.example/device");
let server = Arc::new(AuthorizationServer::new(
config,
crate::store::MemoryStorage::new(),
));
assert!(ServiceBuilder::new(server).build().is_ok());
}
#[test]
fn hex_value_is_exhaustively_correct_over_every_byte() {
const DIGITS: &[u8] = b"0123456789abcdef";
for b in 0u8..=255 {
let expected = DIGITS
.iter()
.position(|d| *d == b.to_ascii_lowercase())
.map(|i| i as u8);
assert_eq!(hex_value(b), expected, "byte {b:#04x}");
}
}
#[test]
fn lowercase_percent_escapes_decode_to_the_byte_they_name() {
assert_eq!(decode_component("%2f"), "/");
assert_eq!(decode_component("%6a"), "j");
assert_eq!(decode_component("%7e"), "~");
assert_eq!(decode_component("%2F"), "/");
assert_eq!(decode_component("%6A"), "j");
assert_eq!(
decode_component("urn%3aietf%3aparams%3aoauth%3agrant-type%3adevice_code"),
"urn:ietf:params:oauth:grant-type:device_code"
);
}
#[test]
fn a_truncated_escape_at_the_end_is_passed_through_rather_than_read_past() {
assert_eq!(decode_component("%2"), "%2");
assert_eq!(decode_component("%"), "%");
assert_eq!(decode_component("ab%f"), "ab%f");
assert_eq!(decode_component("a%2b%c"), "a+%c");
}
#[test]
fn every_resource_indicator_survives_including_repeats() {
let pairs =
pairs_of("resource=https%3A%2F%2Fa.example&client_id=c&resource=https%3A%2F%2Fb.example");
assert_eq!(
resource_indicators(&pairs),
vec![
"https://a.example".to_string(),
"https://b.example".to_string()
]
);
assert!(resource_indicators(&pairs_of("client_id=c")).is_empty());
}
#[test]
fn a_missing_required_parameter_borrows_its_description() {
let source = include_str!("../http.rs");
let mut names: Vec<&str> = Vec::new();
for (at, _) in source.match_indices("required(") {
let from = at + "required(".len();
let rest = &source[from..(from + 128).min(source.len())];
let Some(open) = rest.find('"') else { continue };
let Some(close) = rest[open + 1..].find('"') else {
continue;
};
let name = &rest[open + 1..open + 1 + close];
if rest[..open].contains(';') || !name.chars().all(|c| c.is_ascii_lowercase() || c == '_') {
continue;
}
if !names.contains(&name) {
names.push(name);
}
}
assert!(
names.len() >= 6,
"the source scan found only {names:?}; it is meant to find every required() call site"
);
for name in names {
let err = required(&[], name).expect_err("no parameters means every one of them is absent");
match err.error_description {
Some(Cow::Borrowed(_)) => {}
other => panic!(
"required({name:?}) built its description on the heap ({other:?}): add the \
parameter to the borrowing match in http.rs"
),
}
}
}
#[test]
fn a_supplied_scope_is_parsed_and_a_malformed_one_is_invalid_scope() {
assert!(optional_scope(&pairs_of("grant_type=x"))
.expect("absent is not an error")
.is_none());
let parsed = optional_scope(&pairs_of("scope=read+write"))
.expect("a well-formed scope")
.expect("present");
assert_eq!(parsed.to_string(), "read write");
let err = optional_scope(&pairs_of("scope=%22read%22")).expect_err("not a scope list");
assert_eq!(err.error, ErrorCode::InvalidScope);
}
#[test]
fn router_errors_name_the_endpoint_or_the_path_at_fault() {
let text = ServiceError::EndpointOutsideIssuer {
endpoint: "token_endpoint",
url: "https://other.example/token".to_string(),
}
.to_string();
assert!(text.contains("token_endpoint"), "{text}");
assert!(text.contains("https://other.example/token"), "{text}");
let text = ServiceError::DuplicatePath {
path: "/same".to_string(),
}
.to_string();
assert!(text.contains("/same"), "{text}");
let text = ServiceError::MetadataNotSerializable {
detail: "serializer said no".to_string(),
}
.to_string();
assert!(text.contains("serializer said no"), "{text}");
#[cfg(feature = "jwt")]
{
let text = ServiceError::JwksNotSerializable {
detail: "serializer said no".to_string(),
}
.to_string();
assert!(text.contains("serializer said no"), "{text}");
}
}
#[test]
fn a_message_page_carries_its_message_and_escapes_it() {
let html = verification_message("Approved. You can return to your device.");
assert!(html.starts_with("<!DOCTYPE html"), "{html}");
assert!(
html.contains("Approved. You can return to your device."),
"{html}"
);
assert!(html.ends_with("</body></html>"), "{html}");
let html = verification_message("<script>alert(1)</script>");
assert!(!html.contains("<script>"), "{html}");
assert!(html.contains("<script>"), "{html}");
}
fn routes_with_management() -> Routes {
Routes {
well_known: "/.well-known/oauth-authorization-server".to_string(),
authorize: "/authorize".to_string(),
token: "/token".to_string(),
device: "/device_authorization".to_string(),
introspect: Some("/introspect".to_string()),
revoke: None,
verification: Some("/device".to_string()),
register: Some("/register".to_string()),
manage: Some("/register/".to_string()),
#[cfg(feature = "par")]
par: None,
#[cfg(feature = "jwt")]
jwks: None,
}
}
#[test]
fn every_configured_path_resolves_and_nothing_else_does() {
let routes = routes_with_management();
assert!(matches!(
routes.resolve("/.well-known/oauth-authorization-server"),
Some(Route::Metadata)
));
assert!(matches!(routes.resolve("/token"), Some(Route::Token)));
assert!(matches!(
routes.resolve("/introspect"),
Some(Route::Introspect)
));
assert!(routes.resolve("/revoke").is_none());
assert!(routes.resolve("/tok").is_none());
assert!(routes.resolve("/token/").is_none());
assert!(routes.resolve("/xtoken").is_none());
}
#[test]
fn the_management_route_captures_exactly_one_segment() {
let routes = routes_with_management();
assert!(matches!(routes.resolve("/register/abc"), Some(Route::Manage(id)) if id == "abc"));
assert!(routes.resolve("/register/abc/extra").is_none());
assert!(routes.resolve("/register/").is_none());
assert!(matches!(routes.resolve("/register"), Some(Route::Register)));
}
#[test]
fn a_static_route_beats_the_dynamic_one() {
let mut routes = routes_with_management();
routes.token = "/register/token".to_string();
assert!(matches!(
routes.resolve("/register/token"),
Some(Route::Token)
));
assert!(matches!(routes.resolve("/register/other"), Some(Route::Manage(id)) if id == "other"));
}
#[test]
fn a_path_segment_decodes_percent_escapes_but_not_plus() {
assert_eq!(decode_path_segment("a+b"), "a+b");
assert_eq!(decode_component("a+b"), "a b");
assert_eq!(decode_path_segment("a%2Bb"), "a+b");
assert_eq!(decode_path_segment("client%20one"), "client one");
assert!(matches!(
decode_path_segment("plain-id"),
Cow::Borrowed("plain-id")
));
assert!(matches!(decode_path_segment("a+b"), Cow::Borrowed("a+b")));
}
#[test]
fn the_route_table_is_normalised_to_what_a_client_sends() {
for issuer in ["https://as.example/\u{e9}", "https://as.example/%C3%A9"] {
let mut config = ServerConfig::new(issuer, "https://as.example/device");
config.registration = None;
let meta = crate::metadata::AuthorizationServerMetadata::from_config(&config);
let iss = meta.issuer.clone();
let token = endpoint_path(&iss, "token_endpoint", &meta.token_endpoint).expect("under");
assert_eq!(
token, "/%C3%A9/token",
"{issuer}: the table must hold the bytes a client puts on the wire"
);
}
assert_eq!(encode_route_path("/token"), "/token");
assert_ne!(encode_route_path("/token"), "/%74oken");
assert_eq!(encode_route_path("/tenant%20a/token"), "/tenant%20a/token");
}
#[test]
fn the_allow_header_lists_head_wherever_get_is_served() {
assert_eq!(allowed(&Route::Metadata), "GET, HEAD");
assert_eq!(allowed(&Route::Token), "POST");
assert_eq!(allowed(&Route::Verification), "GET, HEAD, POST");
assert_eq!(allowed(&Route::Manage("c")), "GET, HEAD, PUT, DELETE");
}
struct Dribble {
remaining: usize,
chunk: usize,
}
impl http_body::Body for Dribble {
type Data = Bytes;
type Error = std::convert::Infallible;
fn poll_frame(
mut self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Result<http_body::Frame<Bytes>, Self::Error>>> {
if self.remaining == 0 {
return std::task::Poll::Ready(None);
}
let n = self.chunk.min(self.remaining);
self.remaining -= n;
std::task::Poll::Ready(Some(Ok(http_body::Frame::data(Bytes::from(vec![b'x'; n])))))
}
}
#[tokio::test]
async fn a_body_within_the_cap_is_read_whole() {
let body = Dribble {
remaining: 100,
chunk: 7,
};
let bytes = match collect_body(body, MAX_BODY_BYTES).await {
Ok(b) => b,
Err(_) => panic!("a 100 byte body is inside a 64 KiB cap"),
};
assert_eq!(bytes.len(), 100);
assert!(bytes.iter().all(|b| *b == b'x'));
}
#[tokio::test]
async fn a_body_over_the_cap_is_refused_rather_than_buffered() {
let body = Dribble {
remaining: 5_000,
chunk: 64,
};
assert!(matches!(
collect_body(body, 1_000).await,
Err(BodyError::TooLarge)
));
assert!(collect_body(
Dribble {
remaining: 1_000,
chunk: 64
},
1_000
)
.await
.is_ok());
assert!(collect_body(
Dribble {
remaining: 1_001,
chunk: 64
},
1_000
)
.await
.is_err());
}
#[tokio::test]
async fn a_declared_length_over_the_cap_is_refused_before_reading() {
let huge = Body::from(Bytes::from(vec![b'x'; 2_000]));
assert!(matches!(
collect_body(huge, 1_000).await,
Err(BodyError::TooLarge)
));
}
#[test]
fn the_response_body_reports_an_exact_length() {
use http_body::Body as _;
let empty = Body::empty();
assert!(empty.is_end_stream());
assert_eq!(empty.size_hint().exact(), Some(0));
let full = Body::from("hello".to_string());
assert!(!full.is_end_stream());
assert_eq!(full.size_hint().exact(), Some(5));
assert_eq!(full.into_bytes(), Bytes::from_static(b"hello"));
assert!(Body::from(Bytes::new()).is_end_stream());
}