use cookie::{Cookie, SameSite};
use http::{HeaderMap, HeaderValue};
pub const HOST_PREFIX: &str = "__Host-";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CookieNames {
base: String,
}
impl CookieNames {
#[must_use]
pub fn for_console(base: impl Into<String>) -> Self {
Self { base: base.into() }
}
#[must_use]
pub fn session(&self) -> String {
format!("{}_session", self.base)
}
#[must_use]
pub fn sign_in(&self) -> String {
format!("{}_sign_in", self.base)
}
#[must_use]
pub fn preference(&self, name: &str) -> String {
format!("{}_{name}", self.base)
}
}
#[derive(Debug, Clone)]
pub(crate) struct Jar {
pub(crate) names: CookieNames,
pub(crate) secure: bool,
}
impl Jar {
pub(crate) fn name(&self, base: &str) -> String {
if self.secure {
format!("{HOST_PREFIX}{base}")
} else {
base.to_owned()
}
}
pub(crate) fn read(&self, headers: &HeaderMap, base: &str) -> Option<String> {
let name = self.name(base);
headers
.get_all(http::header::COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.flat_map(Cookie::split_parse)
.filter_map(Result::ok)
.find(|cookie| cookie.name() == name)
.map(|cookie| cookie.value().to_owned())
}
pub(crate) fn cookie(
&self,
base: &str,
value: String,
max_age: Option<i64>,
) -> Cookie<'static> {
let mut builder = Cookie::build((self.name(base), value))
.path("/")
.http_only(true)
.secure(self.secure)
.same_site(SameSite::Lax);
if let Some(seconds) = max_age {
builder = builder.max_age(cookie::time::Duration::seconds(seconds.max(0)));
}
builder.build()
}
pub(crate) fn removal(&self, base: &str) -> Cookie<'static> {
self.cookie(base, String::new(), Some(0))
}
}
pub(crate) fn header(cookie: &Cookie<'_>) -> Result<HeaderValue, crate::sessions::SessionError> {
HeaderValue::from_str(&cookie.to_string())
.map_err(|_invalid| crate::sessions::SessionError::Header)
}
#[cfg(test)]
mod tests {
use http::HeaderMap;
use http::header::COOKIE;
use super::{CookieNames, Jar};
fn jar(secure: bool) -> Jar {
Jar {
names: CookieNames::for_console("ferrobridge_console"),
secure,
}
}
#[test]
fn a_secure_cookie_is_host_bound_http_only_and_lax() {
let jar = jar(true);
assert_eq!(
"__Host-ferrobridge_console_session=abc; HttpOnly; SameSite=Lax; Secure; Path=/",
jar.cookie(&jar.names.session(), String::from("abc"), None)
.to_string(),
"RFC 6265bis §4.1.3.2 and §5.4.7"
);
assert_eq!(
"__Host-ferrobridge_console_sign_in=; HttpOnly; SameSite=Lax; Secure; Path=/; Max-Age=0",
jar.removal(&jar.names.sign_in()).to_string(),
"a removal expires at once"
);
}
#[test]
fn a_cookie_that_is_not_secure_carries_no_host_prefix() {
let jar = jar(false);
assert_eq!(
"ferrobridge_console_theme=dark; HttpOnly; SameSite=Lax; Path=/; Max-Age=60",
jar.cookie(
&jar.names.preference("theme"),
String::from("dark"),
Some(60)
)
.to_string(),
"a browser refuses __Host- without Secure"
);
}
#[test]
fn the_read_finds_its_own_cookie_among_others_and_skips_garbage() {
let jar = jar(true);
let mut headers = HeaderMap::new();
headers.append(COOKIE, "junk; other=1".parse().expect("a header"));
headers.append(
COOKIE,
"ferrobridge_console_session=plain; __Host-ferrobridge_console_session=bound"
.parse()
.expect("a header"),
);
assert_eq!(
Some(String::from("bound")),
jar.read(&headers, &jar.names.session()),
"host-bound only"
);
assert_eq!(None, jar.read(&headers, &jar.names.sign_in()), "absent");
}
}