1use std::{fmt, time::Duration};
4
5use http::{
6 HeaderMap, HeaderName, HeaderValue, StatusCode, Uri,
7 header::{LOCATION, WWW_AUTHENTICATE},
8};
9use soaprs_core::{SoapError, SoapResult};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum SameSite {
14 Strict,
16 Lax,
18 None,
20}
21
22#[derive(Clone, PartialEq, Eq)]
24pub struct ResponseCookie {
25 pub name: String,
27 pub value: String,
29 pub path: Option<String>,
31 pub domain: Option<String>,
33 pub max_age: Option<Duration>,
35 pub secure: bool,
37 pub http_only: bool,
39 pub same_site: Option<SameSite>,
41}
42
43impl fmt::Debug for ResponseCookie {
44 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
45 formatter
46 .debug_struct("ResponseCookie")
47 .field("name", &self.name)
48 .field("value", &"[REDACTED]")
49 .field("path", &self.path)
50 .field("domain", &self.domain)
51 .field("max_age", &self.max_age)
52 .field("secure", &self.secure)
53 .field("http_only", &self.http_only)
54 .field("same_site", &self.same_site)
55 .finish()
56 }
57}
58
59impl ResponseCookie {
60 pub fn new(name: impl Into<String>, value: impl Into<String>) -> SoapResult<Self> {
62 let name = name.into();
63 let value = value.into();
64 validate_cookie(&name, &value)?;
65 Ok(Self {
66 name,
67 value,
68 path: Some("/".to_owned()),
69 domain: None,
70 max_age: None,
71 secure: true,
72 http_only: true,
73 same_site: Some(SameSite::Lax),
74 })
75 }
76
77 pub fn remove(name: impl Into<String>) -> SoapResult<Self> {
79 let mut cookie = Self::new(name, "")?;
80 cookie.max_age = Some(Duration::ZERO);
81 Ok(cookie)
82 }
83
84 pub fn path(mut self, path: impl Into<String>) -> SoapResult<Self> {
86 let path = path.into();
87 if !path.starts_with('/') || path.chars().any(char::is_control) || path.contains(';') {
88 return Err(SoapError::validation("invalid cookie path"));
89 }
90 self.path = Some(path);
91 Ok(self)
92 }
93
94 pub fn domain(mut self, domain: impl Into<String>) -> SoapResult<Self> {
96 let domain = domain.into();
97 validate_cookie_domain(&domain)?;
98 self.domain = Some(domain);
99 Ok(self)
100 }
101
102 #[must_use]
104 pub const fn max_age(mut self, max_age: Duration) -> Self {
105 self.max_age = Some(max_age);
106 self
107 }
108
109 pub fn same_site(mut self, same_site: SameSite) -> SoapResult<Self> {
111 if same_site == SameSite::None && !self.secure {
112 return Err(SoapError::validation(
113 "SameSite=None cookies must be secure",
114 ));
115 }
116 self.same_site = Some(same_site);
117 Ok(self)
118 }
119
120 pub fn insecure(mut self) -> SoapResult<Self> {
124 if self.same_site == Some(SameSite::None) {
125 return Err(SoapError::validation(
126 "SameSite=None cookies must be secure",
127 ));
128 }
129 self.secure = false;
130 Ok(self)
131 }
132
133 #[must_use]
135 pub const fn script_accessible(mut self) -> Self {
136 self.http_only = false;
137 self
138 }
139
140 pub fn validate(&self) -> SoapResult<()> {
142 validate_cookie(&self.name, &self.value)?;
143 if self.path.as_ref().is_some_and(|path| {
144 !path.starts_with('/') || path.chars().any(char::is_control) || path.contains(';')
145 }) {
146 return Err(SoapError::validation("invalid cookie path"));
147 }
148 if let Some(domain) = &self.domain {
149 validate_cookie_domain(domain)?;
150 }
151 if self.same_site == Some(SameSite::None) && !self.secure {
152 return Err(SoapError::validation(
153 "SameSite=None cookies must be secure",
154 ));
155 }
156 Ok(())
157 }
158}
159
160#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct AuthChallenge {
163 scheme: String,
164 parameters: Vec<(String, String)>,
165}
166
167impl AuthChallenge {
168 pub fn new(scheme: impl Into<String>) -> SoapResult<Self> {
170 let scheme = scheme.into();
171 if !valid_token(&scheme) {
172 return Err(SoapError::validation(format!(
173 "invalid authentication scheme `{scheme}`"
174 )));
175 }
176 Ok(Self {
177 scheme,
178 parameters: Vec::new(),
179 })
180 }
181
182 pub fn realm(self, realm: impl Into<String>) -> SoapResult<Self> {
184 self.parameter("realm", realm)
185 }
186
187 pub fn parameter(
189 mut self,
190 name: impl Into<String>,
191 value: impl Into<String>,
192 ) -> SoapResult<Self> {
193 let name = name.into();
194 let value = value.into();
195 if !valid_token(&name) || value.chars().any(char::is_control) {
196 return Err(SoapError::validation(
197 "invalid authentication challenge parameter",
198 ));
199 }
200 if let Some(existing) = self
201 .parameters
202 .iter_mut()
203 .find(|(existing, _)| existing.eq_ignore_ascii_case(&name))
204 {
205 *existing = (name, value);
206 } else {
207 self.parameters.push((name, value));
208 }
209 Ok(self)
210 }
211
212 pub fn scheme(&self) -> &str {
214 &self.scheme
215 }
216
217 pub fn to_header_value(&self) -> SoapResult<HeaderValue> {
219 let mut encoded = self.scheme.clone();
220 for (index, (name, value)) in self.parameters.iter().enumerate() {
221 if index == 0 {
222 encoded.push(' ');
223 } else {
224 encoded.push_str(", ");
225 }
226 encoded.push_str(name);
227 encoded.push_str("=\"");
228 for character in value.chars() {
229 if matches!(character, '\\' | '"') {
230 encoded.push('\\');
231 }
232 encoded.push(character);
233 }
234 encoded.push('"');
235 }
236 HeaderValue::from_str(&encoded)
237 .map_err(|_| SoapError::validation("authentication challenge cannot be encoded"))
238 }
239}
240
241impl fmt::Display for AuthChallenge {
242 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
243 match self.to_header_value() {
244 Ok(value) => formatter.write_str(value.to_str().unwrap_or(self.scheme())),
245 Err(_) => formatter.write_str(self.scheme()),
246 }
247 }
248}
249
250#[derive(Clone, PartialEq, Eq)]
252pub struct Redirect {
253 pub status: StatusCode,
255 pub location: Uri,
257}
258
259impl fmt::Debug for Redirect {
260 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
261 formatter
262 .debug_struct("Redirect")
263 .field("status", &self.status)
264 .field("location", &"[REDACTED]")
265 .finish()
266 }
267}
268
269impl Redirect {
270 pub fn new(status: StatusCode, location: Uri) -> SoapResult<Self> {
272 if !matches!(
273 status,
274 StatusCode::MOVED_PERMANENTLY
275 | StatusCode::FOUND
276 | StatusCode::SEE_OTHER
277 | StatusCode::TEMPORARY_REDIRECT
278 | StatusCode::PERMANENT_REDIRECT
279 ) {
280 return Err(SoapError::validation(
281 "redirect status must be 301, 302, 303, 307, or 308",
282 ));
283 }
284 Ok(Self { status, location })
285 }
286
287 pub fn validate(&self) -> SoapResult<()> {
289 if matches!(
290 self.status,
291 StatusCode::MOVED_PERMANENTLY
292 | StatusCode::FOUND
293 | StatusCode::SEE_OTHER
294 | StatusCode::TEMPORARY_REDIRECT
295 | StatusCode::PERMANENT_REDIRECT
296 ) {
297 Ok(())
298 } else {
299 Err(SoapError::validation(
300 "redirect status must be 301, 302, 303, 307, or 308",
301 ))
302 }
303 }
304}
305
306#[derive(Clone, Default)]
308pub struct HttpResponseEffects {
309 pub status: Option<StatusCode>,
311 pub headers: HeaderMap,
313 pub cookies: Vec<ResponseCookie>,
315 pub redirect: Option<Redirect>,
317}
318
319impl fmt::Debug for HttpResponseEffects {
320 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
321 formatter
322 .debug_struct("HttpResponseEffects")
323 .field("status", &self.status)
324 .field("header_names", &self.headers.keys().collect::<Vec<_>>())
325 .field("cookies", &self.cookies)
326 .field("redirect", &self.redirect)
327 .finish_non_exhaustive()
328 }
329}
330
331impl HttpResponseEffects {
332 pub fn new() -> Self {
334 Self::default()
335 }
336
337 #[must_use]
339 pub const fn status(mut self, status: StatusCode) -> Self {
340 self.status = Some(status);
341 self
342 }
343
344 #[must_use]
346 pub fn header(mut self, name: HeaderName, value: HeaderValue) -> Self {
347 self.headers.insert(name, value);
348 self
349 }
350
351 #[must_use]
353 pub fn append_header(mut self, name: HeaderName, value: HeaderValue) -> Self {
354 self.headers.append(name, value);
355 self
356 }
357
358 pub fn cookie(mut self, cookie: ResponseCookie) -> SoapResult<Self> {
360 cookie.validate()?;
361 self.cookies.push(cookie);
362 Ok(self)
363 }
364
365 pub fn challenge(mut self, challenge: &AuthChallenge) -> SoapResult<Self> {
367 self.headers
368 .append(WWW_AUTHENTICATE, challenge.to_header_value()?);
369 Ok(self)
370 }
371
372 pub fn redirect(mut self, redirect: Redirect) -> SoapResult<Self> {
374 redirect.validate()?;
375 let location = HeaderValue::from_str(&redirect.location.to_string())
376 .map_err(|_| SoapError::validation("redirect URI cannot be encoded"))?;
377 self.status = Some(redirect.status);
378 self.headers.insert(LOCATION, location);
379 self.redirect = Some(redirect);
380 Ok(self)
381 }
382
383 pub fn validate(&self) -> SoapResult<()> {
385 self.cookies.iter().try_for_each(ResponseCookie::validate)?;
386 if let Some(redirect) = &self.redirect {
387 redirect.validate()?;
388 if self.status != Some(redirect.status) {
389 return Err(SoapError::validation(
390 "redirect effect status does not match redirect status",
391 ));
392 }
393 let expected = HeaderValue::from_str(&redirect.location.to_string())
394 .map_err(|_| SoapError::validation("redirect URI cannot be encoded"))?;
395 if self.headers.get(LOCATION) != Some(&expected) {
396 return Err(SoapError::validation(
397 "redirect effect is missing its matching Location header",
398 ));
399 }
400 }
401 Ok(())
402 }
403}
404
405fn validate_cookie(name: &str, value: &str) -> SoapResult<()> {
406 if !valid_token(name) || !value.bytes().all(valid_cookie_value_byte) {
407 return Err(SoapError::validation("invalid HTTP cookie name or value"));
408 }
409 Ok(())
410}
411
412fn valid_cookie_value_byte(byte: u8) -> bool {
413 matches!(byte, 0x21 | 0x23..=0x2b | 0x2d..=0x3a | 0x3c..=0x5b | 0x5d..=0x7e)
414}
415
416fn validate_cookie_domain(domain: &str) -> SoapResult<()> {
417 let domain = domain.strip_prefix('.').unwrap_or(domain);
418 if domain.is_empty()
419 || domain.contains("..")
420 || domain.split('.').any(|label| {
421 label.is_empty()
422 || label.starts_with('-')
423 || label.ends_with('-')
424 || !label
425 .bytes()
426 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
427 })
428 {
429 return Err(SoapError::validation("invalid cookie domain"));
430 }
431 Ok(())
432}
433
434fn valid_token(value: &str) -> bool {
435 !value.is_empty()
436 && value.chars().all(|character| {
437 character.is_ascii_alphanumeric()
438 || matches!(
439 character,
440 '!' | '#'
441 | '$'
442 | '%'
443 | '&'
444 | '\''
445 | '*'
446 | '+'
447 | '-'
448 | '.'
449 | '^'
450 | '_'
451 | '`'
452 | '|'
453 | '~'
454 )
455 })
456}
457
458#[cfg(test)]
459mod tests {
460 use http::{StatusCode, Uri, header::WWW_AUTHENTICATE};
461
462 use super::{AuthChallenge, HttpResponseEffects, Redirect, ResponseCookie, SameSite};
463
464 #[test]
465 fn auth_challenges_are_structured_and_escape_quoted_values() {
466 let challenge = AuthChallenge::new("Bearer")
467 .and_then(|value| value.realm("api\"users"))
468 .and_then(|value| value.parameter("error", "invalid_token"));
469 let Some(challenge) = challenge.ok() else {
470 panic!("valid challenge");
471 };
472 let effects = HttpResponseEffects::new().challenge(&challenge);
473 let encoded = effects.ok().and_then(|value| {
474 value
475 .headers
476 .get(WWW_AUTHENTICATE)
477 .and_then(|header| header.to_str().ok())
478 .map(str::to_owned)
479 });
480 assert_eq!(
481 encoded.as_deref(),
482 Some("Bearer realm=\"api\\\"users\", error=\"invalid_token\"")
483 );
484 }
485
486 #[test]
487 fn cookies_use_secure_defaults_and_reject_insecure_same_site_none() {
488 let cookie = ResponseCookie::new("access_token", "opaque");
489 assert_eq!(
490 cookie
491 .as_ref()
492 .ok()
493 .map(|value| (value.secure, value.http_only, value.same_site)),
494 Some((true, true, Some(SameSite::Lax)))
495 );
496 assert!(
497 cookie
498 .and_then(ResponseCookie::insecure)
499 .and_then(|value| value.same_site(SameSite::None))
500 .is_err()
501 );
502 assert_eq!(
503 ResponseCookie::remove("access_token")
504 .ok()
505 .and_then(|value| value.max_age),
506 Some(std::time::Duration::ZERO)
507 );
508 }
509
510 #[test]
511 fn redirects_require_redirect_status_and_emit_location() {
512 let location = Uri::from_static("/login");
513 assert!(Redirect::new(StatusCode::OK, location.clone()).is_err());
514 let redirect = Redirect::new(StatusCode::SEE_OTHER, location);
515 assert!(
516 redirect
517 .and_then(|value| HttpResponseEffects::new().redirect(value))
518 .is_ok()
519 );
520 }
521
522 #[test]
523 fn response_effects_revalidate_public_cookie_and_redirect_fields() {
524 let Some(mut cookie) = ResponseCookie::new("session", "opaque").ok() else {
525 panic!("valid cookie fixture");
526 };
527 cookie.value = "invalid value".to_owned();
528 assert!(HttpResponseEffects::new().cookie(cookie.clone()).is_err());
529
530 let mut effects = HttpResponseEffects::new();
531 effects.cookies.push(cookie);
532 assert!(effects.validate().is_err());
533
534 let Some(mut redirect) =
535 Redirect::new(StatusCode::SEE_OTHER, Uri::from_static("/login")).ok()
536 else {
537 panic!("valid redirect fixture");
538 };
539 redirect.status = StatusCode::OK;
540 assert!(HttpResponseEffects::new().redirect(redirect).is_err());
541 }
542
543 #[test]
544 fn response_debug_output_redacts_cookie_and_header_values() {
545 let Some(cookie) = ResponseCookie::new("session", "cookie-secret").ok() else {
546 panic!("valid cookie fixture");
547 };
548 let effects = HttpResponseEffects::new()
549 .header(
550 http::header::AUTHORIZATION,
551 http::HeaderValue::from_static("Bearer header-secret"),
552 )
553 .cookie(cookie);
554 let Some(effects) = effects.ok() else {
555 panic!("valid response effects");
556 };
557 let debug = format!("{effects:?}");
558
559 assert!(debug.contains("authorization"));
560 assert!(debug.contains("session"));
561 assert!(!debug.contains("header-secret"));
562 assert!(!debug.contains("cookie-secret"));
563 }
564}