Skip to main content

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.
20pub 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
43/// Normalizes a required string field with a byte-length limit.
44pub 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
59/// Normalizes an optional provider claim name.
60pub 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
78/// Normalizes scopes with a caller-provided default.
79pub 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
104/// Normalizes scopes with Forge's default `openid email profile` value.
105pub 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
146/// Normalizes an icon URL that may be a root-relative path or HTTPS URL.
147pub 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
182/// Normalizes an issuer URL.
183pub 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
205/// Normalizes a manually configured provider endpoint.
206pub 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
227/// Normalizes an allow-list of email domains into a stable JSON string.
228pub 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
262/// Parses a stored allow-list JSON string into domain entries.
263pub 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
278/// Returns whether an email address is allowed by a stored allow-list JSON string.
279pub 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
291/// Hashes an OAuth/OIDC state value before persistence.
292pub fn state_hash(state: &str) -> String {
293    aster_forge_crypto::sha256_hex(state.as_bytes())
294}
295
296/// Hashes an external-auth flow token before persistence.
297pub fn token_hash(token: &str) -> String {
298    aster_forge_crypto::sha256_hex(token.as_bytes())
299}
300
301/// Normalizes a post-login return path.
302pub 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
323/// Normalizes an external-auth flow token supplied by a client.
324pub 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        // Control characters (CR/LF, TAB, NUL) must not pass: products may emit
429        // the stored path into redirects or logs where they become injection
430        // primitives. The scopes/claims/provider-key normalizers already reject
431        // `char::is_control`; return_path must match.
432        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}