aster_forge_external_auth/
normalize.rs1use crate::{ExternalAuthError, ExternalAuthProtocol, Result};
9
10pub const DEFAULT_SCOPES: &str = "openid email profile";
12
13pub const DEFAULT_EXTERNAL_AUTH_URL_MAX_LEN: usize = 2048;
15
16pub const DEFAULT_EXTERNAL_AUTH_IDENTITY_NAMESPACE_MAX_LEN: usize = 512;
18
19pub fn normalize_provider_key(value: &str) -> Result<String> {
21 let key = value.trim().to_ascii_lowercase();
22 if key.len() < 2 || key.len() > 64 {
23 return Err(ExternalAuthError::validation_error(
24 "external auth provider key must be 2-64 characters",
25 ));
26 }
27 if !key
28 .chars()
29 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
30 {
31 return Err(ExternalAuthError::validation_error(
32 "external auth provider key may only contain lowercase letters, numbers and hyphens",
33 ));
34 }
35 if key.starts_with('-') || key.ends_with('-') {
36 return Err(ExternalAuthError::validation_error(
37 "external auth provider key cannot start or end with '-'",
38 ));
39 }
40 Ok(key)
41}
42
43pub fn normalize_required_field(value: &str, field: &str, max_len: usize) -> Result<String> {
45 let trimmed = value.trim();
46 if trimmed.is_empty() {
47 return Err(ExternalAuthError::validation_error(format!(
48 "{field} is required"
49 )));
50 }
51 if trimmed.len() > max_len {
52 return Err(ExternalAuthError::validation_error(format!(
53 "{field} exceeds {max_len} bytes"
54 )));
55 }
56 Ok(trimmed.to_string())
57}
58
59pub fn normalize_optional_claim(value: Option<String>, field: &str) -> Result<Option<String>> {
61 match value {
62 Some(value) => {
63 let trimmed = value.trim();
64 if trimmed.is_empty() {
65 Ok(None)
66 } else if trimmed.len() > 128 {
67 Err(ExternalAuthError::validation_error(format!(
68 "{field} exceeds 128 bytes"
69 )))
70 } else {
71 Ok(Some(trimmed.to_string()))
72 }
73 }
74 None => Ok(None),
75 }
76}
77
78pub fn normalize_scopes_with_default(
80 value: Option<&str>,
81 default_scopes: &str,
82 protocol: ExternalAuthProtocol,
83) -> Result<String> {
84 let raw = value.unwrap_or(default_scopes);
85 let mut scopes = Vec::new();
86 for scope in raw.split_whitespace() {
87 let scope = scope.trim();
88 if scope.is_empty() || scopes.iter().any(|existing| existing == scope) {
89 continue;
90 }
91 if scope.chars().any(char::is_control) || scope.len() > 128 {
92 return Err(ExternalAuthError::validation_error(
93 "invalid external auth scope",
94 ));
95 }
96 scopes.push(scope.to_string());
97 }
98 if protocol == ExternalAuthProtocol::Oidc && !scopes.iter().any(|scope| scope == "openid") {
99 scopes.insert(0, "openid".to_string());
100 }
101 Ok(scopes.join(" "))
102}
103
104pub fn normalize_scopes(value: Option<&str>, protocol: ExternalAuthProtocol) -> Result<String> {
106 normalize_scopes_with_default(value, DEFAULT_SCOPES, protocol)
107}
108
109fn parse_external_auth_url(value: &str, context: &str) -> Result<url::Url> {
110 aster_forge_utils::url::parse_url(value, context)
111 .map_err(|error| ExternalAuthError::validation_error(error.to_string()))
112}
113
114fn normalize_optional_url(
115 value: Option<String>,
116 field: &str,
117 max_len: usize,
118) -> Result<Option<String>> {
119 let Some(value) = value else {
120 return Ok(None);
121 };
122 let trimmed = value.trim();
123 if trimmed.is_empty() {
124 return Ok(None);
125 }
126 if trimmed.len() > max_len {
127 return Err(ExternalAuthError::validation_error(format!(
128 "{field} exceeds {max_len} bytes"
129 )));
130 }
131 let parse_context = format!("invalid external auth {field}");
132 let parsed = parse_external_auth_url(trimmed, &parse_context)?;
133 if !aster_forge_utils::url::is_https_or_loopback_http(&parsed) {
134 return Err(ExternalAuthError::validation_error(format!(
135 "external auth {field} must use HTTPS, except localhost"
136 )));
137 }
138 if parsed.fragment().is_some() {
139 return Err(ExternalAuthError::validation_error(format!(
140 "external auth {field} cannot include fragment"
141 )));
142 }
143 Ok(Some(trimmed.to_string()))
144}
145
146pub fn normalize_icon_url_input(value: Option<String>, max_len: usize) -> Result<Option<String>> {
148 let Some(value) = value else {
149 return Ok(None);
150 };
151 let trimmed = value.trim();
152 if trimmed.is_empty() {
153 return Ok(None);
154 }
155 if trimmed.len() > max_len {
156 return Err(ExternalAuthError::validation_error(format!(
157 "icon_url exceeds {max_len} bytes"
158 )));
159 }
160 if trimmed.chars().any(char::is_whitespace) {
161 return Err(ExternalAuthError::validation_error(
162 "external auth icon_url cannot contain whitespace",
163 ));
164 }
165 if trimmed.starts_with('/') && !trimmed.starts_with("//") {
166 return Ok(Some(trimmed.to_string()));
167 }
168 let parsed = parse_external_auth_url(trimmed, "invalid external auth icon_url")?;
169 if !aster_forge_utils::url::is_https_or_loopback_http(&parsed) {
170 return Err(ExternalAuthError::validation_error(
171 "external auth icon_url must be a root-relative path or HTTPS URL, except localhost",
172 ));
173 }
174 if parsed.fragment().is_some() {
175 return Err(ExternalAuthError::validation_error(
176 "external auth icon_url cannot include fragment",
177 ));
178 }
179 Ok(Some(trimmed.to_string()))
180}
181
182pub fn normalize_issuer_url_input(
184 value: Option<String>,
185 required: bool,
186 max_len: usize,
187) -> Result<Option<String>> {
188 let Some(issuer) = normalize_optional_url(value, "issuer_url", max_len)? else {
189 if required {
190 return Err(ExternalAuthError::validation_error(
191 "issuer_url is required",
192 ));
193 }
194 return Ok(None);
195 };
196 let parsed = parse_external_auth_url(&issuer, "invalid external auth issuer_url")?;
197 if parsed.query().is_some() {
198 return Err(ExternalAuthError::validation_error(
199 "external auth issuer_url cannot include query or fragment",
200 ));
201 }
202 Ok(Some(issuer.trim_end_matches('/').to_string()))
203}
204
205pub fn normalize_manual_endpoint_input(
207 value: Option<String>,
208 field: &str,
209 required: bool,
210 supported: bool,
211 max_len: usize,
212) -> Result<Option<String>> {
213 let endpoint = normalize_optional_url(value, field, max_len)?;
214 if endpoint.is_some() && !supported {
215 return Err(ExternalAuthError::validation_error(format!(
216 "{field} is not supported for this external auth provider kind"
217 )));
218 }
219 if endpoint.is_none() && required {
220 return Err(ExternalAuthError::validation_error(format!(
221 "{field} is required"
222 )));
223 }
224 Ok(endpoint)
225}
226
227pub fn normalize_allowed_domains(value: Option<Vec<String>>) -> Result<Option<String>> {
229 let Some(value) = value else {
230 return Ok(None);
231 };
232 let mut domains = Vec::new();
233 for raw in value {
234 let domain = raw.trim().trim_start_matches('@').to_ascii_lowercase();
235 if domain.is_empty() {
236 continue;
237 }
238 if domain.len() > 253
239 || !domain.contains('.')
240 || domain
241 .chars()
242 .any(|c| !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '.'))
243 {
244 return Err(ExternalAuthError::validation_error(format!(
245 "invalid external auth allowed domain '{raw}'"
246 )));
247 }
248 if !domains.contains(&domain) {
249 domains.push(domain);
250 }
251 }
252 if domains.is_empty() {
253 return Ok(None);
254 }
255 serde_json::to_string(&domains).map(Some).map_err(|error| {
256 ExternalAuthError::internal_error(format!(
257 "failed to serialize external auth allowed domains: {error}"
258 ))
259 })
260}
261
262pub fn parse_allowed_domains(raw: Option<&str>) -> Result<Vec<String>> {
264 let Some(raw) = raw else {
265 return Ok(Vec::new());
266 };
267 let trimmed = raw.trim();
268 if trimmed.is_empty() {
269 return Ok(Vec::new());
270 }
271 serde_json::from_str::<Vec<String>>(trimmed).map_err(|error| {
272 ExternalAuthError::state_error(format!(
273 "failed to parse external auth allowed domains: {error}"
274 ))
275 })
276}
277
278pub fn email_domain_allowed(allowed_domains: Option<&str>, email: &str) -> Result<bool> {
280 let domains = parse_allowed_domains(allowed_domains)?;
281 if domains.is_empty() {
282 return Ok(true);
283 }
284 let Some((_, domain)) = email.rsplit_once('@') else {
285 return Ok(false);
286 };
287 let domain = domain.to_ascii_lowercase();
288 Ok(domains.iter().any(|allowed| allowed == &domain))
289}
290
291pub fn state_hash(state: &str) -> String {
293 aster_forge_crypto::sha256_hex(state.as_bytes())
294}
295
296pub fn token_hash(token: &str) -> String {
298 aster_forge_crypto::sha256_hex(token.as_bytes())
299}
300
301pub fn normalize_return_path(value: Option<&str>, max_len: usize) -> Result<String> {
303 let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
304 return Ok("/".to_string());
305 };
306 if !value.starts_with('/')
307 || value.starts_with("//")
308 || value.contains('\\')
309 || value.chars().any(char::is_control)
310 {
311 return Err(ExternalAuthError::validation_error(
312 "invalid external auth return_path",
313 ));
314 }
315 if value.len() > max_len {
316 return Err(ExternalAuthError::validation_error(
317 "external auth return_path is too long",
318 ));
319 }
320 Ok(value.to_string())
321}
322
323pub fn normalize_flow_token(value: &str, max_len: usize) -> Result<String> {
325 let token = value.trim();
326 if token.is_empty() {
327 return Err(ExternalAuthError::validation_error(
328 "external auth flow_token is required",
329 ));
330 }
331 if token.len() > max_len || token.chars().any(char::is_whitespace) {
332 return Err(ExternalAuthError::validation_error(
333 "invalid external auth flow_token",
334 ));
335 }
336 Ok(token.to_string())
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342
343 #[test]
344 fn provider_key_is_lowercase_hyphenated_identifier() {
345 assert_eq!(normalize_provider_key(" GitHub ").unwrap(), "github");
346 assert_eq!(
347 normalize_provider_key("microsoft-work").unwrap(),
348 "microsoft-work"
349 );
350 assert!(normalize_provider_key("-bad").is_err());
351 assert!(normalize_provider_key("bad_underscore").is_err());
352 }
353
354 #[test]
355 fn scopes_are_deduplicated_and_oidc_scopes_include_openid() {
356 assert_eq!(
357 normalize_scopes_with_default(
358 Some("email profile email"),
359 "",
360 ExternalAuthProtocol::OAuth2
361 )
362 .unwrap(),
363 "email profile"
364 );
365 assert_eq!(
366 normalize_scopes_with_default(Some("email"), "", ExternalAuthProtocol::Oidc).unwrap(),
367 "openid email"
368 );
369 }
370
371 #[test]
372 fn icon_url_allows_root_relative_and_https_or_loopback_http() {
373 assert_eq!(
374 normalize_icon_url_input(Some("/assets/icon.svg".to_string()), 2048).unwrap(),
375 Some("/assets/icon.svg".to_string())
376 );
377 assert!(
378 normalize_icon_url_input(Some("http://localhost/icon.svg".to_string()), 2048).is_ok()
379 );
380 assert!(
381 normalize_icon_url_input(Some("//cdn.example.com/icon.svg".to_string()), 2048).is_err()
382 );
383 assert!(
384 normalize_icon_url_input(Some("http://example.com/icon.svg".to_string()), 2048)
385 .is_err()
386 );
387 }
388
389 #[test]
390 fn issuer_url_rejects_query_and_strips_trailing_slash() {
391 assert_eq!(
392 normalize_issuer_url_input(Some("https://id.example.com/".to_string()), true, 512)
393 .unwrap(),
394 Some("https://id.example.com".to_string())
395 );
396 assert!(
397 normalize_issuer_url_input(Some("https://id.example.com/?x=1".to_string()), true, 512)
398 .is_err()
399 );
400 assert!(normalize_issuer_url_input(None, true, 512).is_err());
401 }
402
403 #[test]
404 fn allowed_domains_are_normalized_and_deduplicated() {
405 assert_eq!(
406 normalize_allowed_domains(Some(vec![
407 " Example.COM ".to_string(),
408 "@example.com".to_string(),
409 "sub.example.com".to_string(),
410 ]))
411 .unwrap()
412 .as_deref(),
413 Some(r#"["example.com","sub.example.com"]"#)
414 );
415 assert!(email_domain_allowed(Some(r#"["example.com"]"#), "user@example.com").unwrap());
416 assert!(!email_domain_allowed(Some(r#"["example.com"]"#), "user@test.com").unwrap());
417 }
418
419 #[test]
420 fn return_path_and_flow_token_reject_unsafe_values() {
421 assert_eq!(normalize_return_path(None, 2048).unwrap(), "/");
422 assert_eq!(
423 normalize_return_path(Some("/dashboard?tab=auth"), 2048).unwrap(),
424 "/dashboard?tab=auth"
425 );
426 assert!(normalize_return_path(Some("//evil.example.com"), 2048).is_err());
427 assert!(normalize_return_path(Some("/bad\\path"), 2048).is_err());
428 assert!(normalize_return_path(Some("/ok\r\nhttps://evil.example.com"), 2048).is_err());
433 assert!(normalize_return_path(Some("/tab\ttab"), 2048).is_err());
434 assert!(normalize_return_path(Some("/nul\0byte"), 2048).is_err());
435
436 assert_eq!(normalize_flow_token(" token ", 128).unwrap(), "token");
437 assert!(normalize_flow_token("bad token", 128).is_err());
438 }
439
440 #[test]
441 fn state_and_token_hash_use_sha256_hex() {
442 assert_eq!(state_hash("state").len(), 64);
443 assert_eq!(state_hash("state"), token_hash("state"));
444 }
445}