finance_query_core/client/
yahoo_auth.rs1use 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#[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
37pub struct YahooAuthManager {
42 state: Arc<Mutex<AuthState>>,
43 proxy: Option<String>,
44}
45
46impl YahooAuthManager {
47 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 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 .danger_accept_invalid_certs(true);
86 }
87
88 let client = builder.build().map_err(YahooError::NetworkError)?;
89
90 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 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); 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 pub async fn crumb(&self) -> Option<String> {
176 self.state.lock().await.crumb.clone()
177 }
178
179 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 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 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}