Skip to main content

index_core/
auth.rs

1//! Authentication, cookie, and redaction primitives.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::fmt::{Debug, Display, Formatter};
5
6use crate::{Form, FormSubmitError, IndexUrl, Origin, SessionId};
7
8/// Authentication errors.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum AuthError {
11    /// URL had no origin.
12    MissingOrigin,
13    /// Requested origin is outside the session scope.
14    OriginDenied(Origin),
15    /// Secure cookies require HTTPS origins.
16    InsecureCookieOrigin(Origin),
17    /// Secure storage operation failed.
18    Storage(String),
19    /// Login form submission failed.
20    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/// Cookie value scoped to an origin.
45#[derive(Clone, PartialEq, Eq)]
46pub struct Cookie {
47    /// Cookie name.
48    pub name: String,
49    value: String,
50    /// Whether the cookie should be withheld from scripts.
51    pub http_only: bool,
52    /// Whether the cookie requires HTTPS.
53    pub secure: bool,
54}
55
56impl Cookie {
57    /// Creates a cookie.
58    #[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    /// Returns the cookie value for transport code.
69    #[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/// Cookie jar isolated by origin.
97#[derive(Debug, Clone, Default, PartialEq, Eq)]
98pub struct CookieJar {
99    cookies: BTreeMap<Origin, BTreeMap<String, Cookie>>,
100}
101
102impl CookieJar {
103    /// Creates an empty cookie jar.
104    #[must_use]
105    pub fn new() -> Self {
106        Self::default()
107    }
108
109    /// Stores a cookie for a URL origin.
110    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    /// Returns a cookie by URL origin and name.
123    #[must_use]
124    pub fn get(&self, url: &IndexUrl, name: &str) -> Option<&Cookie> {
125        self.cookies.get(&url.origin()?)?.get(name)
126    }
127
128    /// Builds a Cookie header for a URL.
129    #[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    /// Clears all cookies for one origin.
142    pub fn clear_origin(&mut self, origin: &Origin) {
143        self.cookies.remove(origin);
144    }
145
146    /// Clears all cookies.
147    pub fn clear(&mut self) {
148        self.cookies.clear();
149    }
150
151    /// Persists cookies through secure storage.
152    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    /// Loads cookies from secure storage.
159    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
204/// Secure storage abstraction for sensitive values.
205pub trait SecureStorage {
206    /// Stores bytes under a key.
207    fn store(&mut self, key: &str, value: &[u8]) -> Result<(), String>;
208    /// Loads bytes by key.
209    fn load(&self, key: &str) -> Result<Option<Vec<u8>>, String>;
210    /// Deletes a key.
211    fn delete(&mut self, key: &str) -> Result<(), String>;
212}
213
214/// In-memory secure storage for tests and local prototypes.
215#[derive(Debug, Clone, Default)]
216pub struct MemorySecureStorage {
217    values: BTreeMap<String, Vec<u8>>,
218}
219
220impl MemorySecureStorage {
221    /// Creates empty memory storage.
222    #[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/// Auth session scope.
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub enum SessionScope {
247    /// Session may access exactly one origin.
248    Origin(Origin),
249    /// Session may access listed origins.
250    Origins(BTreeSet<Origin>),
251}
252
253impl SessionScope {
254    /// Returns whether this scope allows an origin.
255    #[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/// Authenticated session with scoped cookies.
265#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct AuthSession {
267    /// Session identifier.
268    pub id: SessionId,
269    /// Scope for this session.
270    pub scope: SessionScope,
271    /// Session cookies.
272    pub cookies: CookieJar,
273}
274
275impl AuthSession {
276    /// Creates a scoped auth session.
277    #[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    /// Stores a cookie if the target URL is inside scope.
287    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    /// Clears session state for logout.
296    pub fn logout(&mut self) {
297        self.cookies.clear();
298    }
299}
300
301/// Origin policy for authenticated flows.
302#[derive(Debug, Clone, PartialEq, Eq)]
303pub struct OriginPolicy {
304    allowed: BTreeSet<Origin>,
305}
306
307impl OriginPolicy {
308    /// Creates an origin policy.
309    #[must_use]
310    pub fn new(allowed: impl IntoIterator<Item = Origin>) -> Self {
311        Self {
312            allowed: allowed.into_iter().collect(),
313        }
314    }
315
316    /// Verifies a URL is allowed.
317    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/// Login flow request abstraction.
328#[derive(Debug, Clone, PartialEq, Eq)]
329pub struct LoginFlow {
330    /// Login form.
331    pub form: Form,
332    /// Base URL used for relative actions.
333    pub base_url: IndexUrl,
334}
335
336impl LoginFlow {
337    /// Submits login form values after applying origin policy.
338    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/// Redacts sensitive values from diagnostics.
354#[derive(Debug, Clone, Default)]
355pub struct Redactor {
356    secrets: Vec<String>,
357}
358
359impl Redactor {
360    /// Creates an empty redactor.
361    #[must_use]
362    pub fn new() -> Self {
363        Self::default()
364    }
365
366    /// Adds a secret to redact.
367    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    /// Redacts known secrets and common credential fields.
375    #[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}