1use std::collections::{BTreeMap, BTreeSet};
4use std::fmt::{Debug, Display, Formatter};
5
6use crate::{Form, FormSubmitError, IndexUrl, Origin, SessionId};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum AuthError {
11 MissingOrigin,
13 OriginDenied(Origin),
15 InsecureCookieOrigin(Origin),
17 Storage(String),
19 Form(FormSubmitError),
21}
22
23impl Display for AuthError {
24 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
25 match self {
26 Self::MissingOrigin => f.write_str("authentication requires a URL origin"),
27 Self::OriginDenied(origin) => {
28 write!(f, "origin is outside auth session scope: {origin}")
29 }
30 Self::InsecureCookieOrigin(origin) => {
31 write!(
32 f,
33 "secure cookie cannot be stored for insecure origin: {origin}"
34 )
35 }
36 Self::Storage(reason) => write!(f, "secure storage failed: {reason}"),
37 Self::Form(error) => write!(f, "login form submission failed: {error}"),
38 }
39 }
40}
41
42impl std::error::Error for AuthError {}
43
44#[derive(Clone, PartialEq, Eq)]
46pub struct Cookie {
47 pub name: String,
49 value: String,
50 pub http_only: bool,
52 pub secure: bool,
54}
55
56impl Cookie {
57 #[must_use]
59 pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
60 Self {
61 name: name.into(),
62 value: value.into(),
63 http_only: true,
64 secure: true,
65 }
66 }
67
68 #[must_use]
70 pub fn value(&self) -> &str {
71 &self.value
72 }
73
74 fn serialized(&self) -> String {
75 format!(
76 "{}={}; HttpOnly={}; Secure={}",
77 escape_field(&self.name),
78 escape_field(&self.value),
79 self.http_only,
80 self.secure
81 )
82 }
83}
84
85impl Debug for Cookie {
86 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
87 f.debug_struct("Cookie")
88 .field("name", &self.name)
89 .field("value", &"[REDACTED]")
90 .field("http_only", &self.http_only)
91 .field("secure", &self.secure)
92 .finish()
93 }
94}
95
96#[derive(Debug, Clone, Default, PartialEq, Eq)]
98pub struct CookieJar {
99 cookies: BTreeMap<Origin, BTreeMap<String, Cookie>>,
100}
101
102impl CookieJar {
103 #[must_use]
105 pub fn new() -> Self {
106 Self::default()
107 }
108
109 pub fn set(&mut self, url: &IndexUrl, cookie: Cookie) -> Result<(), AuthError> {
111 let origin = url.origin().ok_or(AuthError::MissingOrigin)?;
112 if cookie.secure && !origin.as_str().starts_with("https://") {
113 return Err(AuthError::InsecureCookieOrigin(origin));
114 }
115 self.cookies
116 .entry(origin)
117 .or_default()
118 .insert(cookie.name.clone(), cookie);
119 Ok(())
120 }
121
122 #[must_use]
124 pub fn get(&self, url: &IndexUrl, name: &str) -> Option<&Cookie> {
125 self.cookies.get(&url.origin()?)?.get(name)
126 }
127
128 #[must_use]
130 pub fn header_for(&self, url: &IndexUrl) -> Option<String> {
131 let origin = url.origin()?;
132 let cookies = self.cookies.get(&origin)?;
133 let header = cookies
134 .values()
135 .map(|cookie| format!("{}={}", cookie.name, cookie.value()))
136 .collect::<Vec<_>>()
137 .join("; ");
138 (!header.is_empty()).then_some(header)
139 }
140
141 pub fn clear_origin(&mut self, origin: &Origin) {
143 self.cookies.remove(origin);
144 }
145
146 pub fn clear(&mut self) {
148 self.cookies.clear();
149 }
150
151 pub fn save(&self, storage: &mut dyn SecureStorage, key: &str) -> Result<(), AuthError> {
153 storage
154 .store(key, self.serialize().as_bytes())
155 .map_err(AuthError::Storage)
156 }
157
158 pub fn load(storage: &dyn SecureStorage, key: &str) -> Result<Self, AuthError> {
160 let Some(bytes) = storage.load(key).map_err(AuthError::Storage)? else {
161 return Ok(Self::new());
162 };
163 let contents =
164 String::from_utf8(bytes).map_err(|error| AuthError::Storage(error.to_string()))?;
165 Self::deserialize(&contents)
166 }
167
168 fn serialize(&self) -> String {
169 let mut lines = vec!["index-cookies-v1".to_owned()];
170 for (origin, cookies) in &self.cookies {
171 for cookie in cookies.values() {
172 lines.push(format!(
173 "{}\t{}",
174 escape_field(origin.as_str()),
175 cookie.serialized()
176 ));
177 }
178 }
179 lines.join("\n")
180 }
181
182 fn deserialize(contents: &str) -> Result<Self, AuthError> {
183 let mut lines = contents.lines();
184 if lines.next() != Some("index-cookies-v1") {
185 return Err(AuthError::Storage("missing cookie jar header".to_owned()));
186 }
187 let mut jar = Self::new();
188 for line in lines {
189 let fields = line.split('\t').collect::<Vec<_>>();
190 if fields.len() != 2 {
191 return Err(AuthError::Storage("invalid cookie record".to_owned()));
192 }
193 let origin = Origin::from_stored(unescape_field(fields[0])?);
194 let cookie = parse_cookie(fields[1])?;
195 jar.cookies
196 .entry(origin)
197 .or_default()
198 .insert(cookie.name.clone(), cookie);
199 }
200 Ok(jar)
201 }
202}
203
204pub trait SecureStorage {
206 fn store(&mut self, key: &str, value: &[u8]) -> Result<(), String>;
208 fn load(&self, key: &str) -> Result<Option<Vec<u8>>, String>;
210 fn delete(&mut self, key: &str) -> Result<(), String>;
212}
213
214#[derive(Debug, Clone, Default)]
216pub struct MemorySecureStorage {
217 values: BTreeMap<String, Vec<u8>>,
218}
219
220impl MemorySecureStorage {
221 #[must_use]
223 pub fn new() -> Self {
224 Self::default()
225 }
226}
227
228impl SecureStorage for MemorySecureStorage {
229 fn store(&mut self, key: &str, value: &[u8]) -> Result<(), String> {
230 self.values.insert(key.to_owned(), value.to_vec());
231 Ok(())
232 }
233
234 fn load(&self, key: &str) -> Result<Option<Vec<u8>>, String> {
235 Ok(self.values.get(key).cloned())
236 }
237
238 fn delete(&mut self, key: &str) -> Result<(), String> {
239 self.values.remove(key);
240 Ok(())
241 }
242}
243
244#[derive(Debug, Clone, PartialEq, Eq)]
246pub enum SessionScope {
247 Origin(Origin),
249 Origins(BTreeSet<Origin>),
251}
252
253impl SessionScope {
254 #[must_use]
256 pub fn allows(&self, origin: &Origin) -> bool {
257 match self {
258 Self::Origin(allowed) => allowed == origin,
259 Self::Origins(origins) => origins.contains(origin),
260 }
261 }
262}
263
264#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct AuthSession {
267 pub id: SessionId,
269 pub scope: SessionScope,
271 pub cookies: CookieJar,
273}
274
275impl AuthSession {
276 #[must_use]
278 pub fn new(id: SessionId, scope: SessionScope) -> Self {
279 Self {
280 id,
281 scope,
282 cookies: CookieJar::new(),
283 }
284 }
285
286 pub fn set_cookie(&mut self, url: &IndexUrl, cookie: Cookie) -> Result<(), AuthError> {
288 let origin = url.origin().ok_or(AuthError::MissingOrigin)?;
289 if !self.scope.allows(&origin) {
290 return Err(AuthError::OriginDenied(origin));
291 }
292 self.cookies.set(url, cookie)
293 }
294
295 pub fn logout(&mut self) {
297 self.cookies.clear();
298 }
299}
300
301#[derive(Debug, Clone, PartialEq, Eq)]
303pub struct OriginPolicy {
304 allowed: BTreeSet<Origin>,
305}
306
307impl OriginPolicy {
308 #[must_use]
310 pub fn new(allowed: impl IntoIterator<Item = Origin>) -> Self {
311 Self {
312 allowed: allowed.into_iter().collect(),
313 }
314 }
315
316 pub fn check(&self, url: &IndexUrl) -> Result<(), AuthError> {
318 let origin = url.origin().ok_or(AuthError::MissingOrigin)?;
319 if self.allowed.contains(&origin) {
320 Ok(())
321 } else {
322 Err(AuthError::OriginDenied(origin))
323 }
324 }
325}
326
327#[derive(Debug, Clone, PartialEq, Eq)]
329pub struct LoginFlow {
330 pub form: Form,
332 pub base_url: IndexUrl,
334}
335
336impl LoginFlow {
337 pub fn submit(
339 &self,
340 policy: &OriginPolicy,
341 values: &[(&str, &str)],
342 ) -> Result<crate::FormSubmission, AuthError> {
343 policy.check(&self.base_url)?;
344 let submission = self
345 .form
346 .submit(Some(&self.base_url), values)
347 .map_err(AuthError::Form)?;
348 policy.check(&submission.action)?;
349 Ok(submission)
350 }
351}
352
353#[derive(Debug, Clone, Default)]
355pub struct Redactor {
356 secrets: Vec<String>,
357}
358
359impl Redactor {
360 #[must_use]
362 pub fn new() -> Self {
363 Self::default()
364 }
365
366 pub fn add_secret(&mut self, secret: impl Into<String>) {
368 let secret = secret.into();
369 if !secret.is_empty() {
370 self.secrets.push(secret);
371 }
372 }
373
374 #[must_use]
376 pub fn redact(&self, input: &str) -> String {
377 let mut output = redact_known_fields(input);
378 for secret in &self.secrets {
379 output = output.replace(secret, "[REDACTED]");
380 }
381 output
382 }
383}
384
385fn parse_cookie(input: &str) -> Result<Cookie, AuthError> {
386 let mut parts = input.split("; ");
387 let Some(pair) = parts.next() else {
388 return Err(AuthError::Storage("missing cookie pair".to_owned()));
389 };
390 let Some((name, value)) = pair.split_once('=') else {
391 return Err(AuthError::Storage("invalid cookie pair".to_owned()));
392 };
393 let mut cookie = Cookie::new(unescape_field(name)?, unescape_field(value)?);
394 for part in parts {
395 if let Some(value) = part.strip_prefix("HttpOnly=") {
396 cookie.http_only = value == "true";
397 } else if let Some(value) = part.strip_prefix("Secure=") {
398 cookie.secure = value == "true";
399 }
400 }
401 Ok(cookie)
402}
403
404fn redact_known_fields(input: &str) -> String {
405 let mut output = Vec::new();
406 let mut redact_next = false;
407
408 for part in input.split_whitespace() {
409 let lower = part.to_ascii_lowercase();
410 if redact_next {
411 output.push("[REDACTED]".to_owned());
412 redact_next = lower == "bearer" || lower == "basic";
413 continue;
414 }
415
416 if lower.starts_with("authorization:")
417 || lower.starts_with("cookie:")
418 || lower.starts_with("set-cookie:")
419 {
420 output.push("[REDACTED]".to_owned());
421 redact_next = true;
422 } else if lower.starts_with("token=") || lower.starts_with("password=") {
423 output.push("[REDACTED]".to_owned());
424 } else {
425 output.push(part.to_owned());
426 }
427 }
428
429 output.join(" ")
430}
431
432fn escape_field(input: &str) -> String {
433 input
434 .replace('\\', "\\\\")
435 .replace('\t', "\\t")
436 .replace('\n', "\\n")
437}
438
439fn unescape_field(input: &str) -> Result<String, AuthError> {
440 let mut out = String::new();
441 let mut chars = input.chars();
442 while let Some(ch) = chars.next() {
443 if ch != '\\' {
444 out.push(ch);
445 continue;
446 }
447 let Some(next) = chars.next() else {
448 return Err(AuthError::Storage("dangling escape".to_owned()));
449 };
450 match next {
451 '\\' => out.push('\\'),
452 't' => out.push('\t'),
453 'n' => out.push('\n'),
454 other => return Err(AuthError::Storage(format!("unknown escape: {other}"))),
455 }
456 }
457 Ok(out)
458}
459
460#[cfg(test)]
461mod tests {
462 use super::{
463 AuthError, AuthSession, Cookie, CookieJar, LoginFlow, MemorySecureStorage, OriginPolicy,
464 Redactor, SecureStorage, SessionScope,
465 };
466 use crate::{Form, IndexUrl, Input, Origin, SessionId};
467
468 #[test]
469 fn cookies_persist_through_secure_storage() -> Result<(), Box<dyn std::error::Error>> {
470 let url = IndexUrl::parse("https://example.com/account")?;
471 let mut jar = CookieJar::new();
472 jar.set(&url, Cookie::new("sid", "secret"))?;
473 let mut storage = MemorySecureStorage::new();
474
475 jar.save(&mut storage, "cookies")?;
476 let restored = CookieJar::load(&storage, "cookies")?;
477
478 assert_eq!(restored.header_for(&url).as_deref(), Some("sid=secret"));
479 Ok(())
480 }
481
482 #[test]
483 fn cookies_are_isolated_by_origin() -> Result<(), Box<dyn std::error::Error>> {
484 let first = IndexUrl::parse("https://example.com/account")?;
485 let second = IndexUrl::parse("https://other.example/account")?;
486 let mut jar = CookieJar::new();
487 jar.set(&first, Cookie::new("sid", "secret"))?;
488
489 assert_eq!(jar.header_for(&first).as_deref(), Some("sid=secret"));
490 assert_eq!(jar.header_for(&second), None);
491 Ok(())
492 }
493
494 #[test]
495 fn logout_clears_session_cookies() -> Result<(), Box<dyn std::error::Error>> {
496 let url = IndexUrl::parse("https://example.com/account")?;
497 let scope = SessionScope::Origin(Origin::from_stored("https://example.com"));
498 let mut session = AuthSession::new(SessionId::new("auth"), scope);
499 session.set_cookie(&url, Cookie::new("sid", "secret"))?;
500
501 session.logout();
502
503 assert_eq!(session.cookies.header_for(&url), None);
504 Ok(())
505 }
506
507 #[test]
508 fn auth_session_rejects_out_of_scope_cookie() -> Result<(), Box<dyn std::error::Error>> {
509 let url = IndexUrl::parse("https://other.example/account")?;
510 let scope = SessionScope::Origin(Origin::from_stored("https://example.com"));
511 let mut session = AuthSession::new(SessionId::new("auth"), scope);
512
513 assert_eq!(
514 session.set_cookie(&url, Cookie::new("sid", "secret")),
515 Err(AuthError::OriginDenied(Origin::from_stored(
516 "https://other.example"
517 )))
518 );
519 Ok(())
520 }
521
522 #[test]
523 fn secure_cookies_require_https() -> Result<(), Box<dyn std::error::Error>> {
524 let url = IndexUrl::parse("http://example.com/account")?;
525 let mut jar = CookieJar::new();
526
527 assert_eq!(
528 jar.set(&url, Cookie::new("sid", "secret")),
529 Err(AuthError::InsecureCookieOrigin(Origin::from_stored(
530 "http://example.com"
531 )))
532 );
533 Ok(())
534 }
535
536 #[test]
537 fn login_flow_resolves_form_inside_origin_policy() -> Result<(), Box<dyn std::error::Error>> {
538 let flow = LoginFlow {
539 base_url: IndexUrl::parse("https://example.com/login")?,
540 form: Form {
541 name: "login".to_owned(),
542 method: "POST".to_owned(),
543 action: "/session".to_owned(),
544 inputs: vec![Input {
545 name: "user".to_owned(),
546 kind: "text".to_owned(),
547 value: None,
548 required: true,
549 }],
550 buttons: Vec::new(),
551 },
552 };
553 let policy = OriginPolicy::new([Origin::from_stored("https://example.com")]);
554
555 let submission = flow.submit(&policy, &[("user", "ada")])?;
556
557 assert_eq!(submission.action.as_str(), "https://example.com/session");
558 assert_eq!(submission.body.as_deref(), Some("user=ada"));
559 Ok(())
560 }
561
562 #[test]
563 fn login_flow_rejects_cross_origin_action() -> Result<(), Box<dyn std::error::Error>> {
564 let flow = LoginFlow {
565 base_url: IndexUrl::parse("https://example.com/login")?,
566 form: Form {
567 name: "login".to_owned(),
568 method: "POST".to_owned(),
569 action: "https://evil.example/session".to_owned(),
570 inputs: Vec::new(),
571 buttons: Vec::new(),
572 },
573 };
574 let policy = OriginPolicy::new([Origin::from_stored("https://example.com")]);
575
576 assert_eq!(
577 flow.submit(&policy, &[]),
578 Err(AuthError::OriginDenied(Origin::from_stored(
579 "https://evil.example"
580 )))
581 );
582 Ok(())
583 }
584
585 #[test]
586 fn redactor_removes_cookie_tokens_and_known_secrets() {
587 let mut redactor = Redactor::new();
588 redactor.add_secret("abc123");
589
590 let output =
591 redactor.redact("Authorization: Bearer abc123 token=abc123 Cookie: sid=abc123");
592
593 assert!(!output.contains("abc123"));
594 assert!(!output.contains("Bearer"));
595 assert!(output.contains("[REDACTED]"));
596 }
597
598 #[test]
599 fn cookie_debug_does_not_leak_secret_value() {
600 let cookie = Cookie::new("sid", "abc123");
601 let rendered = format!("{cookie:?}");
602
603 assert!(rendered.contains("sid"));
604 assert!(!rendered.contains("abc123"));
605 }
606
607 #[test]
608 fn secure_storage_delete_removes_value() -> Result<(), Box<dyn std::error::Error>> {
609 let mut storage = MemorySecureStorage::new();
610 storage.store("key", b"value")?;
611 storage.delete("key")?;
612
613 assert_eq!(storage.load("key")?, None);
614 Ok(())
615 }
616}