Skip to main content

finance_query_core/client/
yahoo_auth.rs

1//! Yahoo Finance authentication manager.
2//!
3//! This module handles cookie/crumb authentication for Yahoo Finance API requests.
4
5use crate::client::error::YahooError;
6use chrono::{DateTime, Utc};
7use once_cell::sync::Lazy;
8use regex::Regex;
9use reqwest::{cookie::Jar, ClientBuilder};
10use std::sync::Arc;
11use std::time::Duration;
12use tokio::sync::Mutex;
13use tracing::{debug, error, info, warn};
14
15const MIN_REFRESH_INTERVAL_SECS: i64 = 30;
16
17/// Auth acquisition strategy. Mirrors yfinance’s dual-path approach.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
19enum CookieStrategy {
20    #[default]
21    Basic,
22    Csrf,
23}
24
25static CSRF_TOKEN_RE: Lazy<Regex> =
26    Lazy::new(|| Regex::new(r#"name="csrfToken"[^>]*value="([^"]+)""#).unwrap());
27static SESSION_ID_RE: Lazy<Regex> =
28    Lazy::new(|| Regex::new(r#"name="sessionId"[^>]*value="([^"]+)""#).unwrap());
29
30struct AuthState {
31    crumb: Option<String>,
32    cookie_jar: Arc<Jar>,
33    last_update: Option<DateTime<Utc>>,
34    strategy: CookieStrategy,
35}
36
37/// Manages Yahoo Finance authentication (cookies and crumb).
38///
39/// Yahoo Finance requires a valid crumb token for API requests.
40/// This manager handles obtaining and refreshing the crumb automatically.
41pub struct YahooAuthManager {
42    state: Arc<Mutex<AuthState>>,
43    proxy: Option<String>,
44}
45
46impl YahooAuthManager {
47    /// Create a new YahooAuthManager.
48    ///
49    /// # Arguments
50    /// * `proxy` - Optional proxy URL for authentication requests
51    /// * `cookie_jar` - Shared cookie jar for storing session cookies
52    pub fn new(proxy: Option<String>, cookie_jar: Arc<Jar>) -> Self {
53        Self {
54            state: Arc::new(Mutex::new(AuthState {
55                crumb: None,
56                cookie_jar,
57                last_update: None,
58                strategy: CookieStrategy::default(),
59            })),
60            proxy,
61        }
62    }
63
64    /// Force refresh the authentication credentials.
65    pub async fn refresh(&self) -> Result<(), YahooError> {
66        info!("Refreshing Yahoo authentication...");
67        let state = self.state.lock().await;
68        let cookie_jar = state.cookie_jar.clone();
69        let mut strategy = state.strategy;
70        drop(state);
71
72        let mut builder = ClientBuilder::new()
73            .timeout(Duration::from_secs(30))
74            .cookie_provider(cookie_jar.clone())
75            .redirect(reqwest::redirect::Policy::limited(10));
76
77        if let Some(proxy_url) = &self.proxy {
78            info!(
79                "Using proxy for Yahoo auth: {}...",
80                &proxy_url.chars().take(30).collect::<String>()
81            );
82            builder = builder
83                .proxy(reqwest::Proxy::all(proxy_url).map_err(YahooError::NetworkError)?)
84                // Accept proxy's SSL certificate (Bright Data and similar proxies use self-signed certs)
85                .danger_accept_invalid_certs(true);
86        }
87
88        let client = builder.build().map_err(YahooError::NetworkError)?;
89
90        // Try current strategy, then flip once if it fails (mirrors yfinance dual-path logic)
91        let mut last_err: Option<YahooError> = None;
92        for _ in 0..2 {
93            let result = match strategy {
94                CookieStrategy::Basic => {
95                    info!("Attempting Yahoo auth (basic crumb)");
96                    Self::fetch_basic(&client).await
97                }
98                CookieStrategy::Csrf => {
99                    info!("Attempting Yahoo auth (consent/CSRF crumb)");
100                    Self::fetch_csrf(&client).await
101                }
102            };
103
104            match result {
105                Ok(crumb) => {
106                    info!(
107                        "Successfully obtained crumb via {:?} (length: {})",
108                        strategy,
109                        crumb.len()
110                    );
111                    let mut state = self.state.lock().await;
112                    state.crumb = Some(crumb);
113                    state.last_update = Some(Utc::now());
114                    state.strategy = strategy;
115                    return Ok(());
116                }
117                Err(err) => {
118                    warn!(
119                        "Auth attempt with {:?} failed: {}. Switching strategy.",
120                        strategy, err
121                    );
122                    last_err = Some(err);
123                    strategy = match strategy {
124                        CookieStrategy::Basic => CookieStrategy::Csrf,
125                        CookieStrategy::Csrf => CookieStrategy::Basic,
126                    };
127                }
128            }
129        }
130
131        Err(last_err.unwrap_or_else(|| {
132            YahooError::AuthFailed("Failed to obtain Yahoo crumb with all strategies".to_string())
133        }))
134    }
135
136    /// Get the current crumb, refreshing if necessary.
137    ///
138    /// Returns a tuple of (cookie_jar, crumb) for use in API requests.
139    pub async fn get_or_refresh(&self) -> Result<(Arc<Jar>, String), YahooError> {
140        let mut state = self.state.lock().await;
141
142        let needs_refresh = state.crumb.is_none()
143            || state.last_update.is_none()
144            || (Utc::now() - state.last_update.unwrap()).num_seconds() > MIN_REFRESH_INTERVAL_SECS;
145
146        if needs_refresh {
147            debug!(
148                "Auth refresh needed. Crumb exists: {}, Last update: {:?}",
149                state.crumb.is_some(),
150                state.last_update
151            );
152            drop(state); // Release lock before async operation
153            self.refresh().await?;
154            state = self.state.lock().await;
155        } else {
156            debug!(
157                "Using cached crumb (age: {}s)",
158                state
159                    .last_update
160                    .map(|t| (Utc::now() - t).num_seconds())
161                    .unwrap_or(0)
162            );
163        }
164
165        let crumb = state.crumb.clone().ok_or_else(|| {
166            error!("No crumb available after refresh attempt");
167            YahooError::AuthFailed("No crumb available".to_string())
168        })?;
169
170        debug!("Returning crumb (length: {})", crumb.len());
171        Ok((state.cookie_jar.clone(), crumb))
172    }
173
174    /// Get the current crumb without refreshing.
175    pub async fn crumb(&self) -> Option<String> {
176        self.state.lock().await.crumb.clone()
177    }
178
179    /// Force a strategy flip and refresh credentials. Used when Yahoo responds with generic 4xx indicating
180    /// a potentially bad crumb, mirroring yfinance's "retry with other cookie strategy" behavior.
181    pub async fn switch_strategy_and_refresh(&self) -> Result<(), YahooError> {
182        {
183            let mut state = self.state.lock().await;
184            state.strategy = match state.strategy {
185                CookieStrategy::Basic => CookieStrategy::Csrf,
186                CookieStrategy::Csrf => CookieStrategy::Basic,
187            };
188            state.crumb = None;
189            state.last_update = None;
190            info!("Switched Yahoo auth strategy to {:?}", state.strategy);
191        }
192        self.refresh().await
193    }
194
195    /// Basic path: hit fc.yahoo.com then query1 getcrumb.
196    async fn fetch_basic(client: &reqwest::Client) -> Result<String, YahooError> {
197        client
198            .get("https://fc.yahoo.com")
199            .header(
200                "User-Agent",
201                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
202            )
203            .header("Accept", "*/*")
204            .send()
205            .await
206            .map_err(YahooError::NetworkError)?;
207
208        let crumb_response = client
209            .get("https://query1.finance.yahoo.com/v1/test/getcrumb")
210            .header(
211                "User-Agent",
212                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
213            )
214            .header("Accept", "*/*")
215            .send()
216            .await
217            .map_err(YahooError::NetworkError)?;
218
219        let crumb_text = crumb_response
220            .text()
221            .await
222            .map_err(YahooError::NetworkError)?
223            .trim()
224            .to_string();
225
226        if Self::is_valid_crumb(&crumb_text) {
227            Ok(crumb_text)
228        } else {
229            Err(YahooError::AuthFailed(
230                "Invalid crumb from query1 endpoint".to_string(),
231            ))
232        }
233    }
234
235    /// Consent/CSRF path: visit consent page, post consent, then query2 getcrumb.
236    async fn fetch_csrf(client: &reqwest::Client) -> Result<String, YahooError> {
237        let consent_html = client
238            .get("https://guce.yahoo.com/consent")
239            .header(
240                "User-Agent",
241                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
242            )
243            .header("Accept", "text/html,*/*;q=0.8")
244            .send()
245            .await
246            .map_err(YahooError::NetworkError)?
247            .text()
248            .await
249            .map_err(YahooError::NetworkError)?;
250
251        let csrf_token = CSRF_TOKEN_RE
252            .captures(&consent_html)
253            .and_then(|c| c.get(1))
254            .map(|m| m.as_str().to_string());
255        let session_id = SESSION_ID_RE
256            .captures(&consent_html)
257            .and_then(|c| c.get(1))
258            .map(|m| m.as_str().to_string());
259
260        let (csrf_token, session_id) = match (csrf_token, session_id) {
261            (Some(c), Some(s)) => (c, s),
262            _ => {
263                return Err(YahooError::AuthFailed(
264                    "Failed to extract CSRF token or session id from consent page".to_string(),
265                ))
266            }
267        };
268
269        let data = [
270            ("agree", "agree"),
271            ("consentUUID", "default"),
272            ("sessionId", session_id.as_str()),
273            ("csrfToken", csrf_token.as_str()),
274            ("originalDoneUrl", "https://finance.yahoo.com/"),
275            ("namespace", "yahoo"),
276        ];
277
278        client
279            .post(format!(
280                "https://consent.yahoo.com/v2/collectConsent?sessionId={}",
281                session_id
282            ))
283            .form(&data)
284            .header(
285                "User-Agent",
286                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
287            )
288            .send()
289            .await
290            .map_err(YahooError::NetworkError)?;
291
292        client
293            .get(format!(
294                "https://guce.yahoo.com/copyConsent?sessionId={}",
295                session_id
296            ))
297            .header(
298                "User-Agent",
299                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
300            )
301            .query(&data)
302            .send()
303            .await
304            .map_err(YahooError::NetworkError)?;
305
306        let crumb_response = client
307            .get("https://query2.finance.yahoo.com/v1/test/getcrumb")
308            .header(
309                "User-Agent",
310                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
311            )
312            .header("Accept", "*/*")
313            .send()
314            .await
315            .map_err(YahooError::NetworkError)?;
316
317        let crumb_text = crumb_response
318            .text()
319            .await
320            .map_err(YahooError::NetworkError)?
321            .trim()
322            .to_string();
323
324        if Self::is_valid_crumb(&crumb_text) {
325            Ok(crumb_text)
326        } else {
327            Err(YahooError::AuthFailed(
328                "Invalid crumb from query2 endpoint".to_string(),
329            ))
330        }
331    }
332
333    fn is_valid_crumb(crumb: &str) -> bool {
334        !crumb.is_empty()
335            && !crumb.contains("<html")
336            && !crumb.contains("Unauthorized")
337            && !crumb.contains("Too Many Requests")
338    }
339}