aster_forge_external_auth/
normalize.rs

1//! Product-neutral normalization helpers for external authentication settings.
2//!
3//! These helpers cover small validation rules that recur in Aster services: provider keys, scopes,
4//! claim names, provider URLs, allow-listed email domains, flow tokens, return paths, and hashed
5//! login-flow secrets. Product crates still own callback URL construction, local email validation,
6//! persisted provider rows, API error codes, and account-linking policy.
7
8use crate::{ExternalAuthError, ExternalAuthProtocol, Result};
9
10/// Default scopes used when a provider or request does not specify its own scope list.
11pub const DEFAULT_SCOPES: &str = "openid email profile";
12
13/// Default maximum length for external-auth provider URLs.
14pub const DEFAULT_EXTERNAL_AUTH_URL_MAX_LEN: usize = 2048;
15
16/// Default maximum length for provider identity namespace values such as issuer URLs.
17pub const DEFAULT_EXTERNAL_AUTH_IDENTITY_NAMESPACE_MAX_LEN: usize = 512;
18
19/// Normalizes an application-owned provider key.
20///
21/// # Errors
22///
23/// Returns [`ExternalAuthError`] when the provider input violates its format, length, or protocol contract.
24pub 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
47/// Normalizes a required string field with a byte-length limit.
48///
49/// # Errors
50///
51/// Returns [`ExternalAuthError`] when the provider input violates its format, length, or protocol contract.
52pub 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
67/// Normalizes an optional provider claim name.
68///
69/// # Errors
70///
71/// Returns [`ExternalAuthError`] when the provider input violates its format, length, or protocol contract.
72pub 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
90/// Normalizes scopes with a caller-provided default.
91///
92/// # Errors
93///
94/// Returns [`ExternalAuthError`] when the provider input violates its format, length, or protocol contract.
95pub 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
120/// Normalizes scopes with Forge's default `openid email profile` value.
121///
122/// # Errors
123///
124/// Returns [`ExternalAuthError`] when the provider input violates its format, length, or protocol contract.
125pub 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
166/// Normalizes an icon URL that may be a root-relative path or HTTPS URL.
167///
168/// # Errors
169///
170/// Returns [`ExternalAuthError`] when the provider input violates its format, length, or protocol contract.
171pub 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
206/// Normalizes an issuer URL.
207///
208/// # Errors
209///
210/// Returns [`ExternalAuthError`] when the provider input violates its format, length, or protocol contract.
211pub 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
233/// Normalizes a manually configured provider endpoint.
234///
235/// # Errors
236///
237/// Returns [`ExternalAuthError`] when the provider input violates its format, length, or protocol contract.
238pub 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
259/// Normalizes an allow-list of email domains into a stable JSON string.
260///
261/// # Errors
262///
263/// Returns [`ExternalAuthError`] when the provider input violates its format, length, or protocol contract.
264pub 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
298/// Parses a stored allow-list JSON string into domain entries.
299///
300/// # Errors
301///
302/// Returns [`ExternalAuthError`] when the provider input violates its format, length, or protocol contract.
303pub 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
318/// Returns whether an email address is allowed by a stored allow-list JSON string.
319///
320/// # Errors
321///
322/// Returns [`ExternalAuthError`] when the provider input violates its format, length, or protocol contract.
323pub 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/// Hashes an OAuth/OIDC state value before persistence.
336#[must_use]
337pub fn state_hash(state: &str) -> String {
338    aster_forge_crypto::sha256_hex(state.as_bytes())
339}
340
341/// Hashes an external-auth flow token before persistence.
342#[must_use]
343pub fn token_hash(token: &str) -> String {
344    aster_forge_crypto::sha256_hex(token.as_bytes())
345}
346
347/// Normalizes a post-login return path.
348///
349/// # Errors
350///
351/// Returns [`ExternalAuthError`] when the provider input violates its format, length, or protocol contract.
352pub 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
373/// Normalizes an external-auth flow token supplied by a client.
374///
375/// # Errors
376///
377/// Returns [`ExternalAuthError`] when the provider input violates its format, length, or protocol contract.
378pub 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        // Control characters (CR/LF, TAB, NUL) must not pass: products may emit
483        // the stored path into redirects or logs where they become injection
484        // primitives. The scopes/claims/provider-key normalizers already reject
485        // `char::is_control`; return_path must match.
486        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}