viewpoint_core/network/auth/
mod.rs

1//! HTTP and proxy authentication handling.
2//!
3//! This module provides support for handling HTTP Basic and Digest authentication
4//! challenges via the Fetch.authRequired CDP event. It also handles proxy
5//! authentication challenges.
6
7use std::sync::Arc;
8
9use tokio::sync::RwLock;
10use viewpoint_cdp::CdpConnection;
11use viewpoint_cdp::protocol::fetch::{
12    AuthChallenge, AuthChallengeResponse, AuthChallengeSource, AuthRequiredEvent,
13    ContinueWithAuthParams,
14};
15
16use crate::error::NetworkError;
17
18/// Proxy credentials for authentication.
19#[derive(Debug, Clone)]
20pub struct ProxyCredentials {
21    /// Username for proxy authentication.
22    pub username: String,
23    /// Password for proxy authentication.
24    pub password: String,
25}
26
27impl ProxyCredentials {
28    /// Create new proxy credentials.
29    pub fn new(username: impl Into<String>, password: impl Into<String>) -> Self {
30        Self {
31            username: username.into(),
32            password: password.into(),
33        }
34    }
35}
36
37/// HTTP credentials for authentication.
38#[derive(Debug, Clone)]
39pub struct HttpCredentials {
40    /// Username for authentication.
41    pub username: String,
42    /// Password for authentication.
43    pub password: String,
44    /// Optional origin to restrict credentials to.
45    /// If None, credentials apply to all origins.
46    pub origin: Option<String>,
47}
48
49impl HttpCredentials {
50    /// Create new HTTP credentials.
51    pub fn new(username: impl Into<String>, password: impl Into<String>) -> Self {
52        Self {
53            username: username.into(),
54            password: password.into(),
55            origin: None,
56        }
57    }
58
59    /// Create HTTP credentials restricted to a specific origin.
60    pub fn for_origin(
61        username: impl Into<String>,
62        password: impl Into<String>,
63        origin: impl Into<String>,
64    ) -> Self {
65        Self {
66            username: username.into(),
67            password: password.into(),
68            origin: Some(origin.into()),
69        }
70    }
71
72    /// Check if these credentials apply to the given challenge origin.
73    pub fn matches_origin(&self, challenge_origin: &str) -> bool {
74        match &self.origin {
75            Some(origin) => {
76                // Match if origin matches exactly or is a subdomain
77                challenge_origin == origin || challenge_origin.ends_with(&format!(".{origin}"))
78            }
79            None => true, // No origin restriction - apply to all
80        }
81    }
82}
83
84/// Handler for HTTP and proxy authentication challenges.
85#[derive(Debug)]
86pub struct AuthHandler {
87    /// CDP connection.
88    connection: Arc<CdpConnection>,
89    /// Session ID for CDP commands.
90    session_id: String,
91    /// Stored HTTP credentials.
92    credentials: RwLock<Option<HttpCredentials>>,
93    /// Stored proxy credentials.
94    proxy_credentials: RwLock<Option<ProxyCredentials>>,
95    /// How many times to retry with credentials before canceling.
96    max_retries: u32,
97    /// Current retry count per origin.
98    retry_counts: RwLock<std::collections::HashMap<String, u32>>,
99}
100
101impl AuthHandler {
102    /// Create a new auth handler.
103    pub fn new(connection: Arc<CdpConnection>, session_id: String) -> Self {
104        Self {
105            connection,
106            session_id,
107            credentials: RwLock::new(None),
108            proxy_credentials: RwLock::new(None),
109            max_retries: 3,
110            retry_counts: RwLock::new(std::collections::HashMap::new()),
111        }
112    }
113
114    /// Create an auth handler with pre-configured credentials.
115    pub fn with_credentials(
116        connection: Arc<CdpConnection>,
117        session_id: String,
118        credentials: HttpCredentials,
119    ) -> Self {
120        Self {
121            connection,
122            session_id,
123            credentials: RwLock::new(Some(credentials)),
124            proxy_credentials: RwLock::new(None),
125            max_retries: 3,
126            retry_counts: RwLock::new(std::collections::HashMap::new()),
127        }
128    }
129
130    /// Create an auth handler with pre-configured proxy credentials.
131    pub fn with_proxy_credentials(
132        connection: Arc<CdpConnection>,
133        session_id: String,
134        proxy_credentials: ProxyCredentials,
135    ) -> Self {
136        Self {
137            connection,
138            session_id,
139            credentials: RwLock::new(None),
140            proxy_credentials: RwLock::new(Some(proxy_credentials)),
141            max_retries: 3,
142            retry_counts: RwLock::new(std::collections::HashMap::new()),
143        }
144    }
145
146    /// Create an auth handler with both HTTP and proxy credentials.
147    pub fn with_all_credentials(
148        connection: Arc<CdpConnection>,
149        session_id: String,
150        http_credentials: Option<HttpCredentials>,
151        proxy_credentials: Option<ProxyCredentials>,
152    ) -> Self {
153        Self {
154            connection,
155            session_id,
156            credentials: RwLock::new(http_credentials),
157            proxy_credentials: RwLock::new(proxy_credentials),
158            max_retries: 3,
159            retry_counts: RwLock::new(std::collections::HashMap::new()),
160        }
161    }
162
163    /// Set HTTP credentials.
164    pub async fn set_credentials(&self, credentials: HttpCredentials) {
165        let mut creds = self.credentials.write().await;
166        *creds = Some(credentials);
167    }
168
169    /// Set HTTP credentials synchronously (for use during construction).
170    ///
171    /// This uses `blocking_write` which should only be called from non-async contexts.
172    pub fn set_credentials_sync(&self, credentials: HttpCredentials) {
173        // Use try_write to avoid blocking - this is called during construction
174        // before any async tasks are running, so it should always succeed.
175        if let Ok(mut creds) = self.credentials.try_write() {
176            *creds = Some(credentials);
177        }
178    }
179
180    /// Clear HTTP credentials.
181    pub async fn clear_credentials(&self) {
182        let mut creds = self.credentials.write().await;
183        *creds = None;
184    }
185
186    /// Set proxy credentials.
187    pub async fn set_proxy_credentials(&self, credentials: ProxyCredentials) {
188        let mut creds = self.proxy_credentials.write().await;
189        *creds = Some(credentials);
190    }
191
192    /// Set proxy credentials synchronously (for use during construction).
193    ///
194    /// This uses `try_write` which should only be called from non-async contexts.
195    pub fn set_proxy_credentials_sync(&self, credentials: ProxyCredentials) {
196        if let Ok(mut creds) = self.proxy_credentials.try_write() {
197            *creds = Some(credentials);
198        }
199    }
200
201    /// Clear proxy credentials.
202    pub async fn clear_proxy_credentials(&self) {
203        let mut creds = self.proxy_credentials.write().await;
204        *creds = None;
205    }
206
207    /// Handle an authentication challenge.
208    ///
209    /// Returns true if the challenge was handled, false if no credentials available.
210    ///
211    /// # Errors
212    ///
213    /// Returns an error if the CDP command to continue with authentication fails,
214    /// such as when the connection is closed or the browser rejects the request.
215    pub async fn handle_auth_challenge(
216        &self,
217        event: &AuthRequiredEvent,
218    ) -> Result<bool, NetworkError> {
219        // Check if this is a proxy authentication challenge
220        if event.auth_challenge.source == AuthChallengeSource::Proxy {
221            return self.handle_proxy_auth(event).await;
222        }
223
224        // Handle HTTP authentication challenge
225        let creds = self.credentials.read().await;
226
227        if let Some(credentials) = &*creds {
228            // Check if credentials match the challenge origin
229            if !credentials.matches_origin(&event.auth_challenge.origin) {
230                tracing::debug!(
231                    origin = %event.auth_challenge.origin,
232                    "No matching credentials for origin"
233                );
234                return self.cancel_auth(&event.request_id).await.map(|()| false);
235            }
236
237            // Check retry count
238            {
239                let mut counts = self.retry_counts.write().await;
240                let count = counts
241                    .entry(event.auth_challenge.origin.clone())
242                    .or_insert(0);
243
244                if *count >= self.max_retries {
245                    tracing::warn!(
246                        origin = %event.auth_challenge.origin,
247                        retries = self.max_retries,
248                        "Max auth retries exceeded, canceling"
249                    );
250                    return self.cancel_auth(&event.request_id).await.map(|()| false);
251                }
252
253                *count += 1;
254            }
255
256            // Provide credentials based on the authentication scheme
257            self.provide_credentials(
258                &event.request_id,
259                &event.auth_challenge,
260                &credentials.username,
261                &credentials.password,
262            )
263            .await?;
264
265            Ok(true)
266        } else {
267            tracing::debug!(
268                origin = %event.auth_challenge.origin,
269                scheme = %event.auth_challenge.scheme,
270                "No credentials available, deferring to default"
271            );
272            // No credentials - let browser handle it (show dialog or fail)
273            self.default_auth(&event.request_id).await?;
274            Ok(false)
275        }
276    }
277
278    /// Handle a proxy authentication challenge.
279    async fn handle_proxy_auth(&self, event: &AuthRequiredEvent) -> Result<bool, NetworkError> {
280        let proxy_creds = self.proxy_credentials.read().await;
281
282        if let Some(credentials) = &*proxy_creds {
283            // Check retry count for proxy (use "proxy" as key)
284            let retry_key = format!("proxy:{}", event.auth_challenge.origin);
285            {
286                let mut counts = self.retry_counts.write().await;
287                let count = counts.entry(retry_key.clone()).or_insert(0);
288
289                if *count >= self.max_retries {
290                    tracing::warn!(
291                        origin = %event.auth_challenge.origin,
292                        retries = self.max_retries,
293                        "Max proxy auth retries exceeded, canceling"
294                    );
295                    return self.cancel_auth(&event.request_id).await.map(|()| false);
296                }
297
298                *count += 1;
299            }
300
301            tracing::debug!(
302                origin = %event.auth_challenge.origin,
303                scheme = %event.auth_challenge.scheme,
304                "Providing proxy credentials"
305            );
306
307            // Provide proxy credentials
308            self.provide_credentials(
309                &event.request_id,
310                &event.auth_challenge,
311                &credentials.username,
312                &credentials.password,
313            )
314            .await?;
315
316            Ok(true)
317        } else {
318            tracing::debug!(
319                origin = %event.auth_challenge.origin,
320                scheme = %event.auth_challenge.scheme,
321                "No proxy credentials available, deferring to default"
322            );
323            // No proxy credentials - let browser handle it
324            self.default_auth(&event.request_id).await?;
325            Ok(false)
326        }
327    }
328
329    /// Provide credentials for an auth challenge.
330    async fn provide_credentials(
331        &self,
332        request_id: &str,
333        challenge: &AuthChallenge,
334        username: &str,
335        password: &str,
336    ) -> Result<(), NetworkError> {
337        tracing::debug!(
338            origin = %challenge.origin,
339            scheme = %challenge.scheme,
340            realm = %challenge.realm,
341            "Providing credentials for auth challenge"
342        );
343
344        self.connection
345            .send_command::<_, serde_json::Value>(
346                "Fetch.continueWithAuth",
347                Some(ContinueWithAuthParams {
348                    request_id: request_id.to_string(),
349                    auth_challenge_response: AuthChallengeResponse::provide_credentials(
350                        username, password,
351                    ),
352                }),
353                Some(&self.session_id),
354            )
355            .await?;
356
357        Ok(())
358    }
359
360    /// Cancel authentication.
361    async fn cancel_auth(&self, request_id: &str) -> Result<(), NetworkError> {
362        tracing::debug!("Canceling auth challenge");
363
364        self.connection
365            .send_command::<_, serde_json::Value>(
366                "Fetch.continueWithAuth",
367                Some(ContinueWithAuthParams {
368                    request_id: request_id.to_string(),
369                    auth_challenge_response: AuthChallengeResponse::cancel(),
370                }),
371                Some(&self.session_id),
372            )
373            .await?;
374
375        Ok(())
376    }
377
378    /// Use default browser behavior for auth.
379    async fn default_auth(&self, request_id: &str) -> Result<(), NetworkError> {
380        self.connection
381            .send_command::<_, serde_json::Value>(
382                "Fetch.continueWithAuth",
383                Some(ContinueWithAuthParams {
384                    request_id: request_id.to_string(),
385                    auth_challenge_response: AuthChallengeResponse::default_response(),
386                }),
387                Some(&self.session_id),
388            )
389            .await?;
390
391        Ok(())
392    }
393
394    /// Reset retry counts (call after successful auth).
395    pub async fn reset_retries(&self, origin: &str) {
396        let mut counts = self.retry_counts.write().await;
397        counts.remove(origin);
398    }
399
400    /// Reset all retry counts.
401    pub async fn reset_all_retries(&self) {
402        let mut counts = self.retry_counts.write().await;
403        counts.clear();
404    }
405}
406
407#[cfg(test)]
408mod tests;