Skip to main content

soaprs_http/
response.rs

1//! Framework-neutral response effects used by auth and HTTP extension packages.
2
3use 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/// Browser SameSite cookie behavior.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum SameSite {
14    /// Cookie is withheld from cross-site requests.
15    Strict,
16    /// Cookie is available to safe top-level cross-site navigation.
17    Lax,
18    /// Cookie is available cross-site and therefore must also be secure.
19    None,
20}
21
22/// Transport-neutral cookie mutation returned by HTTP modules.
23#[derive(Clone, PartialEq, Eq)]
24pub struct ResponseCookie {
25    /// Cookie name.
26    pub name: String,
27    /// Opaque cookie value.
28    pub value: String,
29    /// Optional path scope.
30    pub path: Option<String>,
31    /// Optional domain scope.
32    pub domain: Option<String>,
33    /// Optional relative lifetime.
34    pub max_age: Option<Duration>,
35    /// Restricts transmission to secure transports.
36    pub secure: bool,
37    /// Hides the cookie from browser scripting APIs.
38    pub http_only: bool,
39    /// Browser cross-site behavior.
40    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    /// Creates a validated cookie with secure HTTP-only defaults.
61    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    /// Creates a secure cookie deletion effect using `Max-Age=0`.
78    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    /// Sets the cookie path.
85    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    /// Sets the cookie domain.
95    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    /// Sets a relative lifetime. Zero represents immediate deletion.
103    #[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    /// Changes browser cross-site behavior.
110    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    /// Allows transmission over insecure transport for local development.
121    ///
122    /// This is rejected while `SameSite=None` is selected.
123    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    /// Makes the cookie visible to browser scripts.
134    #[must_use]
135    pub const fn script_accessible(mut self) -> Self {
136        self.http_only = false;
137        self
138    }
139
140    /// Validates cookie attributes after direct public-field mutation.
141    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/// Structured `WWW-Authenticate` challenge.
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct AuthChallenge {
163    scheme: String,
164    parameters: Vec<(String, String)>,
165}
166
167impl AuthChallenge {
168    /// Creates a challenge for a validated authentication scheme.
169    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    /// Adds or replaces the challenge realm.
183    pub fn realm(self, realm: impl Into<String>) -> SoapResult<Self> {
184        self.parameter("realm", realm)
185    }
186
187    /// Adds or replaces one quoted challenge parameter.
188    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    /// Returns the authentication scheme.
213    pub fn scheme(&self) -> &str {
214        &self.scheme
215    }
216
217    /// Encodes the challenge as a valid HTTP header value.
218    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/// Validated redirect response effect.
251#[derive(Clone, PartialEq, Eq)]
252pub struct Redirect {
253    /// Redirect status code.
254    pub status: StatusCode,
255    /// Absolute or relative redirect target.
256    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    /// Creates a redirect using one of 301, 302, 303, 307, or 308.
271    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    /// Validates redirect status after direct public-field mutation.
288    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/// Headers, cookies, status, and redirects emitted by a transport-neutral module.
307#[derive(Clone, Default)]
308pub struct HttpResponseEffects {
309    /// Optional response status override.
310    pub status: Option<StatusCode>,
311    /// Response headers to insert or append.
312    pub headers: HeaderMap,
313    /// Structured cookie mutations.
314    pub cookies: Vec<ResponseCookie>,
315    /// Optional redirect instruction.
316    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    /// Creates empty response effects.
333    pub fn new() -> Self {
334        Self::default()
335    }
336
337    /// Overrides the response status.
338    #[must_use]
339    pub const fn status(mut self, status: StatusCode) -> Self {
340        self.status = Some(status);
341        self
342    }
343
344    /// Inserts or replaces one response header.
345    #[must_use]
346    pub fn header(mut self, name: HeaderName, value: HeaderValue) -> Self {
347        self.headers.insert(name, value);
348        self
349    }
350
351    /// Appends a response header without replacing existing values.
352    #[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    /// Appends one structured response cookie.
359    pub fn cookie(mut self, cookie: ResponseCookie) -> SoapResult<Self> {
360        cookie.validate()?;
361        self.cookies.push(cookie);
362        Ok(self)
363    }
364
365    /// Appends one structured authentication challenge.
366    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    /// Applies a validated redirect and matching `Location` header.
373    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    /// Validates structured effects after direct public-field mutation.
384    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}