use std::collections::{BTreeMap, BTreeSet};
use std::fmt::{Debug, Display, Formatter};
use crate::{Form, FormSubmitError, IndexUrl, Origin, SessionId};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthError {
MissingOrigin,
OriginDenied(Origin),
InsecureCookieOrigin(Origin),
Storage(String),
Form(FormSubmitError),
}
impl Display for AuthError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingOrigin => f.write_str("authentication requires a URL origin"),
Self::OriginDenied(origin) => {
write!(f, "origin is outside auth session scope: {origin}")
}
Self::InsecureCookieOrigin(origin) => {
write!(
f,
"secure cookie cannot be stored for insecure origin: {origin}"
)
}
Self::Storage(reason) => write!(f, "secure storage failed: {reason}"),
Self::Form(error) => write!(f, "login form submission failed: {error}"),
}
}
}
impl std::error::Error for AuthError {}
#[derive(Clone, PartialEq, Eq)]
pub struct Cookie {
pub name: String,
value: String,
pub http_only: bool,
pub secure: bool,
}
impl Cookie {
#[must_use]
pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
Self {
name: name.into(),
value: value.into(),
http_only: true,
secure: true,
}
}
#[must_use]
pub fn value(&self) -> &str {
&self.value
}
fn serialized(&self) -> String {
format!(
"{}={}; HttpOnly={}; Secure={}",
escape_field(&self.name),
escape_field(&self.value),
self.http_only,
self.secure
)
}
}
impl Debug for Cookie {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Cookie")
.field("name", &self.name)
.field("value", &"[REDACTED]")
.field("http_only", &self.http_only)
.field("secure", &self.secure)
.finish()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CookieJar {
cookies: BTreeMap<Origin, BTreeMap<String, Cookie>>,
}
impl CookieJar {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn set(&mut self, url: &IndexUrl, cookie: Cookie) -> Result<(), AuthError> {
let origin = url.origin().ok_or(AuthError::MissingOrigin)?;
if cookie.secure && !origin.as_str().starts_with("https://") {
return Err(AuthError::InsecureCookieOrigin(origin));
}
self.cookies
.entry(origin)
.or_default()
.insert(cookie.name.clone(), cookie);
Ok(())
}
#[must_use]
pub fn get(&self, url: &IndexUrl, name: &str) -> Option<&Cookie> {
self.cookies.get(&url.origin()?)?.get(name)
}
#[must_use]
pub fn header_for(&self, url: &IndexUrl) -> Option<String> {
let origin = url.origin()?;
let cookies = self.cookies.get(&origin)?;
let header = cookies
.values()
.map(|cookie| format!("{}={}", cookie.name, cookie.value()))
.collect::<Vec<_>>()
.join("; ");
(!header.is_empty()).then_some(header)
}
pub fn clear_origin(&mut self, origin: &Origin) {
self.cookies.remove(origin);
}
pub fn clear(&mut self) {
self.cookies.clear();
}
pub fn save(&self, storage: &mut dyn SecureStorage, key: &str) -> Result<(), AuthError> {
storage
.store(key, self.serialize().as_bytes())
.map_err(AuthError::Storage)
}
pub fn load(storage: &dyn SecureStorage, key: &str) -> Result<Self, AuthError> {
let Some(bytes) = storage.load(key).map_err(AuthError::Storage)? else {
return Ok(Self::new());
};
let contents =
String::from_utf8(bytes).map_err(|error| AuthError::Storage(error.to_string()))?;
Self::deserialize(&contents)
}
fn serialize(&self) -> String {
let mut lines = vec!["index-cookies-v1".to_owned()];
for (origin, cookies) in &self.cookies {
for cookie in cookies.values() {
lines.push(format!(
"{}\t{}",
escape_field(origin.as_str()),
cookie.serialized()
));
}
}
lines.join("\n")
}
fn deserialize(contents: &str) -> Result<Self, AuthError> {
let mut lines = contents.lines();
if lines.next() != Some("index-cookies-v1") {
return Err(AuthError::Storage("missing cookie jar header".to_owned()));
}
let mut jar = Self::new();
for line in lines {
let fields = line.split('\t').collect::<Vec<_>>();
if fields.len() != 2 {
return Err(AuthError::Storage("invalid cookie record".to_owned()));
}
let origin = Origin::from_stored(unescape_field(fields[0])?);
let cookie = parse_cookie(fields[1])?;
jar.cookies
.entry(origin)
.or_default()
.insert(cookie.name.clone(), cookie);
}
Ok(jar)
}
}
pub trait SecureStorage {
fn store(&mut self, key: &str, value: &[u8]) -> Result<(), String>;
fn load(&self, key: &str) -> Result<Option<Vec<u8>>, String>;
fn delete(&mut self, key: &str) -> Result<(), String>;
}
#[derive(Debug, Clone, Default)]
pub struct MemorySecureStorage {
values: BTreeMap<String, Vec<u8>>,
}
impl MemorySecureStorage {
#[must_use]
pub fn new() -> Self {
Self::default()
}
}
impl SecureStorage for MemorySecureStorage {
fn store(&mut self, key: &str, value: &[u8]) -> Result<(), String> {
self.values.insert(key.to_owned(), value.to_vec());
Ok(())
}
fn load(&self, key: &str) -> Result<Option<Vec<u8>>, String> {
Ok(self.values.get(key).cloned())
}
fn delete(&mut self, key: &str) -> Result<(), String> {
self.values.remove(key);
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SessionScope {
Origin(Origin),
Origins(BTreeSet<Origin>),
}
impl SessionScope {
#[must_use]
pub fn allows(&self, origin: &Origin) -> bool {
match self {
Self::Origin(allowed) => allowed == origin,
Self::Origins(origins) => origins.contains(origin),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthSession {
pub id: SessionId,
pub scope: SessionScope,
pub cookies: CookieJar,
}
impl AuthSession {
#[must_use]
pub fn new(id: SessionId, scope: SessionScope) -> Self {
Self {
id,
scope,
cookies: CookieJar::new(),
}
}
pub fn set_cookie(&mut self, url: &IndexUrl, cookie: Cookie) -> Result<(), AuthError> {
let origin = url.origin().ok_or(AuthError::MissingOrigin)?;
if !self.scope.allows(&origin) {
return Err(AuthError::OriginDenied(origin));
}
self.cookies.set(url, cookie)
}
pub fn logout(&mut self) {
self.cookies.clear();
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OriginPolicy {
allowed: BTreeSet<Origin>,
}
impl OriginPolicy {
#[must_use]
pub fn new(allowed: impl IntoIterator<Item = Origin>) -> Self {
Self {
allowed: allowed.into_iter().collect(),
}
}
pub fn check(&self, url: &IndexUrl) -> Result<(), AuthError> {
let origin = url.origin().ok_or(AuthError::MissingOrigin)?;
if self.allowed.contains(&origin) {
Ok(())
} else {
Err(AuthError::OriginDenied(origin))
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoginFlow {
pub form: Form,
pub base_url: IndexUrl,
}
impl LoginFlow {
pub fn submit(
&self,
policy: &OriginPolicy,
values: &[(&str, &str)],
) -> Result<crate::FormSubmission, AuthError> {
policy.check(&self.base_url)?;
let submission = self
.form
.submit(Some(&self.base_url), values)
.map_err(AuthError::Form)?;
policy.check(&submission.action)?;
Ok(submission)
}
}
#[derive(Debug, Clone, Default)]
pub struct Redactor {
secrets: Vec<String>,
}
impl Redactor {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn add_secret(&mut self, secret: impl Into<String>) {
let secret = secret.into();
if !secret.is_empty() {
self.secrets.push(secret);
}
}
#[must_use]
pub fn redact(&self, input: &str) -> String {
let mut output = redact_known_fields(input);
for secret in &self.secrets {
output = output.replace(secret, "[REDACTED]");
}
output
}
}
fn parse_cookie(input: &str) -> Result<Cookie, AuthError> {
let mut parts = input.split("; ");
let Some(pair) = parts.next() else {
return Err(AuthError::Storage("missing cookie pair".to_owned()));
};
let Some((name, value)) = pair.split_once('=') else {
return Err(AuthError::Storage("invalid cookie pair".to_owned()));
};
let mut cookie = Cookie::new(unescape_field(name)?, unescape_field(value)?);
for part in parts {
if let Some(value) = part.strip_prefix("HttpOnly=") {
cookie.http_only = value == "true";
} else if let Some(value) = part.strip_prefix("Secure=") {
cookie.secure = value == "true";
}
}
Ok(cookie)
}
fn redact_known_fields(input: &str) -> String {
let mut output = Vec::new();
let mut redact_next = false;
for part in input.split_whitespace() {
let lower = part.to_ascii_lowercase();
if redact_next {
output.push("[REDACTED]".to_owned());
redact_next = lower == "bearer" || lower == "basic";
continue;
}
if lower.starts_with("authorization:")
|| lower.starts_with("cookie:")
|| lower.starts_with("set-cookie:")
{
output.push("[REDACTED]".to_owned());
redact_next = true;
} else if lower.starts_with("token=") || lower.starts_with("password=") {
output.push("[REDACTED]".to_owned());
} else {
output.push(part.to_owned());
}
}
output.join(" ")
}
fn escape_field(input: &str) -> String {
input
.replace('\\', "\\\\")
.replace('\t', "\\t")
.replace('\n', "\\n")
}
fn unescape_field(input: &str) -> Result<String, AuthError> {
let mut out = String::new();
let mut chars = input.chars();
while let Some(ch) = chars.next() {
if ch != '\\' {
out.push(ch);
continue;
}
let Some(next) = chars.next() else {
return Err(AuthError::Storage("dangling escape".to_owned()));
};
match next {
'\\' => out.push('\\'),
't' => out.push('\t'),
'n' => out.push('\n'),
other => return Err(AuthError::Storage(format!("unknown escape: {other}"))),
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::{
AuthError, AuthSession, Cookie, CookieJar, LoginFlow, MemorySecureStorage, OriginPolicy,
Redactor, SecureStorage, SessionScope,
};
use crate::{Form, IndexUrl, Input, Origin, SessionId};
#[test]
fn cookies_persist_through_secure_storage() -> Result<(), Box<dyn std::error::Error>> {
let url = IndexUrl::parse("https://example.com/account")?;
let mut jar = CookieJar::new();
jar.set(&url, Cookie::new("sid", "secret"))?;
let mut storage = MemorySecureStorage::new();
jar.save(&mut storage, "cookies")?;
let restored = CookieJar::load(&storage, "cookies")?;
assert_eq!(restored.header_for(&url).as_deref(), Some("sid=secret"));
Ok(())
}
#[test]
fn cookies_are_isolated_by_origin() -> Result<(), Box<dyn std::error::Error>> {
let first = IndexUrl::parse("https://example.com/account")?;
let second = IndexUrl::parse("https://other.example/account")?;
let mut jar = CookieJar::new();
jar.set(&first, Cookie::new("sid", "secret"))?;
assert_eq!(jar.header_for(&first).as_deref(), Some("sid=secret"));
assert_eq!(jar.header_for(&second), None);
Ok(())
}
#[test]
fn logout_clears_session_cookies() -> Result<(), Box<dyn std::error::Error>> {
let url = IndexUrl::parse("https://example.com/account")?;
let scope = SessionScope::Origin(Origin::from_stored("https://example.com"));
let mut session = AuthSession::new(SessionId::new("auth"), scope);
session.set_cookie(&url, Cookie::new("sid", "secret"))?;
session.logout();
assert_eq!(session.cookies.header_for(&url), None);
Ok(())
}
#[test]
fn auth_session_rejects_out_of_scope_cookie() -> Result<(), Box<dyn std::error::Error>> {
let url = IndexUrl::parse("https://other.example/account")?;
let scope = SessionScope::Origin(Origin::from_stored("https://example.com"));
let mut session = AuthSession::new(SessionId::new("auth"), scope);
assert_eq!(
session.set_cookie(&url, Cookie::new("sid", "secret")),
Err(AuthError::OriginDenied(Origin::from_stored(
"https://other.example"
)))
);
Ok(())
}
#[test]
fn secure_cookies_require_https() -> Result<(), Box<dyn std::error::Error>> {
let url = IndexUrl::parse("http://example.com/account")?;
let mut jar = CookieJar::new();
assert_eq!(
jar.set(&url, Cookie::new("sid", "secret")),
Err(AuthError::InsecureCookieOrigin(Origin::from_stored(
"http://example.com"
)))
);
Ok(())
}
#[test]
fn login_flow_resolves_form_inside_origin_policy() -> Result<(), Box<dyn std::error::Error>> {
let flow = LoginFlow {
base_url: IndexUrl::parse("https://example.com/login")?,
form: Form {
name: "login".to_owned(),
method: "POST".to_owned(),
action: "/session".to_owned(),
inputs: vec![Input {
name: "user".to_owned(),
kind: "text".to_owned(),
value: None,
required: true,
}],
buttons: Vec::new(),
},
};
let policy = OriginPolicy::new([Origin::from_stored("https://example.com")]);
let submission = flow.submit(&policy, &[("user", "ada")])?;
assert_eq!(submission.action.as_str(), "https://example.com/session");
assert_eq!(submission.body.as_deref(), Some("user=ada"));
Ok(())
}
#[test]
fn login_flow_rejects_cross_origin_action() -> Result<(), Box<dyn std::error::Error>> {
let flow = LoginFlow {
base_url: IndexUrl::parse("https://example.com/login")?,
form: Form {
name: "login".to_owned(),
method: "POST".to_owned(),
action: "https://evil.example/session".to_owned(),
inputs: Vec::new(),
buttons: Vec::new(),
},
};
let policy = OriginPolicy::new([Origin::from_stored("https://example.com")]);
assert_eq!(
flow.submit(&policy, &[]),
Err(AuthError::OriginDenied(Origin::from_stored(
"https://evil.example"
)))
);
Ok(())
}
#[test]
fn redactor_removes_cookie_tokens_and_known_secrets() {
let mut redactor = Redactor::new();
redactor.add_secret("abc123");
let output =
redactor.redact("Authorization: Bearer abc123 token=abc123 Cookie: sid=abc123");
assert!(!output.contains("abc123"));
assert!(!output.contains("Bearer"));
assert!(output.contains("[REDACTED]"));
}
#[test]
fn cookie_debug_does_not_leak_secret_value() {
let cookie = Cookie::new("sid", "abc123");
let rendered = format!("{cookie:?}");
assert!(rendered.contains("sid"));
assert!(!rendered.contains("abc123"));
}
#[test]
fn secure_storage_delete_removes_value() -> Result<(), Box<dyn std::error::Error>> {
let mut storage = MemorySecureStorage::new();
storage.store("key", b"value")?;
storage.delete("key")?;
assert_eq!(storage.load("key")?, None);
Ok(())
}
}