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> {
25 let key = value.trim().to_ascii_lowercase();
26 if key.len() < 2 || key.len() > 64 {
27 return Err(ExternalAuthError::validation_error(
28 "external auth provider key must be 2-64 characters",
29 ));
30 }
31 if !key
32 .chars()
33 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
34 {
35 return Err(ExternalAuthError::validation_error(
36 "external auth provider key may only contain lowercase letters, numbers and hyphens",
37 ));
38 }
39 if key.starts_with('-') || key.ends_with('-') {
40 return Err(ExternalAuthError::validation_error(
41 "external auth provider key cannot start or end with '-'",
42 ));
43 }
44 Ok(key)
45}
46
47pub fn normalize_required_field(value: &str, field: &str, max_len: usize) -> Result<String> {
53 let trimmed = value.trim();
54 if trimmed.is_empty() {
55 return Err(ExternalAuthError::validation_error(format!(
56 "{field} is required"
57 )));
58 }
59 if trimmed.len() > max_len {
60 return Err(ExternalAuthError::validation_error(format!(
61 "{field} exceeds {max_len} bytes"
62 )));
63 }
64 Ok(trimmed.to_string())
65}
66
67pub fn normalize_optional_claim(value: Option<String>, field: &str) -> Result<Option<String>> {
73 match value {
74 Some(value) => {
75 let trimmed = value.trim();
76 if trimmed.is_empty() {
77 Ok(None)
78 } else if trimmed.len() > 128 {
79 Err(ExternalAuthError::validation_error(format!(
80 "{field} exceeds 128 bytes"
81 )))
82 } else {
83 Ok(Some(trimmed.to_string()))
84 }
85 }
86 None => Ok(None),
87 }
88}
89
90pub fn normalize_scopes_with_default(
96 value: Option<&str>,
97 default_scopes: &str,
98 protocol: ExternalAuthProtocol,
99) -> Result<String> {
100 let raw = value.unwrap_or(default_scopes);
101 let mut scopes = Vec::new();
102 for scope in raw.split_whitespace() {
103 let scope = scope.trim();
104 if scope.is_empty() || scopes.iter().any(|existing| existing == scope) {
105 continue;
106 }
107 if scope.chars().any(char::is_control) || scope.len() > 128 {
108 return Err(ExternalAuthError::validation_error(
109 "invalid external auth scope",
110 ));
111 }
112 scopes.push(scope.to_string());
113 }
114 if protocol == ExternalAuthProtocol::Oidc && !scopes.iter().any(|scope| scope == "openid") {
115 scopes.insert(0, "openid".to_string());
116 }
117 Ok(scopes.join(" "))
118}
119
120pub fn normalize_scopes(value: Option<&str>, protocol: ExternalAuthProtocol) -> Result<String> {
126 normalize_scopes_with_default(value, DEFAULT_SCOPES, protocol)
127}
128
129fn parse_external_auth_url(value: &str, context: &str) -> Result<url::Url> {
130 aster_forge_utils::url::parse_url(value, context)
131 .map_err(|error| ExternalAuthError::validation_error(error.to_string()))
132}
133
134fn normalize_optional_url(
135 value: Option<String>,
136 field: &str,
137 max_len: usize,
138) -> Result<Option<String>> {
139 let Some(value) = value else {
140 return Ok(None);
141 };
142 let trimmed = value.trim();
143 if trimmed.is_empty() {
144 return Ok(None);
145 }
146 if trimmed.len() > max_len {
147 return Err(ExternalAuthError::validation_error(format!(
148 "{field} exceeds {max_len} bytes"
149 )));
150 }
151 let parse_context = format!("invalid external auth {field}");
152 let parsed = parse_external_auth_url(trimmed, &parse_context)?;
153 if !aster_forge_utils::url::is_https_or_loopback_http(&parsed) {
154 return Err(ExternalAuthError::validation_error(format!(
155 "external auth {field} must use HTTPS, except localhost"
156 )));
157 }
158 if parsed.fragment().is_some() {
159 return Err(ExternalAuthError::validation_error(format!(
160 "external auth {field} cannot include fragment"
161 )));
162 }
163 Ok(Some(trimmed.to_string()))
164}
165
166pub fn normalize_icon_url_input(value: Option<String>, max_len: usize) -> Result<Option<String>> {
172 let Some(value) = value else {
173 return Ok(None);
174 };
175 let trimmed = value.trim();
176 if trimmed.is_empty() {
177 return Ok(None);
178 }
179 if trimmed.len() > max_len {
180 return Err(ExternalAuthError::validation_error(format!(
181 "icon_url exceeds {max_len} bytes"
182 )));
183 }
184 if trimmed.chars().any(char::is_whitespace) {
185 return Err(ExternalAuthError::validation_error(
186 "external auth icon_url cannot contain whitespace",
187 ));
188 }
189 if trimmed.starts_with('/') && !trimmed.starts_with("//") {
190 return Ok(Some(trimmed.to_string()));
191 }
192 let parsed = parse_external_auth_url(trimmed, "invalid external auth icon_url")?;
193 if !aster_forge_utils::url::is_https_or_loopback_http(&parsed) {
194 return Err(ExternalAuthError::validation_error(
195 "external auth icon_url must be a root-relative path or HTTPS URL, except localhost",
196 ));
197 }
198 if parsed.fragment().is_some() {
199 return Err(ExternalAuthError::validation_error(
200 "external auth icon_url cannot include fragment",
201 ));
202 }
203 Ok(Some(trimmed.to_string()))
204}
205
206pub fn normalize_issuer_url_input(
212 value: Option<String>,
213 required: bool,
214 max_len: usize,
215) -> Result<Option<String>> {
216 let Some(issuer) = normalize_optional_url(value, "issuer_url", max_len)? else {
217 if required {
218 return Err(ExternalAuthError::validation_error(
219 "issuer_url is required",
220 ));
221 }
222 return Ok(None);
223 };
224 let parsed = parse_external_auth_url(&issuer, "invalid external auth issuer_url")?;
225 if parsed.query().is_some() {
226 return Err(ExternalAuthError::validation_error(
227 "external auth issuer_url cannot include query or fragment",
228 ));
229 }
230 Ok(Some(issuer.trim_end_matches('/').to_string()))
231}
232
233pub fn normalize_manual_endpoint_input(
239 value: Option<String>,
240 field: &str,
241 required: bool,
242 supported: bool,
243 max_len: usize,
244) -> Result<Option<String>> {
245 let endpoint = normalize_optional_url(value, field, max_len)?;
246 if endpoint.is_some() && !supported {
247 return Err(ExternalAuthError::validation_error(format!(
248 "{field} is not supported for this external auth provider kind"
249 )));
250 }
251 if endpoint.is_none() && required {
252 return Err(ExternalAuthError::validation_error(format!(
253 "{field} is required"
254 )));
255 }
256 Ok(endpoint)
257}
258
259pub fn normalize_allowed_domains(value: Option<Vec<String>>) -> Result<Option<String>> {
265 let Some(value) = value else {
266 return Ok(None);
267 };
268 let mut domains = Vec::new();
269 for raw in value {
270 let domain = raw.trim().trim_start_matches('@').to_ascii_lowercase();
271 if domain.is_empty() {
272 continue;
273 }
274 if domain.len() > 253
275 || !domain.contains('.')
276 || domain
277 .chars()
278 .any(|c| !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '.'))
279 {
280 return Err(ExternalAuthError::validation_error(format!(
281 "invalid external auth allowed domain '{raw}'"
282 )));
283 }
284 if !domains.contains(&domain) {
285 domains.push(domain);
286 }
287 }
288 if domains.is_empty() {
289 return Ok(None);
290 }
291 serde_json::to_string(&domains).map(Some).map_err(|error| {
292 ExternalAuthError::internal_error(format!(
293 "failed to serialize external auth allowed domains: {error}"
294 ))
295 })
296}
297
298pub fn parse_allowed_domains(raw: Option<&str>) -> Result<Vec<String>> {
304 let Some(raw) = raw else {
305 return Ok(Vec::new());
306 };
307 let trimmed = raw.trim();
308 if trimmed.is_empty() {
309 return Ok(Vec::new());
310 }
311 serde_json::from_str::<Vec<String>>(trimmed).map_err(|error| {
312 ExternalAuthError::state_error(format!(
313 "failed to parse external auth allowed domains: {error}"
314 ))
315 })
316}
317
318pub fn email_domain_allowed(allowed_domains: Option<&str>, email: &str) -> Result<bool> {
324 let domains = parse_allowed_domains(allowed_domains)?;
325 if domains.is_empty() {
326 return Ok(true);
327 }
328 let Some((_, domain)) = email.rsplit_once('@') else {
329 return Ok(false);
330 };
331 let domain = domain.to_ascii_lowercase();
332 Ok(domains.iter().any(|allowed| allowed == &domain))
333}
334
335#[must_use]
337pub fn state_hash(state: &str) -> String {
338 aster_forge_crypto::sha256_hex(state.as_bytes())
339}
340
341#[must_use]
343pub fn token_hash(token: &str) -> String {
344 aster_forge_crypto::sha256_hex(token.as_bytes())
345}
346
347pub fn normalize_return_path(value: Option<&str>, max_len: usize) -> Result<String> {
353 let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
354 return Ok("/".to_string());
355 };
356 if !value.starts_with('/')
357 || value.starts_with("//")
358 || value.contains('\\')
359 || value.chars().any(char::is_control)
360 {
361 return Err(ExternalAuthError::validation_error(
362 "invalid external auth return_path",
363 ));
364 }
365 if value.len() > max_len {
366 return Err(ExternalAuthError::validation_error(
367 "external auth return_path is too long",
368 ));
369 }
370 Ok(value.to_string())
371}
372
373pub fn normalize_flow_token(value: &str, max_len: usize) -> Result<String> {
379 let token = value.trim();
380 if token.is_empty() {
381 return Err(ExternalAuthError::validation_error(
382 "external auth flow_token is required",
383 ));
384 }
385 if token.len() > max_len || token.chars().any(char::is_whitespace) {
386 return Err(ExternalAuthError::validation_error(
387 "invalid external auth flow_token",
388 ));
389 }
390 Ok(token.to_string())
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 #[test]
398 fn provider_key_is_lowercase_hyphenated_identifier() {
399 assert_eq!(normalize_provider_key(" GitHub ").unwrap(), "github");
400 assert_eq!(
401 normalize_provider_key("microsoft-work").unwrap(),
402 "microsoft-work"
403 );
404 assert!(normalize_provider_key("-bad").is_err());
405 assert!(normalize_provider_key("bad_underscore").is_err());
406 }
407
408 #[test]
409 fn scopes_are_deduplicated_and_oidc_scopes_include_openid() {
410 assert_eq!(
411 normalize_scopes_with_default(
412 Some("email profile email"),
413 "",
414 ExternalAuthProtocol::OAuth2
415 )
416 .unwrap(),
417 "email profile"
418 );
419 assert_eq!(
420 normalize_scopes_with_default(Some("email"), "", ExternalAuthProtocol::Oidc).unwrap(),
421 "openid email"
422 );
423 }
424
425 #[test]
426 fn icon_url_allows_root_relative_and_https_or_loopback_http() {
427 assert_eq!(
428 normalize_icon_url_input(Some("/assets/icon.svg".to_string()), 2048).unwrap(),
429 Some("/assets/icon.svg".to_string())
430 );
431 assert!(
432 normalize_icon_url_input(Some("http://localhost/icon.svg".to_string()), 2048).is_ok()
433 );
434 assert!(
435 normalize_icon_url_input(Some("//cdn.example.com/icon.svg".to_string()), 2048).is_err()
436 );
437 assert!(
438 normalize_icon_url_input(Some("http://example.com/icon.svg".to_string()), 2048)
439 .is_err()
440 );
441 }
442
443 #[test]
444 fn issuer_url_rejects_query_and_strips_trailing_slash() {
445 assert_eq!(
446 normalize_issuer_url_input(Some("https://id.example.com/".to_string()), true, 512)
447 .unwrap(),
448 Some("https://id.example.com".to_string())
449 );
450 assert!(
451 normalize_issuer_url_input(Some("https://id.example.com/?x=1".to_string()), true, 512)
452 .is_err()
453 );
454 assert!(normalize_issuer_url_input(None, true, 512).is_err());
455 }
456
457 #[test]
458 fn allowed_domains_are_normalized_and_deduplicated() {
459 assert_eq!(
460 normalize_allowed_domains(Some(vec![
461 " Example.COM ".to_string(),
462 "@example.com".to_string(),
463 "sub.example.com".to_string(),
464 ]))
465 .unwrap()
466 .as_deref(),
467 Some(r#"["example.com","sub.example.com"]"#)
468 );
469 assert!(email_domain_allowed(Some(r#"["example.com"]"#), "user@example.com").unwrap());
470 assert!(!email_domain_allowed(Some(r#"["example.com"]"#), "user@test.com").unwrap());
471 }
472
473 #[test]
474 fn return_path_and_flow_token_reject_unsafe_values() {
475 assert_eq!(normalize_return_path(None, 2048).unwrap(), "/");
476 assert_eq!(
477 normalize_return_path(Some("/dashboard?tab=auth"), 2048).unwrap(),
478 "/dashboard?tab=auth"
479 );
480 assert!(normalize_return_path(Some("//evil.example.com"), 2048).is_err());
481 assert!(normalize_return_path(Some("/bad\\path"), 2048).is_err());
482 assert!(normalize_return_path(Some("/ok\r\nhttps://evil.example.com"), 2048).is_err());
487 assert!(normalize_return_path(Some("/tab\ttab"), 2048).is_err());
488 assert!(normalize_return_path(Some("/nul\0byte"), 2048).is_err());
489
490 assert_eq!(normalize_flow_token(" token ", 128).unwrap(), "token");
491 assert!(normalize_flow_token("bad token", 128).is_err());
492 }
493
494 #[test]
495 fn state_and_token_hash_use_sha256_hex() {
496 assert_eq!(state_hash("state").len(), 64);
497 assert_eq!(state_hash("state"), token_hash("state"));
498 }
499}