1use iri_string::types::{UriReferenceString, UriRelativeString, UriString};
2use regex::Regex;
3use serde::{Deserialize, Serialize};
4use snafu::Snafu;
5use typed_builder::TypedBuilder;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8#[serde(tag = "type", content = "value", rename_all = "snake_case")]
9pub enum RedirectTargetRule {
10 Regex {
11 #[serde(with = "serde_regex")]
12 value: Regex,
13 },
14 All,
15 Strict {
16 value: String,
17 },
18}
19
20impl PartialEq for RedirectTargetRule {
21 fn eq(&self, other: &Self) -> bool {
22 match (self, other) {
23 (Self::All, Self::All) => true,
24 (Self::Strict { value: left }, Self::Strict { value: right }) => left == right,
25 (Self::Regex { value: left }, Self::Regex { value: right }) => {
26 left.as_str() == right.as_str()
27 }
28 _ => false,
29 }
30 }
31}
32
33impl Eq for RedirectTargetRule {}
34
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TypedBuilder)]
36pub struct RedirectTargetConfig {
37 #[serde(default)]
38 #[builder(default, setter(strip_option, into))]
39 pub default_redirect_target: Option<String>,
40 #[serde(default)]
41 #[builder(default)]
42 pub dynamic_redirect_target_enabled: bool,
43 #[serde(default)]
44 #[builder(default)]
45 pub allowed_redirect_targets: Vec<RedirectTargetRule>,
46}
47
48impl RedirectTargetConfig {
49 pub fn strict_default(redirect_target: impl Into<String>) -> Self {
50 Self::builder()
51 .default_redirect_target(redirect_target.into())
52 .build()
53 }
54
55 pub fn dynamic_targets(targets: impl IntoIterator<Item = RedirectTargetRule>) -> Self {
56 Self::builder()
57 .dynamic_redirect_target_enabled(true)
58 .allowed_redirect_targets(targets.into_iter().collect())
59 .build()
60 }
61
62 pub fn dynamic_default_and_dynamic_targets(
63 default_redirect_target: impl Into<String>,
64 targets: impl IntoIterator<Item = RedirectTargetRule>,
65 ) -> Self {
66 Self::builder()
67 .default_redirect_target(default_redirect_target.into())
68 .dynamic_redirect_target_enabled(true)
69 .allowed_redirect_targets(targets.into_iter().collect())
70 .build()
71 }
72
73 pub fn validate_as_uri_reference(&self) -> Result<(), RedirectTargetError> {
74 self.validate_with(parse_uri_reference, "URI reference")
75 }
76
77 pub fn validate_as_uri(&self) -> Result<(), RedirectTargetError> {
78 self.validate_with(parse_uri, "URI")
79 }
80
81 pub fn validate_as_uri_relative(&self) -> Result<(), RedirectTargetError> {
82 self.validate_with(parse_uri_relative, "relative URI reference")
83 }
84
85 fn validate_with<T>(
86 &self,
87 parse: fn(&str) -> Result<T, RedirectTargetError>,
88 target_type: &str,
89 ) -> Result<(), RedirectTargetError> {
90 if let Some(default_redirect_target) = self.default_redirect_target.as_deref() {
91 parse(default_redirect_target).map_err(|source| {
92 RedirectTargetError::InvalidRedirectTarget {
93 message: format!(
94 "default_redirect_target is not a valid {target_type}: {source}"
95 ),
96 }
97 })?;
98 }
99
100 if self.dynamic_redirect_target_enabled && self.allowed_redirect_targets.is_empty() {
101 return Err(RedirectTargetError::InvalidRedirectTarget {
102 message: "allowed_redirect_targets is required when \
103 dynamic_redirect_target_enabled is true"
104 .to_string(),
105 });
106 }
107
108 for rule in &self.allowed_redirect_targets {
109 if let RedirectTargetRule::Strict { value } = rule {
110 parse(value).map_err(|source| RedirectTargetError::InvalidRedirectTarget {
111 message: format!(
112 "invalid strict redirect target `{value}` for {target_type}: {source}"
113 ),
114 })?;
115 }
116 }
117
118 Ok(())
119 }
120}
121
122#[derive(Debug, Snafu)]
123pub enum RedirectTargetError {
124 #[snafu(display("redirect target is invalid: {message}"))]
125 InvalidRedirectTarget { message: String },
126}
127
128#[derive(Clone, Debug)]
129pub struct UriReferenceRedirectTargetResolver {
130 config: RedirectTargetConfig,
131}
132
133#[derive(Clone, Debug)]
134pub struct UriRedirectTargetResolver {
135 config: RedirectTargetConfig,
136}
137
138#[derive(Clone, Debug)]
139pub struct UriRelativeRedirectTargetResolver {
140 config: RedirectTargetConfig,
141}
142
143impl UriReferenceRedirectTargetResolver {
144 pub fn from_config(config: RedirectTargetConfig) -> Result<Self, RedirectTargetError> {
145 config.validate_as_uri_reference()?;
146 Ok(Self { config })
147 }
148
149 pub fn resolve_redirect_target(
150 &self,
151 requested_redirect_target: Option<&str>,
152 ) -> Result<UriReferenceString, RedirectTargetError> {
153 resolve_with_config(&self.config, requested_redirect_target, parse_uri_reference)
154 }
155}
156
157impl UriRedirectTargetResolver {
158 pub fn from_config(config: RedirectTargetConfig) -> Result<Self, RedirectTargetError> {
159 config.validate_as_uri()?;
160 Ok(Self { config })
161 }
162
163 pub fn resolve_redirect_target(
164 &self,
165 requested_redirect_target: Option<&str>,
166 ) -> Result<UriString, RedirectTargetError> {
167 resolve_with_config(&self.config, requested_redirect_target, parse_uri)
168 }
169}
170
171impl UriRelativeRedirectTargetResolver {
172 pub fn from_config(config: RedirectTargetConfig) -> Result<Self, RedirectTargetError> {
173 config.validate_as_uri_relative()?;
174 Ok(Self { config })
175 }
176
177 pub fn resolve_redirect_target(
178 &self,
179 requested_redirect_target: Option<&str>,
180 ) -> Result<UriRelativeString, RedirectTargetError> {
181 resolve_with_config(&self.config, requested_redirect_target, parse_uri_relative)
182 }
183}
184
185fn resolve_with_config<T>(
186 config: &RedirectTargetConfig,
187 requested_redirect_target: Option<&str>,
188 parse: fn(&str) -> Result<T, RedirectTargetError>,
189) -> Result<T, RedirectTargetError> {
190 match requested_redirect_target {
191 Some(requested_redirect_target) => {
192 if !config.dynamic_redirect_target_enabled {
193 return Err(RedirectTargetError::InvalidRedirectTarget {
194 message: "dynamic redirect target is disabled".to_string(),
195 });
196 }
197
198 let parsed = parse(requested_redirect_target).map_err(|source| {
199 RedirectTargetError::InvalidRedirectTarget {
200 message: format!("requested redirect target is invalid: {source}"),
201 }
202 })?;
203
204 if config
205 .allowed_redirect_targets
206 .iter()
207 .any(|rule| rule_matches(rule, requested_redirect_target))
208 {
209 Ok(parsed)
210 } else {
211 Err(RedirectTargetError::InvalidRedirectTarget {
212 message: format!(
213 "requested redirect target `{requested_redirect_target}` is not allowed"
214 ),
215 })
216 }
217 }
218 None => config
219 .default_redirect_target
220 .as_deref()
221 .ok_or_else(|| RedirectTargetError::InvalidRedirectTarget {
222 message: "redirect target is required when no default_redirect_target is \
223 configured"
224 .to_string(),
225 })
226 .and_then(parse),
227 }
228}
229
230fn rule_matches(rule: &RedirectTargetRule, redirect_target: &str) -> bool {
231 match rule {
232 RedirectTargetRule::All => true,
233 RedirectTargetRule::Regex { value } => value.is_match(redirect_target),
234 RedirectTargetRule::Strict { value } => value == redirect_target,
235 }
236}
237
238fn parse_uri_reference(value: &str) -> Result<UriReferenceString, RedirectTargetError> {
239 UriReferenceString::try_from(value.to_string()).map_err(|e| {
240 RedirectTargetError::InvalidRedirectTarget {
241 message: e.to_string(),
242 }
243 })
244}
245
246fn parse_uri(value: &str) -> Result<UriString, RedirectTargetError> {
247 UriString::try_from(value.to_string()).map_err(|e| RedirectTargetError::InvalidRedirectTarget {
248 message: e.to_string(),
249 })
250}
251
252fn parse_uri_relative(value: &str) -> Result<UriRelativeString, RedirectTargetError> {
253 UriRelativeString::try_from(value.to_string()).map_err(|e| {
254 RedirectTargetError::InvalidRedirectTarget {
255 message: e.to_string(),
256 }
257 })
258}
259
260#[cfg(test)]
261mod tests {
262 use regex::Regex;
263
264 use super::{
265 RedirectTargetConfig, RedirectTargetRule, UriRedirectTargetResolver,
266 UriReferenceRedirectTargetResolver, UriRelativeRedirectTargetResolver,
267 };
268
269 #[test]
270 fn uri_config_rejects_relative_default() {
271 let error = UriRedirectTargetResolver::from_config(RedirectTargetConfig {
272 default_redirect_target: Some("/not-absolute".to_string()),
273 dynamic_redirect_target_enabled: false,
274 allowed_redirect_targets: Vec::new(),
275 })
276 .expect_err("relative path should be rejected");
277
278 assert!(format!("{error}").contains("default_redirect_target is not a valid URI"));
279 }
280
281 #[test]
282 fn uri_relative_config_rejects_absolute_default() {
283 let error = UriRelativeRedirectTargetResolver::from_config(RedirectTargetConfig {
284 default_redirect_target: Some("https://evil.example.com".to_string()),
285 dynamic_redirect_target_enabled: false,
286 allowed_redirect_targets: Vec::new(),
287 })
288 .expect_err("absolute uri should be rejected");
289
290 assert!(format!("{error}").contains("relative URI reference"));
291 }
292
293 #[test]
294 fn uri_relative_resolver_allows_strict_default() {
295 let resolved = UriRelativeRedirectTargetResolver::from_config(
296 RedirectTargetConfig::strict_default("/"),
297 )
298 .expect("config should be valid")
299 .resolve_redirect_target(None)
300 .expect("default target should resolve");
301
302 assert_eq!(resolved.as_str(), "/");
303 }
304
305 #[test]
306 fn uri_resolver_allows_regex_rule() {
307 let resolved =
308 UriRedirectTargetResolver::from_config(RedirectTargetConfig::dynamic_targets(vec![
309 RedirectTargetRule::Regex {
310 value: Regex::new(r"^https://app\.example\.com/callback(/.*)?$")
311 .expect("regex should compile"),
312 },
313 ]))
314 .expect("config should be valid")
315 .resolve_redirect_target(Some("https://app.example.com/callback/a"))
316 .expect("regex target should resolve");
317
318 assert_eq!(resolved.as_str(), "https://app.example.com/callback/a");
319 }
320
321 #[test]
322 fn uri_reference_resolver_allows_relative_and_absolute() {
323 let resolver = UriReferenceRedirectTargetResolver::from_config(
324 RedirectTargetConfig::dynamic_targets(vec![RedirectTargetRule::All]),
325 )
326 .expect("config should be valid");
327
328 assert_eq!(
329 resolver
330 .resolve_redirect_target(Some("/foo"))
331 .expect("relative reference should resolve")
332 .as_str(),
333 "/foo"
334 );
335 assert_eq!(
336 resolver
337 .resolve_redirect_target(Some("https://example.com/foo"))
338 .expect("absolute reference should resolve")
339 .as_str(),
340 "https://example.com/foo"
341 );
342 }
343}