Skip to main content

dravr_browser/
session.rs

1// ABOUTME: Cookie-based browser session capture/injection and the AuthSession data model
2// ABOUTME: Lets a profile's authenticated cookies be snapshotted and replayed across browsers
3//
4// SPDX-License-Identifier: MIT OR Apache-2.0
5// Copyright (c) 2026 dravr.ai
6
7use std::time::SystemTime;
8
9use chromiumoxide::cdp::browser_protocol::network::CookieParam;
10use chromiumoxide::Page;
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use tracing::debug;
14
15use crate::error::{BrowserError, BrowserResult};
16
17/// A captured browser session: an identifier plus the cookies that authenticate it.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct AuthSession {
20    /// Session identifier (for cache keying / profile naming).
21    pub session_id: String,
22    /// Captured browser cookies.
23    pub cookies: Vec<CookieData>,
24    /// When this session was created.
25    pub created_at: DateTime<Utc>,
26    /// When this session expires, if known.
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub expires_at: Option<DateTime<Utc>>,
29}
30
31/// A single browser cookie.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct CookieData {
34    /// Cookie name.
35    pub name: String,
36    /// Cookie value.
37    pub value: String,
38    /// Cookie domain.
39    pub domain: String,
40    /// Cookie path.
41    pub path: String,
42    /// Whether the cookie is secure-only.
43    pub secure: bool,
44    /// Whether the cookie is HTTP-only.
45    pub http_only: bool,
46}
47
48/// Inject session cookies into a page before navigation.
49pub async fn inject_cookies(page: &Page, session: &AuthSession) -> BrowserResult<()> {
50    for cookie in &session.cookies {
51        let mut param = CookieParam::new(&cookie.name, &cookie.value);
52        param.domain = Some(cookie.domain.clone());
53        param.path = Some(cookie.path.clone());
54        param.secure = Some(cookie.secure);
55        param.http_only = Some(cookie.http_only);
56
57        page.set_cookie(param)
58            .await
59            .map_err(|e| BrowserError::Browser {
60                reason: format!("Failed to set cookie {}: {e}", cookie.name),
61            })?;
62    }
63
64    debug!(count = session.cookies.len(), "Injected session cookies");
65    Ok(())
66}
67
68/// Capture all cookies from the current page into an [`AuthSession`].
69///
70/// Returns [`BrowserError::Auth`] if no cookies are present (login likely failed).
71pub async fn capture_session(page: &Page) -> BrowserResult<AuthSession> {
72    let cookies = page
73        .get_cookies()
74        .await
75        .map_err(|e| BrowserError::Browser {
76            reason: format!("Failed to get cookies: {e}"),
77        })?;
78
79    let cookie_data: Vec<CookieData> = cookies
80        .iter()
81        .map(|c| CookieData {
82            name: c.name.clone(),
83            value: c.value.clone(),
84            domain: c.domain.clone(),
85            path: c.path.clone(),
86            secure: c.secure,
87            http_only: c.http_only,
88        })
89        .collect();
90
91    if cookie_data.is_empty() {
92        return Err(BrowserError::Auth {
93            reason: "No cookies captured after login".to_owned(),
94        });
95    }
96
97    Ok(AuthSession {
98        session_id: generate_session_id(),
99        cookies: cookie_data,
100        created_at: Utc::now(),
101        expires_at: None,
102    })
103}
104
105/// Generate a unique session identifier from the current system time.
106#[must_use]
107pub fn generate_session_id() -> String {
108    let d = SystemTime::now()
109        .duration_since(SystemTime::UNIX_EPOCH)
110        .unwrap_or_default();
111    format!("{:x}-{:x}", d.as_secs(), d.subsec_nanos())
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn session_id_is_nonempty_and_hyphenated() {
120        let id = generate_session_id();
121        assert!(id.contains('-'));
122        assert!(!id.starts_with('-'));
123    }
124
125    #[test]
126    fn auth_session_roundtrips_json() {
127        let session = AuthSession {
128            session_id: "abc".to_owned(),
129            cookies: vec![CookieData {
130                name: "sessionKey".to_owned(),
131                value: "v".to_owned(),
132                domain: ".claude.ai".to_owned(),
133                path: "/".to_owned(),
134                secure: true,
135                http_only: true,
136            }],
137            created_at: Utc::now(),
138            expires_at: None,
139        };
140        let json = serde_json::to_string(&session).unwrap();
141        let back: AuthSession = serde_json::from_str(&json).unwrap();
142        assert_eq!(back.session_id, "abc");
143        assert_eq!(back.cookies.len(), 1);
144        assert_eq!(back.cookies[0].domain, ".claude.ai");
145    }
146}