soma_auth/
redirect_uri.rs1fn 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
17fn 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 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}