pub fn is_registered(registered: &[String], presented: &str) -> bool {
registered.iter().any(|uri| uri == presented)
}
pub fn validate_registration(uri: &str) -> Result<(), String> {
let Some((scheme, rest)) = uri.split_once(':') else {
return Err(format!(
"a redirect URI must be absolute, with a scheme: {uri:?} has none (RFC 6749 §3.1.2)"
));
};
if scheme.is_empty() || !scheme.starts_with(|c: char| c.is_ascii_alphabetic()) {
return Err(format!("{uri:?} does not start with a URI scheme (RFC 6749 §3.1.2)"));
}
if uri.contains('#') {
return Err(format!(
"a redirect URI may not carry a fragment: {uri:?}. The browser never sends the \
fragment to the server, so a registered one could never be matched (RFC 6749 §3.1.2)"
));
}
if scheme.eq_ignore_ascii_case("http") && !is_loopback(rest) {
return Err(format!(
"{uri:?} is plain http, which puts the authorization code on the wire in clear \
text. OAuth 2.1 §4.1.1 allows http only for loopback — http://127.0.0.1, \
http://[::1] or http://localhost — which is how a native client receives a \
redirect. Register the https URL instead."
));
}
if (scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https"))
&& host_of(rest).is_empty()
{
return Err(format!("{uri:?} has no host (RFC 6749 §3.1.2)"));
}
Ok(())
}
fn host_of(rest: &str) -> &str {
let Some(authority) = rest.strip_prefix("//") else { return "" };
let authority = authority.split(['/', '?']).next().unwrap_or("");
let authority = authority.rsplit('@').next().unwrap_or("");
match authority.strip_prefix('[') {
Some(inner) => inner.split(']').next().unwrap_or(""),
None => authority.split(':').next().unwrap_or(""),
}
}
fn is_loopback(rest: &str) -> bool {
let host = host_of(rest);
host.eq_ignore_ascii_case("localhost")
|| host == "::1"
|| host.parse::<std::net::Ipv4Addr>().is_ok_and(|ip| ip.is_loopback())
}
#[cfg(test)]
mod tests {
use super::*;
fn registered() -> Vec<String> {
vec!["https://a.test/cb".to_string()]
}
#[test]
fn the_registered_uri_matches_itself_and_nothing_else_does() {
assert!(is_registered(®istered(), "https://a.test/cb"));
}
#[test]
fn a_trailing_slash_is_a_different_uri() {
assert!(!is_registered(®istered(), "https://a.test/cb/"));
}
#[test]
fn an_added_query_string_is_a_different_uri() {
assert!(!is_registered(®istered(), "https://a.test/cb?x=1"));
assert!(!is_registered(®istered(), "https://a.test/cb?"));
}
#[test]
fn a_dot_dot_segment_is_not_normalised_away() {
assert!(!is_registered(®istered(), "https://a.test/cb/../evil"));
assert!(!is_registered(®istered(), "https://a.test/./cb"));
}
#[test]
fn a_suffixed_host_is_not_the_registered_host() {
assert!(!is_registered(®istered(), "https://a.test.evil.com/cb"));
assert!(!is_registered(®istered(), "https://a.test@evil.com/cb"));
assert!(!is_registered(®istered(), "https://evil.com/?x=https://a.test/cb"));
}
#[test]
fn a_scheme_relative_uri_is_not_the_registered_one() {
assert!(!is_registered(®istered(), "//evil.com"));
assert!(!is_registered(®istered(), "//a.test/cb"));
}
#[test]
fn the_scheme_port_and_case_all_have_to_agree() {
assert!(!is_registered(®istered(), "http://a.test/cb"), "scheme");
assert!(!is_registered(®istered(), "https://a.test:443/cb"), "port");
assert!(!is_registered(®istered(), "https://A.TEST/cb"), "host case");
assert!(!is_registered(®istered(), "https://a.test/CB"), "path case");
}
#[test]
fn an_empty_registration_matches_nothing_at_all() {
assert!(!is_registered(&[], "https://a.test/cb"));
assert!(!is_registered(&[], ""));
}
#[test]
fn several_registered_uris_each_match_only_themselves() {
let registered =
vec!["https://a.test/cb".to_string(), "https://a.test/other".to_string()];
assert!(is_registered(®istered, "https://a.test/other"));
assert!(!is_registered(®istered, "https://a.test/oth"));
}
#[test]
fn registration_accepts_https_loopback_and_private_use_schemes() {
assert!(validate_registration("https://a.test/cb").is_ok());
assert!(validate_registration("https://a.test:8443/cb?x=1").is_ok());
assert!(validate_registration("http://127.0.0.1:1234/cb").is_ok());
assert!(validate_registration("http://localhost/cb").is_ok());
assert!(validate_registration("http://[::1]:9000/cb").is_ok());
assert!(validate_registration("com.example.app:/oauth").is_ok());
}
#[test]
fn registration_refuses_plain_http_to_anywhere_but_loopback() {
let error = validate_registration("http://example.com/cb").unwrap_err();
assert!(error.contains("clear text"), "got {error}");
assert!(validate_registration("http://127.0.0.1.evil.com/cb").is_err());
assert!(validate_registration("http://localhost.evil.com/cb").is_err());
}
#[test]
fn registration_refuses_a_relative_uri_or_a_fragment() {
assert!(validate_registration("/cb").is_err());
assert!(validate_registration("a.test/cb").is_err());
assert!(validate_registration("//evil.com").is_err());
let fragment = validate_registration("https://a.test/cb#done").unwrap_err();
assert!(fragment.contains("fragment"), "got {fragment}");
}
#[test]
fn registration_refuses_a_hostless_http_uri() {
assert!(validate_registration("https:///cb").is_err());
assert!(validate_registration("https://").is_err());
}
}