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