use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use reqwest::Url;
use ring::rand::{SecureRandom, SystemRandom};
use sha2::{Digest, Sha256};
pub const MAX_REDIRECT_URIS: usize = 10;
pub const MAX_CLIENT_NAME: usize = 100;
pub fn sha256_hex(text: &str) -> String {
Sha256::digest(text.as_bytes()).iter().map(|b| format!("{b:02x}")).collect()
}
pub fn route_hash(route: &str) -> String {
sha256_hex(route)[..16].to_string()
}
fn random_bytes<const N: usize>() -> [u8; N] {
let mut bytes = [0u8; N];
SystemRandom::new().fill(&mut bytes).expect("the system's random source");
bytes
}
pub fn new_secret() -> String {
URL_SAFE_NO_PAD.encode(random_bytes::<32>())
}
pub fn new_client_id() -> String {
const ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567";
let bytes = random_bytes::<16>();
let mut bits: u32 = 0;
let mut held = 0;
let mut out = String::from("zfc_");
for byte in bytes {
bits = (bits << 8) | u32::from(byte);
held += 8;
while held >= 5 {
held -= 5;
out.push(ALPHABET[((bits >> held) & 31) as usize] as char);
}
}
if held > 0 {
out.push(ALPHABET[((bits << (5 - held)) & 31) as usize] as char);
}
out
}
pub fn is_issued_client_id(id: &str) -> bool {
id.strip_prefix("zfc_").is_some_and(|rest| rest.len() == 26 && rest.bytes().all(|b| b.is_ascii_lowercase() || (b'2'..=b'7').contains(&b)))
}
pub fn valid_challenge(challenge: &str) -> bool {
challenge.len() == 43 && challenge.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
pub fn pkce_matches(verifier: &str, challenge: &str) -> bool {
let shaped = (43..=128).contains(&verifier.len())
&& verifier.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~'));
shaped && URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())) == challenge
}
fn is_loopback_host(url: &Url) -> bool {
matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]"))
}
pub fn acceptable_redirect(uri: &str) -> Result<(), String> {
let url = Url::parse(uri).map_err(|e| format!("redirect URI '{uri}' is not a URL: {e}"))?;
if url.fragment().is_some() {
return Err(format!("redirect URI '{uri}' has a fragment"));
}
match url.scheme() {
"https" if url.host_str().is_some() => Ok(()),
"http" if is_loopback_host(&url) => Ok(()),
_ => Err(format!("redirect URI '{uri}' must be https, or http on localhost / 127.0.0.1 / [::1]")),
}
}
pub fn redirect_matches(registered: &str, presented: &str) -> bool {
if registered == presented {
return true;
}
let (Ok(mut a), Ok(mut b)) = (Url::parse(registered), Url::parse(presented)) else {
return false;
};
if a.scheme() != "http" || b.scheme() != "http" || !is_loopback_host(&a) || !is_loopback_host(&b) {
return false;
}
let _ = a.set_port(None);
let _ = b.set_port(None);
a == b
}
pub fn redirect_host(uri: &str) -> String {
Url::parse(uri).ok().and_then(|u| u.host_str().map(str::to_string)).unwrap_or_default()
}
pub fn with_query(uri: &str, pairs: &[(&str, &str)]) -> String {
match Url::parse(uri) {
Ok(mut url) => {
{
let mut query = url.query_pairs_mut();
for (key, value) in pairs {
query.append_pair(key, value);
}
}
url.to_string()
}
Err(_) => uri.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pkce_matches_the_rfc_7636_appendix_b_vector() {
let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
let challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
assert!(valid_challenge(challenge));
assert!(pkce_matches(verifier, challenge));
assert!(!pkce_matches("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXx", challenge));
assert!(!pkce_matches("short", challenge), "a verifier is 43 to 128 characters");
assert!(!valid_challenge("plain-text"));
}
#[test]
fn redirects_match_exactly_but_a_loopback_port() {
assert!(redirect_matches("https://client.example/cb", "https://client.example/cb"));
assert!(!redirect_matches("https://client.example/cb", "https://client.example/cb/"));
assert!(!redirect_matches("https://client.example/cb", "https://client.example:8443/cb"), "an https port is part of the URI");
assert!(redirect_matches("http://127.0.0.1/callback", "http://127.0.0.1:53121/callback"));
assert!(redirect_matches("http://localhost:7777/callback", "http://localhost:40123/callback"));
assert!(!redirect_matches("http://localhost/callback", "http://localhost:4000/other"));
assert!(!redirect_matches("http://localhost/callback", "http://127.0.0.1:4000/callback"), "another host is another URI");
}
#[test]
fn only_https_or_loopback_http_without_a_fragment_registers() {
assert!(acceptable_redirect("https://client.example/cb").is_ok());
assert!(acceptable_redirect("http://localhost:7777/oauth/callback").is_ok());
assert!(acceptable_redirect("http://[::1]/cb").is_ok());
assert!(acceptable_redirect("http://client.example/cb").is_err());
assert!(acceptable_redirect("https://client.example/cb#x").is_err());
assert!(acceptable_redirect("javascript:alert(1)").is_err());
}
#[test]
fn ids_and_secrets_are_shaped_and_fresh() {
let id = new_client_id();
assert!(is_issued_client_id(&id), "{id}");
assert_ne!(id, new_client_id());
assert_eq!(new_secret().len(), 43);
assert_eq!(route_hash("/library").len(), 16);
assert_eq!(redirect_host("https://client.example:8443/cb"), "client.example");
assert_eq!(with_query("https://client.example/cb?a=1", &[("code", "c d")]), "https://client.example/cb?a=1&code=c+d");
}
}