assay_auth/oidc_provider/
auth_params.rs1use std::collections::BTreeMap;
16
17pub const ALLOWED_KEYS: &[&str] = &[
19 "prompt",
20 "login_hint",
21 "domain_hint",
22 "hd",
23 "acr_values",
24 "max_age",
25 "ui_locales",
26];
27
28pub const ALLOWED_PREFIX: &str = "idp_";
31
32pub const REJECTED_KEYS: &[&str] = &[
34 "client_id",
35 "redirect_uri",
36 "scope",
37 "state",
38 "nonce",
39 "response_type",
40 "code_challenge",
41 "code_challenge_method",
42 "request",
43 "request_uri",
44];
45
46pub const MAX_VALUE_LEN: usize = 256;
49
50#[derive(Clone, Debug, PartialEq, Eq)]
53pub enum AuthParamError {
54 RejectedKey(String),
56 UnknownKey(String),
58 ValueTooLong(String),
60 InvalidKey(String),
66}
67
68impl std::fmt::Display for AuthParamError {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 match self {
71 Self::RejectedKey(k) => write!(f, "auth_params key {k:?} is owned by the framework"),
72 Self::UnknownKey(k) => write!(f, "auth_params key {k:?} is not in the whitelist"),
73 Self::ValueTooLong(k) => write!(
74 f,
75 "auth_params value for {k:?} exceeds {MAX_VALUE_LEN} chars"
76 ),
77 Self::InvalidKey(k) => write!(f, "auth_params key {k:?} contains invalid characters"),
78 }
79 }
80}
81
82impl std::error::Error for AuthParamError {}
83
84pub fn validate(params: &BTreeMap<String, String>) -> Result<(), AuthParamError> {
89 for (k, v) in params {
90 validate_pair(k, v)?;
91 }
92 Ok(())
93}
94
95pub fn validate_pair(key: &str, value: &str) -> Result<(), AuthParamError> {
98 if !is_clean_key(key) {
99 return Err(AuthParamError::InvalidKey(key.to_string()));
100 }
101 if REJECTED_KEYS.contains(&key) {
102 return Err(AuthParamError::RejectedKey(key.to_string()));
103 }
104 let allowed = ALLOWED_KEYS.contains(&key) || key.starts_with(ALLOWED_PREFIX);
105 if !allowed {
106 return Err(AuthParamError::UnknownKey(key.to_string()));
107 }
108 if value.len() > MAX_VALUE_LEN {
109 return Err(AuthParamError::ValueTooLong(key.to_string()));
110 }
111 Ok(())
112}
113
114fn is_clean_key(key: &str) -> bool {
115 !key.is_empty()
116 && key
117 .chars()
118 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
119}
120
121pub fn append_to_query(buf: &mut String, params: &BTreeMap<String, String>) {
125 for (k, v) in params {
126 if validate_pair(k, v).is_err() {
127 continue;
130 }
131 buf.push('&');
132 buf.push_str(k);
133 buf.push('=');
134 buf.push_str(&url_encode(v));
135 }
136}
137
138fn url_encode(s: &str) -> String {
139 let mut out = String::with_capacity(s.len());
140 for byte in s.bytes() {
141 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
142 out.push(byte as char);
143 } else {
144 out.push_str(&format!("%{byte:02X}"));
145 }
146 }
147 out
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 #[test]
155 fn whitelisted_keys_pass() {
156 for k in ALLOWED_KEYS {
157 assert!(validate_pair(k, "x").is_ok(), "{k} should pass");
158 }
159 }
160
161 #[test]
162 fn idp_prefix_passes() {
163 assert!(validate_pair("idp_login_hint_dom", "x").is_ok());
164 assert!(validate_pair("idp_anything", "x").is_ok());
165 }
166
167 #[test]
168 fn rejected_keys_are_rejected() {
169 for k in REJECTED_KEYS {
170 assert!(
171 matches!(validate_pair(k, "x"), Err(AuthParamError::RejectedKey(_))),
172 "{k} should be rejected as framework-owned"
173 );
174 }
175 }
176
177 #[test]
178 fn unknown_keys_are_rejected() {
179 let err = validate_pair("totally_random", "v").unwrap_err();
180 assert!(matches!(err, AuthParamError::UnknownKey(_)));
181 }
182
183 #[test]
184 fn oversize_value_is_rejected() {
185 let big = "x".repeat(MAX_VALUE_LEN + 1);
186 let err = validate_pair("prompt", &big).unwrap_err();
187 assert!(matches!(err, AuthParamError::ValueTooLong(_)));
188 }
189
190 #[test]
191 fn boundary_value_passes() {
192 let ok = "x".repeat(MAX_VALUE_LEN);
193 assert!(validate_pair("prompt", &ok).is_ok());
194 }
195
196 #[test]
197 fn invalid_key_chars_rejected() {
198 assert!(matches!(
199 validate_pair("with space", "v"),
200 Err(AuthParamError::InvalidKey(_))
201 ));
202 assert!(matches!(
203 validate_pair("", "v"),
204 Err(AuthParamError::InvalidKey(_))
205 ));
206 }
207
208 #[test]
209 fn append_to_query_url_encodes_values() {
210 let mut params = BTreeMap::new();
211 params.insert("prompt".to_string(), "consent".to_string());
212 params.insert("hd".to_string(), "example.com".to_string());
213 let mut buf = String::new();
214 append_to_query(&mut buf, ¶ms);
215 assert!(buf.contains("&prompt=consent"));
216 assert!(buf.contains("&hd=example.com"));
217 }
218
219 #[test]
220 fn append_to_query_skips_smuggled_invalid_pairs() {
221 let mut params = BTreeMap::new();
222 params.insert("client_id".to_string(), "evil".to_string());
223 params.insert("prompt".to_string(), "consent".to_string());
224 let mut buf = String::new();
225 append_to_query(&mut buf, ¶ms);
226 assert!(!buf.contains("client_id"));
227 assert!(buf.contains("prompt=consent"));
228 }
229}