use std::{fmt, time::Duration};
use http::{
HeaderMap, HeaderName, HeaderValue, StatusCode, Uri,
header::{LOCATION, WWW_AUTHENTICATE},
};
use soaprs_core::{SoapError, SoapResult};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SameSite {
Strict,
Lax,
None,
}
#[derive(Clone, PartialEq, Eq)]
pub struct ResponseCookie {
pub name: String,
pub value: String,
pub path: Option<String>,
pub domain: Option<String>,
pub max_age: Option<Duration>,
pub secure: bool,
pub http_only: bool,
pub same_site: Option<SameSite>,
}
impl fmt::Debug for ResponseCookie {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ResponseCookie")
.field("name", &self.name)
.field("value", &"[REDACTED]")
.field("path", &self.path)
.field("domain", &self.domain)
.field("max_age", &self.max_age)
.field("secure", &self.secure)
.field("http_only", &self.http_only)
.field("same_site", &self.same_site)
.finish()
}
}
impl ResponseCookie {
pub fn new(name: impl Into<String>, value: impl Into<String>) -> SoapResult<Self> {
let name = name.into();
let value = value.into();
validate_cookie(&name, &value)?;
Ok(Self {
name,
value,
path: Some("/".to_owned()),
domain: None,
max_age: None,
secure: true,
http_only: true,
same_site: Some(SameSite::Lax),
})
}
pub fn remove(name: impl Into<String>) -> SoapResult<Self> {
let mut cookie = Self::new(name, "")?;
cookie.max_age = Some(Duration::ZERO);
Ok(cookie)
}
pub fn path(mut self, path: impl Into<String>) -> SoapResult<Self> {
let path = path.into();
if !path.starts_with('/') || path.chars().any(char::is_control) || path.contains(';') {
return Err(SoapError::validation("invalid cookie path"));
}
self.path = Some(path);
Ok(self)
}
pub fn domain(mut self, domain: impl Into<String>) -> SoapResult<Self> {
let domain = domain.into();
validate_cookie_domain(&domain)?;
self.domain = Some(domain);
Ok(self)
}
#[must_use]
pub const fn max_age(mut self, max_age: Duration) -> Self {
self.max_age = Some(max_age);
self
}
pub fn same_site(mut self, same_site: SameSite) -> SoapResult<Self> {
if same_site == SameSite::None && !self.secure {
return Err(SoapError::validation(
"SameSite=None cookies must be secure",
));
}
self.same_site = Some(same_site);
Ok(self)
}
pub fn insecure(mut self) -> SoapResult<Self> {
if self.same_site == Some(SameSite::None) {
return Err(SoapError::validation(
"SameSite=None cookies must be secure",
));
}
self.secure = false;
Ok(self)
}
#[must_use]
pub const fn script_accessible(mut self) -> Self {
self.http_only = false;
self
}
pub fn validate(&self) -> SoapResult<()> {
validate_cookie(&self.name, &self.value)?;
if self.path.as_ref().is_some_and(|path| {
!path.starts_with('/') || path.chars().any(char::is_control) || path.contains(';')
}) {
return Err(SoapError::validation("invalid cookie path"));
}
if let Some(domain) = &self.domain {
validate_cookie_domain(domain)?;
}
if self.same_site == Some(SameSite::None) && !self.secure {
return Err(SoapError::validation(
"SameSite=None cookies must be secure",
));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthChallenge {
scheme: String,
parameters: Vec<(String, String)>,
}
impl AuthChallenge {
pub fn new(scheme: impl Into<String>) -> SoapResult<Self> {
let scheme = scheme.into();
if !valid_token(&scheme) {
return Err(SoapError::validation(format!(
"invalid authentication scheme `{scheme}`"
)));
}
Ok(Self {
scheme,
parameters: Vec::new(),
})
}
pub fn realm(self, realm: impl Into<String>) -> SoapResult<Self> {
self.parameter("realm", realm)
}
pub fn parameter(
mut self,
name: impl Into<String>,
value: impl Into<String>,
) -> SoapResult<Self> {
let name = name.into();
let value = value.into();
if !valid_token(&name) || value.chars().any(char::is_control) {
return Err(SoapError::validation(
"invalid authentication challenge parameter",
));
}
if let Some(existing) = self
.parameters
.iter_mut()
.find(|(existing, _)| existing.eq_ignore_ascii_case(&name))
{
*existing = (name, value);
} else {
self.parameters.push((name, value));
}
Ok(self)
}
pub fn scheme(&self) -> &str {
&self.scheme
}
pub fn to_header_value(&self) -> SoapResult<HeaderValue> {
let mut encoded = self.scheme.clone();
for (index, (name, value)) in self.parameters.iter().enumerate() {
if index == 0 {
encoded.push(' ');
} else {
encoded.push_str(", ");
}
encoded.push_str(name);
encoded.push_str("=\"");
for character in value.chars() {
if matches!(character, '\\' | '"') {
encoded.push('\\');
}
encoded.push(character);
}
encoded.push('"');
}
HeaderValue::from_str(&encoded)
.map_err(|_| SoapError::validation("authentication challenge cannot be encoded"))
}
}
impl fmt::Display for AuthChallenge {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.to_header_value() {
Ok(value) => formatter.write_str(value.to_str().unwrap_or(self.scheme())),
Err(_) => formatter.write_str(self.scheme()),
}
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct Redirect {
pub status: StatusCode,
pub location: Uri,
}
impl fmt::Debug for Redirect {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("Redirect")
.field("status", &self.status)
.field("location", &"[REDACTED]")
.finish()
}
}
impl Redirect {
pub fn new(status: StatusCode, location: Uri) -> SoapResult<Self> {
if !matches!(
status,
StatusCode::MOVED_PERMANENTLY
| StatusCode::FOUND
| StatusCode::SEE_OTHER
| StatusCode::TEMPORARY_REDIRECT
| StatusCode::PERMANENT_REDIRECT
) {
return Err(SoapError::validation(
"redirect status must be 301, 302, 303, 307, or 308",
));
}
Ok(Self { status, location })
}
pub fn validate(&self) -> SoapResult<()> {
if matches!(
self.status,
StatusCode::MOVED_PERMANENTLY
| StatusCode::FOUND
| StatusCode::SEE_OTHER
| StatusCode::TEMPORARY_REDIRECT
| StatusCode::PERMANENT_REDIRECT
) {
Ok(())
} else {
Err(SoapError::validation(
"redirect status must be 301, 302, 303, 307, or 308",
))
}
}
}
#[derive(Clone, Default)]
pub struct HttpResponseEffects {
pub status: Option<StatusCode>,
pub headers: HeaderMap,
pub cookies: Vec<ResponseCookie>,
pub redirect: Option<Redirect>,
}
impl fmt::Debug for HttpResponseEffects {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("HttpResponseEffects")
.field("status", &self.status)
.field("header_names", &self.headers.keys().collect::<Vec<_>>())
.field("cookies", &self.cookies)
.field("redirect", &self.redirect)
.finish_non_exhaustive()
}
}
impl HttpResponseEffects {
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub const fn status(mut self, status: StatusCode) -> Self {
self.status = Some(status);
self
}
#[must_use]
pub fn header(mut self, name: HeaderName, value: HeaderValue) -> Self {
self.headers.insert(name, value);
self
}
#[must_use]
pub fn append_header(mut self, name: HeaderName, value: HeaderValue) -> Self {
self.headers.append(name, value);
self
}
pub fn cookie(mut self, cookie: ResponseCookie) -> SoapResult<Self> {
cookie.validate()?;
self.cookies.push(cookie);
Ok(self)
}
pub fn challenge(mut self, challenge: &AuthChallenge) -> SoapResult<Self> {
self.headers
.append(WWW_AUTHENTICATE, challenge.to_header_value()?);
Ok(self)
}
pub fn redirect(mut self, redirect: Redirect) -> SoapResult<Self> {
redirect.validate()?;
let location = HeaderValue::from_str(&redirect.location.to_string())
.map_err(|_| SoapError::validation("redirect URI cannot be encoded"))?;
self.status = Some(redirect.status);
self.headers.insert(LOCATION, location);
self.redirect = Some(redirect);
Ok(self)
}
pub fn validate(&self) -> SoapResult<()> {
self.cookies.iter().try_for_each(ResponseCookie::validate)?;
if let Some(redirect) = &self.redirect {
redirect.validate()?;
if self.status != Some(redirect.status) {
return Err(SoapError::validation(
"redirect effect status does not match redirect status",
));
}
let expected = HeaderValue::from_str(&redirect.location.to_string())
.map_err(|_| SoapError::validation("redirect URI cannot be encoded"))?;
if self.headers.get(LOCATION) != Some(&expected) {
return Err(SoapError::validation(
"redirect effect is missing its matching Location header",
));
}
}
Ok(())
}
}
fn validate_cookie(name: &str, value: &str) -> SoapResult<()> {
if !valid_token(name) || !value.bytes().all(valid_cookie_value_byte) {
return Err(SoapError::validation("invalid HTTP cookie name or value"));
}
Ok(())
}
fn valid_cookie_value_byte(byte: u8) -> bool {
matches!(byte, 0x21 | 0x23..=0x2b | 0x2d..=0x3a | 0x3c..=0x5b | 0x5d..=0x7e)
}
fn validate_cookie_domain(domain: &str) -> SoapResult<()> {
let domain = domain.strip_prefix('.').unwrap_or(domain);
if domain.is_empty()
|| domain.contains("..")
|| domain.split('.').any(|label| {
label.is_empty()
|| label.starts_with('-')
|| label.ends_with('-')
|| !label
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
})
{
return Err(SoapError::validation("invalid cookie domain"));
}
Ok(())
}
fn valid_token(value: &str) -> bool {
!value.is_empty()
&& value.chars().all(|character| {
character.is_ascii_alphanumeric()
|| matches!(
character,
'!' | '#'
| '$'
| '%'
| '&'
| '\''
| '*'
| '+'
| '-'
| '.'
| '^'
| '_'
| '`'
| '|'
| '~'
)
})
}
#[cfg(test)]
mod tests {
use http::{StatusCode, Uri, header::WWW_AUTHENTICATE};
use super::{AuthChallenge, HttpResponseEffects, Redirect, ResponseCookie, SameSite};
#[test]
fn auth_challenges_are_structured_and_escape_quoted_values() {
let challenge = AuthChallenge::new("Bearer")
.and_then(|value| value.realm("api\"users"))
.and_then(|value| value.parameter("error", "invalid_token"));
let Some(challenge) = challenge.ok() else {
panic!("valid challenge");
};
let effects = HttpResponseEffects::new().challenge(&challenge);
let encoded = effects.ok().and_then(|value| {
value
.headers
.get(WWW_AUTHENTICATE)
.and_then(|header| header.to_str().ok())
.map(str::to_owned)
});
assert_eq!(
encoded.as_deref(),
Some("Bearer realm=\"api\\\"users\", error=\"invalid_token\"")
);
}
#[test]
fn cookies_use_secure_defaults_and_reject_insecure_same_site_none() {
let cookie = ResponseCookie::new("access_token", "opaque");
assert_eq!(
cookie
.as_ref()
.ok()
.map(|value| (value.secure, value.http_only, value.same_site)),
Some((true, true, Some(SameSite::Lax)))
);
assert!(
cookie
.and_then(ResponseCookie::insecure)
.and_then(|value| value.same_site(SameSite::None))
.is_err()
);
assert_eq!(
ResponseCookie::remove("access_token")
.ok()
.and_then(|value| value.max_age),
Some(std::time::Duration::ZERO)
);
}
#[test]
fn redirects_require_redirect_status_and_emit_location() {
let location = Uri::from_static("/login");
assert!(Redirect::new(StatusCode::OK, location.clone()).is_err());
let redirect = Redirect::new(StatusCode::SEE_OTHER, location);
assert!(
redirect
.and_then(|value| HttpResponseEffects::new().redirect(value))
.is_ok()
);
}
#[test]
fn response_effects_revalidate_public_cookie_and_redirect_fields() {
let Some(mut cookie) = ResponseCookie::new("session", "opaque").ok() else {
panic!("valid cookie fixture");
};
cookie.value = "invalid value".to_owned();
assert!(HttpResponseEffects::new().cookie(cookie.clone()).is_err());
let mut effects = HttpResponseEffects::new();
effects.cookies.push(cookie);
assert!(effects.validate().is_err());
let Some(mut redirect) =
Redirect::new(StatusCode::SEE_OTHER, Uri::from_static("/login")).ok()
else {
panic!("valid redirect fixture");
};
redirect.status = StatusCode::OK;
assert!(HttpResponseEffects::new().redirect(redirect).is_err());
}
#[test]
fn response_debug_output_redacts_cookie_and_header_values() {
let Some(cookie) = ResponseCookie::new("session", "cookie-secret").ok() else {
panic!("valid cookie fixture");
};
let effects = HttpResponseEffects::new()
.header(
http::header::AUTHORIZATION,
http::HeaderValue::from_static("Bearer header-secret"),
)
.cookie(cookie);
let Some(effects) = effects.ok() else {
panic!("valid response effects");
};
let debug = format!("{effects:?}");
assert!(debug.contains("authorization"));
assert!(debug.contains("session"));
assert!(!debug.contains("header-secret"));
assert!(!debug.contains("cookie-secret"));
}
}