use std::collections::BTreeMap;
pub const ALLOWED_KEYS: &[&str] = &[
"prompt",
"login_hint",
"domain_hint",
"hd",
"acr_values",
"max_age",
"ui_locales",
];
pub const ALLOWED_PREFIX: &str = "idp_";
pub const REJECTED_KEYS: &[&str] = &[
"client_id",
"redirect_uri",
"scope",
"state",
"nonce",
"response_type",
"code_challenge",
"code_challenge_method",
"request",
"request_uri",
];
pub const MAX_VALUE_LEN: usize = 256;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AuthParamError {
RejectedKey(String),
UnknownKey(String),
ValueTooLong(String),
InvalidKey(String),
}
impl std::fmt::Display for AuthParamError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::RejectedKey(k) => write!(f, "auth_params key {k:?} is owned by the framework"),
Self::UnknownKey(k) => write!(f, "auth_params key {k:?} is not in the whitelist"),
Self::ValueTooLong(k) => write!(
f,
"auth_params value for {k:?} exceeds {MAX_VALUE_LEN} chars"
),
Self::InvalidKey(k) => write!(f, "auth_params key {k:?} contains invalid characters"),
}
}
}
impl std::error::Error for AuthParamError {}
pub fn validate(params: &BTreeMap<String, String>) -> Result<(), AuthParamError> {
for (k, v) in params {
validate_pair(k, v)?;
}
Ok(())
}
pub fn validate_pair(key: &str, value: &str) -> Result<(), AuthParamError> {
if !is_clean_key(key) {
return Err(AuthParamError::InvalidKey(key.to_string()));
}
if REJECTED_KEYS.contains(&key) {
return Err(AuthParamError::RejectedKey(key.to_string()));
}
let allowed = ALLOWED_KEYS.contains(&key) || key.starts_with(ALLOWED_PREFIX);
if !allowed {
return Err(AuthParamError::UnknownKey(key.to_string()));
}
if value.len() > MAX_VALUE_LEN {
return Err(AuthParamError::ValueTooLong(key.to_string()));
}
Ok(())
}
fn is_clean_key(key: &str) -> bool {
!key.is_empty()
&& key
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
}
pub fn append_to_query(buf: &mut String, params: &BTreeMap<String, String>) {
for (k, v) in params {
if validate_pair(k, v).is_err() {
continue;
}
buf.push('&');
buf.push_str(k);
buf.push('=');
buf.push_str(&url_encode(v));
}
}
fn url_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for byte in s.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
out.push(byte as char);
} else {
out.push_str(&format!("%{byte:02X}"));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn whitelisted_keys_pass() {
for k in ALLOWED_KEYS {
assert!(validate_pair(k, "x").is_ok(), "{k} should pass");
}
}
#[test]
fn idp_prefix_passes() {
assert!(validate_pair("idp_login_hint_dom", "x").is_ok());
assert!(validate_pair("idp_anything", "x").is_ok());
}
#[test]
fn rejected_keys_are_rejected() {
for k in REJECTED_KEYS {
assert!(
matches!(validate_pair(k, "x"), Err(AuthParamError::RejectedKey(_))),
"{k} should be rejected as framework-owned"
);
}
}
#[test]
fn unknown_keys_are_rejected() {
let err = validate_pair("totally_random", "v").unwrap_err();
assert!(matches!(err, AuthParamError::UnknownKey(_)));
}
#[test]
fn oversize_value_is_rejected() {
let big = "x".repeat(MAX_VALUE_LEN + 1);
let err = validate_pair("prompt", &big).unwrap_err();
assert!(matches!(err, AuthParamError::ValueTooLong(_)));
}
#[test]
fn boundary_value_passes() {
let ok = "x".repeat(MAX_VALUE_LEN);
assert!(validate_pair("prompt", &ok).is_ok());
}
#[test]
fn invalid_key_chars_rejected() {
assert!(matches!(
validate_pair("with space", "v"),
Err(AuthParamError::InvalidKey(_))
));
assert!(matches!(
validate_pair("", "v"),
Err(AuthParamError::InvalidKey(_))
));
}
#[test]
fn append_to_query_url_encodes_values() {
let mut params = BTreeMap::new();
params.insert("prompt".to_string(), "consent".to_string());
params.insert("hd".to_string(), "example.com".to_string());
let mut buf = String::new();
append_to_query(&mut buf, ¶ms);
assert!(buf.contains("&prompt=consent"));
assert!(buf.contains("&hd=example.com"));
}
#[test]
fn append_to_query_skips_smuggled_invalid_pairs() {
let mut params = BTreeMap::new();
params.insert("client_id".to_string(), "evil".to_string());
params.insert("prompt".to_string(), "consent".to_string());
let mut buf = String::new();
append_to_query(&mut buf, ¶ms);
assert!(!buf.contains("client_id"));
assert!(buf.contains("prompt=consent"));
}
}