Skip to main content

soma_auth/
redirect_uri.rs

1//! Pure redirect-URI trust checks shared by DCR (`registration.rs`) and CIMD
2//! (`authorize.rs`) client resolution. No I/O, no `AuthState` โ€” every
3//! function here takes only strings/patterns and returns a bool, so this
4//! module has no feature dependency of its own beyond `reqwest::Url`
5//! parsing; it's gated behind `http-axum` only because its sole callers are.
6
7fn is_loopback_redirect(value: &str) -> bool {
8    let Ok(url) = reqwest::Url::parse(value) else {
9        return false;
10    };
11    if url.scheme() != "http" {
12        return false;
13    }
14    matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "::1"))
15}
16
17/// Native-app private-use URI scheme redirects (RFC 8252 ยง7.1), e.g.
18/// `com.raycast:/oauth`. Only an app registered for that scheme with the
19/// OS can receive the redirect, so โ€” like loopback โ€” these don't need an
20/// explicit allowlist entry per client. Deliberately excludes `http(s)`
21/// (network-reachable, needs the allowlist) and script-executing pseudo
22/// schemes a browser might act on directly instead of merely redirecting.
23fn is_native_app_scheme_redirect(value: &str) -> bool {
24    let Ok(url) = reqwest::Url::parse(value) else {
25        return false;
26    };
27    !matches!(
28        url.scheme(),
29        "http" | "https" | "javascript" | "data" | "vbscript" | "file"
30    )
31}
32
33pub(crate) fn is_allowed_redirect_uri(value: &str, patterns: &[String]) -> bool {
34    if is_loopback_redirect(value) || is_native_app_scheme_redirect(value) {
35        return true;
36    }
37
38    let Ok(candidate) = reqwest::Url::parse(value) else {
39        return false;
40    };
41    patterns
42        .iter()
43        .any(|pattern| redirect_pattern_matches(pattern, &candidate))
44}
45
46pub(crate) fn wildcard_matches(pattern: &str, value: &str) -> bool {
47    if pattern == "*" {
48        return true;
49    }
50    let parts: Vec<&str> = pattern.split('*').collect();
51    if parts.len() == 1 {
52        return pattern == value;
53    }
54
55    let anchored_start = !pattern.starts_with('*');
56    let anchored_end = !pattern.ends_with('*');
57    let non_empty_parts: Vec<&str> = parts.into_iter().filter(|part| !part.is_empty()).collect();
58    if non_empty_parts.is_empty() {
59        return true;
60    }
61
62    let mut cursor = 0usize;
63    for (index, part) in non_empty_parts.iter().enumerate() {
64        if index == 0 && anchored_start {
65            if !value[cursor..].starts_with(part) {
66                return false;
67            }
68            cursor += part.len();
69            continue;
70        }
71
72        match value[cursor..].find(part) {
73            Some(found) => cursor += found + part.len(),
74            None => return false,
75        }
76    }
77
78    if anchored_end && let Some(last) = non_empty_parts.last() {
79        return value.ends_with(last);
80    }
81
82    true
83}
84
85fn redirect_pattern_matches(pattern: &str, candidate: &reqwest::Url) -> bool {
86    if pattern == "https://*" {
87        return candidate.scheme() == "https" && candidate.host_str().is_some();
88    }
89
90    let Ok(pattern_url) = reqwest::Url::parse(pattern) else {
91        return false;
92    };
93    if pattern_url.scheme() != candidate.scheme() {
94        return false;
95    }
96
97    // Native-app custom URI schemes (e.g. `com.raycast:/oauth`) have no
98    // authority component, so `host_str()` is None and can never satisfy the
99    // host/port comparison below. Compare the whole URI instead.
100    if pattern_url.host_str().is_none() || candidate.host_str().is_none() {
101        return wildcard_matches(pattern, candidate.as_str());
102    }
103
104    if pattern_url.port_or_known_default() != candidate.port_or_known_default() {
105        return false;
106    }
107    let Some(pattern_host) = pattern_url.host_str() else {
108        return false;
109    };
110    let Some(candidate_host) = candidate.host_str() else {
111        return false;
112    };
113    if !host_pattern_matches(pattern_host, candidate_host) {
114        return false;
115    }
116    if !wildcard_matches(pattern_url.path(), candidate.path()) {
117        return false;
118    }
119
120    match (pattern_url.query(), candidate.query()) {
121        (Some(pattern_query), Some(candidate_query)) => {
122            wildcard_matches(pattern_query, candidate_query)
123        }
124        (None, None) => true,
125        _ => false,
126    }
127}
128
129pub(crate) fn host_pattern_matches(pattern_host: &str, candidate_host: &str) -> bool {
130    let pattern_labels = pattern_host.split('.').collect::<Vec<_>>();
131    let candidate_labels = candidate_host.split('.').collect::<Vec<_>>();
132    if pattern_labels.len() != candidate_labels.len() {
133        return false;
134    }
135
136    pattern_labels
137        .iter()
138        .zip(candidate_labels.iter())
139        .all(|(pattern, candidate)| {
140            *pattern == "*" || (!pattern.contains('*') && pattern.eq_ignore_ascii_case(candidate))
141        })
142}