1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct AuthSession {
20 pub session_id: String,
22 pub cookies: Vec<CookieData>,
24 pub created_at: DateTime<Utc>,
26 #[serde(skip_serializing_if = "Option::is_none")]
28 pub expires_at: Option<DateTime<Utc>>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct CookieData {
34 pub name: String,
36 pub value: String,
38 pub domain: String,
40 pub path: String,
42 pub secure: bool,
44 pub http_only: bool,
46}
47
48pub 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
68pub 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#[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}