1use crate::client::oauth1::{parse_oauth_response, OAuth1Signer, OAuthConsumer, OAuthToken};
6use crate::client::tokens::{OAuth1Token, OAuth2Token};
7use crate::error::{GarminError, Result};
8use regex::Regex;
9use reqwest::cookie::Jar;
10use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE, REFERER, USER_AGENT};
11use reqwest::Client;
12use serde::Deserialize;
13use std::sync::Arc;
14use std::time::{SystemTime, UNIX_EPOCH};
15
16const DEFAULT_DOMAIN: &str = "garmin.com";
18
19const MOBILE_USER_AGENT: &str = "com.garmin.android.apps.connectmobile";
21
22const API_USER_AGENT: &str = "GCM-iOS-5.7.2.1";
24
25const OAUTH_CONSUMER_URL: &str = "https://thegarth.s3.amazonaws.com/oauth_consumer.json";
27
28#[derive(Debug, Deserialize)]
30struct OAuthConsumerResponse {
31 consumer_key: String,
32 consumer_secret: String,
33}
34
35pub struct SsoClient {
37 client: Client,
38 domain: String,
39 last_url: Option<String>,
40}
41
42impl SsoClient {
43 pub fn new(domain: Option<&str>) -> Result<Self> {
45 let cookie_jar = Arc::new(Jar::default());
46 let client = Client::builder()
47 .cookie_provider(cookie_jar)
48 .timeout(std::time::Duration::from_secs(30))
49 .build()
50 .map_err(GarminError::Http)?;
51
52 Ok(Self {
53 client,
54 domain: domain.unwrap_or(DEFAULT_DOMAIN).to_string(),
55 last_url: None,
56 })
57 }
58
59 pub async fn login(
61 &mut self,
62 email: &str,
63 password: &str,
64 mfa_callback: Option<impl FnOnce() -> String>,
65 ) -> Result<(OAuth1Token, OAuth2Token)> {
66 let csrf_token = self.init_session_and_get_csrf().await?;
68
69 let login_result = self.submit_login(email, password, &csrf_token).await?;
71
72 let ticket = match login_result {
74 LoginResult::Success(ticket) => ticket,
75 LoginResult::MfaRequired => {
76 let mfa_code = mfa_callback.ok_or_else(|| GarminError::MfaRequired)?();
77 self.submit_mfa(&mfa_code, &csrf_token).await?
78 }
79 };
80
81 let oauth1 = self.get_oauth1_token(&ticket).await?;
83
84 let oauth2 = self.exchange_oauth1_for_oauth2(&oauth1).await?;
86
87 Ok((oauth1, oauth2))
88 }
89
90 async fn init_session_and_get_csrf(&mut self) -> Result<String> {
92 let sso_base = format!("https://sso.{}/sso", self.domain);
93 let sso_embed = format!("{}/embed", sso_base);
94
95 let embed_params = [
97 ("id", "gauth-widget"),
98 ("embedWidget", "true"),
99 ("gauthHost", sso_base.as_str()),
100 ];
101
102 let resp = self
103 .client
104 .get(&sso_embed)
105 .query(&embed_params)
106 .header(USER_AGENT, API_USER_AGENT)
107 .send()
108 .await
109 .map_err(GarminError::Http)?;
110
111 let _ = resp.text().await;
113
114 let signin_url = format!("{}/signin", sso_base);
116 let signin_params = [
117 ("id", "gauth-widget"),
118 ("embedWidget", "true"),
119 ("gauthHost", sso_embed.as_str()),
120 ("service", sso_embed.as_str()),
121 ("source", sso_embed.as_str()),
122 ("redirectAfterAccountLoginUrl", sso_embed.as_str()),
123 ("redirectAfterAccountCreationUrl", sso_embed.as_str()),
124 ];
125
126 let response = self
127 .client
128 .get(&signin_url)
129 .query(&signin_params)
130 .header(USER_AGENT, API_USER_AGENT)
131 .send()
132 .await
133 .map_err(GarminError::Http)?;
134
135 self.last_url = Some(response.url().to_string());
136 let html = response.text().await.map_err(GarminError::Http)?;
137
138 extract_csrf_token(&html)
139 }
140
141 async fn submit_login(
143 &mut self,
144 email: &str,
145 password: &str,
146 csrf_token: &str,
147 ) -> Result<LoginResult> {
148 let sso_base = format!("https://sso.{}/sso", self.domain);
149 let sso_embed = format!("{}/embed", sso_base);
150 let signin_url = format!("{}/signin", sso_base);
151
152 let signin_params = [
153 ("id", "gauth-widget"),
154 ("embedWidget", "true"),
155 ("gauthHost", sso_embed.as_str()),
156 ("service", sso_embed.as_str()),
157 ("source", sso_embed.as_str()),
158 ("redirectAfterAccountLoginUrl", sso_embed.as_str()),
159 ("redirectAfterAccountCreationUrl", sso_embed.as_str()),
160 ];
161
162 let form_data = [
163 ("username", email),
164 ("password", password),
165 ("embed", "true"),
166 ("_csrf", csrf_token),
167 ];
168
169 let mut headers = HeaderMap::new();
170 headers.insert(USER_AGENT, HeaderValue::from_static(API_USER_AGENT));
171 if let Some(ref referer) = self.last_url {
172 headers.insert(REFERER, HeaderValue::from_str(referer).unwrap());
173 }
174 headers.insert(
175 CONTENT_TYPE,
176 HeaderValue::from_static("application/x-www-form-urlencoded"),
177 );
178
179 let response = self
180 .client
181 .post(&signin_url)
182 .query(&signin_params)
183 .headers(headers)
184 .form(&form_data)
185 .send()
186 .await
187 .map_err(GarminError::Http)?;
188
189 self.last_url = Some(response.url().to_string());
190 let html = response.text().await.map_err(GarminError::Http)?;
191
192 let title = extract_title(&html)?;
194
195 if title.contains("MFA") {
196 Ok(LoginResult::MfaRequired)
197 } else if title == "Success" {
198 let ticket = extract_ticket(&html)?;
199 Ok(LoginResult::Success(ticket))
200 } else {
201 Err(GarminError::auth(format!(
202 "Unexpected login response: {}",
203 title
204 )))
205 }
206 }
207
208 async fn submit_mfa(&mut self, mfa_code: &str, csrf_token: &str) -> Result<String> {
210 let sso_base = format!("https://sso.{}/sso", self.domain);
211 let sso_embed = format!("{}/embed", sso_base);
212 let mfa_url = format!("{}/verifyMFA/loginEnterMfaCode", sso_base);
213
214 let signin_params = [
215 ("id", "gauth-widget"),
216 ("embedWidget", "true"),
217 ("gauthHost", sso_embed.as_str()),
218 ("service", sso_embed.as_str()),
219 ("source", sso_embed.as_str()),
220 ("redirectAfterAccountLoginUrl", sso_embed.as_str()),
221 ("redirectAfterAccountCreationUrl", sso_embed.as_str()),
222 ];
223
224 let form_data = [
225 ("mfa-code", mfa_code),
226 ("embed", "true"),
227 ("_csrf", csrf_token),
228 ("fromPage", "setupEnterMfaCode"),
229 ];
230
231 let mut headers = HeaderMap::new();
232 headers.insert(USER_AGENT, HeaderValue::from_static(API_USER_AGENT));
233 if let Some(ref referer) = self.last_url {
234 headers.insert(REFERER, HeaderValue::from_str(referer).unwrap());
235 }
236
237 let response = self
238 .client
239 .post(&mfa_url)
240 .query(&signin_params)
241 .headers(headers)
242 .form(&form_data)
243 .send()
244 .await
245 .map_err(GarminError::Http)?;
246
247 let html = response.text().await.map_err(GarminError::Http)?;
248 let title = extract_title(&html)?;
249
250 if title == "Success" {
251 extract_ticket(&html)
252 } else {
253 Err(GarminError::auth(format!(
254 "MFA verification failed: {}",
255 title
256 )))
257 }
258 }
259
260 async fn get_oauth1_token(&self, ticket: &str) -> Result<OAuth1Token> {
262 let consumer = self.fetch_oauth_consumer().await?;
264
265 let base_url = format!("https://connectapi.{}/oauth-service/oauth/", self.domain);
266 let login_url = format!("https://sso.{}/sso/embed", self.domain);
267 let url = format!(
268 "{}preauthorized?ticket={}&login-url={}&accepts-mfa-tokens=true",
269 base_url, ticket, login_url
270 );
271
272 let signer = OAuth1Signer::new(OAuthConsumer {
274 key: consumer.consumer_key.clone(),
275 secret: consumer.consumer_secret.clone(),
276 });
277
278 let auth_header = signer.sign("GET", &url, &[]);
279
280 let oauth_client = Client::builder()
283 .timeout(std::time::Duration::from_secs(30))
284 .build()
285 .map_err(GarminError::Http)?;
286
287 let response = oauth_client
288 .get(&url)
289 .header(USER_AGENT, MOBILE_USER_AGENT)
290 .header("Authorization", auth_header)
291 .send()
292 .await
293 .map_err(GarminError::Http)?;
294
295 let status = response.status();
296
297 if !status.is_success() {
298 return Err(GarminError::auth(format!(
299 "Failed to get OAuth1 token: {}",
300 status
301 )));
302 }
303
304 let body = response.text().await.map_err(GarminError::Http)?;
305 let params = parse_oauth_response(&body);
306
307 let oauth_token = params
308 .get("oauth_token")
309 .ok_or_else(|| GarminError::invalid_response("Missing oauth_token"))?
310 .clone();
311 let oauth_token_secret = params
312 .get("oauth_token_secret")
313 .ok_or_else(|| GarminError::invalid_response("Missing oauth_token_secret"))?
314 .clone();
315 let mfa_token = params.get("mfa_token").cloned();
316
317 let mut token = OAuth1Token::new(oauth_token, oauth_token_secret).with_domain(&self.domain);
318
319 if let Some(mfa) = mfa_token {
320 token = token.with_mfa(mfa, None);
321 }
322
323 Ok(token)
324 }
325
326 async fn exchange_oauth1_for_oauth2(&self, oauth1: &OAuth1Token) -> Result<OAuth2Token> {
328 let consumer = self.fetch_oauth_consumer().await?;
329
330 let url = format!(
331 "https://connectapi.{}/oauth-service/oauth/exchange/user/2.0",
332 self.domain
333 );
334
335 let signer = OAuth1Signer::new(OAuthConsumer {
337 key: consumer.consumer_key.clone(),
338 secret: consumer.consumer_secret.clone(),
339 })
340 .with_token(OAuthToken {
341 token: oauth1.oauth_token.clone(),
342 secret: oauth1.oauth_token_secret.clone(),
343 });
344
345 let params: Vec<(String, String)> = if let Some(ref mfa_token) = oauth1.mfa_token {
346 vec![("mfa_token".to_string(), mfa_token.clone())]
347 } else {
348 vec![]
349 };
350
351 let auth_header = signer.sign("POST", &url, ¶ms);
352
353 let oauth_client = Client::builder()
355 .timeout(std::time::Duration::from_secs(30))
356 .build()
357 .map_err(GarminError::Http)?;
358
359 let mut request = oauth_client
360 .post(&url)
361 .header(USER_AGENT, MOBILE_USER_AGENT)
362 .header("Authorization", auth_header)
363 .header(CONTENT_TYPE, "application/x-www-form-urlencoded");
364
365 if let Some(ref mfa_token) = oauth1.mfa_token {
366 request = request.form(&[("mfa_token", mfa_token)]);
367 }
368
369 let response = request.send().await.map_err(GarminError::Http)?;
370
371 let status = response.status();
372
373 if !status.is_success() {
374 return Err(GarminError::auth(format!(
375 "Failed to exchange OAuth1 for OAuth2: {}",
376 status
377 )));
378 }
379
380 let mut token: OAuth2Token = response.json().await.map_err(|e| {
381 GarminError::invalid_response(format!("Failed to parse OAuth2 token: {}", e))
382 })?;
383
384 let now = SystemTime::now()
386 .duration_since(UNIX_EPOCH)
387 .unwrap()
388 .as_secs() as i64;
389 token.expires_at = now + token.expires_in;
390 token.refresh_token_expires_at = now + token.refresh_token_expires_in;
391
392 Ok(token)
393 }
394
395 async fn fetch_oauth_consumer(&self) -> Result<OAuthConsumerResponse> {
397 let response = self
398 .client
399 .get(OAUTH_CONSUMER_URL)
400 .send()
401 .await
402 .map_err(GarminError::Http)?;
403
404 response.json().await.map_err(|e| {
405 GarminError::invalid_response(format!("Failed to parse OAuth consumer: {}", e))
406 })
407 }
408
409 pub async fn refresh_oauth2(&self, oauth1: &OAuth1Token) -> Result<OAuth2Token> {
411 self.exchange_oauth1_for_oauth2(oauth1).await
412 }
413}
414
415enum LoginResult {
417 Success(String), MfaRequired,
419}
420
421fn extract_csrf_token(html: &str) -> Result<String> {
423 let re = Regex::new(r#"name="_csrf"\s+value="([^"]+)""#).unwrap();
424 re.captures(html)
425 .and_then(|caps| caps.get(1))
426 .map(|m| m.as_str().to_string())
427 .ok_or_else(|| GarminError::invalid_response("Could not find CSRF token"))
428}
429
430fn extract_title(html: &str) -> Result<String> {
432 let re = Regex::new(r"<title>([^<]+)</title>").unwrap();
433 re.captures(html)
434 .and_then(|caps| caps.get(1))
435 .map(|m| m.as_str().to_string())
436 .ok_or_else(|| GarminError::invalid_response("Could not find page title"))
437}
438
439fn extract_ticket(html: &str) -> Result<String> {
441 let re = Regex::new(r#"embed\?ticket=([^"]+)""#).unwrap();
442 re.captures(html)
443 .and_then(|caps| caps.get(1))
444 .map(|m| m.as_str().to_string())
445 .ok_or_else(|| GarminError::invalid_response("Could not find ticket in response"))
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451
452 #[test]
453 fn test_extract_csrf_token() {
454 let html = r#"<input type="hidden" name="_csrf" value="abc123token">"#;
455 let token = extract_csrf_token(html).unwrap();
456 assert_eq!(token, "abc123token");
457 }
458
459 #[test]
460 fn test_extract_csrf_token_missing() {
461 let html = r#"<html><body>No token here</body></html>"#;
462 let result = extract_csrf_token(html);
463 assert!(result.is_err());
464 }
465
466 #[test]
467 fn test_extract_title() {
468 let html = r#"<html><head><title>Success</title></head></html>"#;
469 let title = extract_title(html).unwrap();
470 assert_eq!(title, "Success");
471 }
472
473 #[test]
474 fn test_extract_title_mfa() {
475 let html = r#"<html><head><title>GARMIN > MFA Challenge</title></head></html>"#;
476 let title = extract_title(html).unwrap();
477 assert!(title.contains("MFA"));
478 }
479
480 #[test]
481 fn test_extract_ticket() {
482 let html = r#"<a href="embed?ticket=ST-12345-abc">Continue</a>"#;
483 let ticket = extract_ticket(html).unwrap();
484 assert_eq!(ticket, "ST-12345-abc");
485 }
486
487 #[test]
488 fn test_extract_ticket_missing() {
489 let html = r#"<html><body>No ticket</body></html>"#;
490 let result = extract_ticket(html);
491 assert!(result.is_err());
492 }
493
494 #[test]
495 fn test_sso_client_creation() {
496 let client = SsoClient::new(None);
497 assert!(client.is_ok());
498 }
499
500 #[test]
501 fn test_sso_client_with_custom_domain() {
502 let client = SsoClient::new(Some("garmin.cn")).unwrap();
503 assert_eq!(client.domain, "garmin.cn");
504 }
505}