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
77 .ok_or_else(|| GarminError::MfaRequired)?();
78 self.submit_mfa(&mfa_code, &csrf_token).await?
79 }
80 };
81
82 let oauth1 = self.get_oauth1_token(&ticket).await?;
84
85 let oauth2 = self.exchange_oauth1_for_oauth2(&oauth1).await?;
87
88 Ok((oauth1, oauth2))
89 }
90
91 async fn init_session_and_get_csrf(&mut self) -> Result<String> {
93 let sso_base = format!("https://sso.{}/sso", self.domain);
94 let sso_embed = format!("{}/embed", sso_base);
95
96 let embed_params = [
98 ("id", "gauth-widget"),
99 ("embedWidget", "true"),
100 ("gauthHost", sso_base.as_str()),
101 ];
102
103 let resp = self.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!("Unexpected login response: {}", title)))
202 }
203 }
204
205 async fn submit_mfa(&mut self, mfa_code: &str, csrf_token: &str) -> Result<String> {
207 let sso_base = format!("https://sso.{}/sso", self.domain);
208 let sso_embed = format!("{}/embed", sso_base);
209 let mfa_url = format!("{}/verifyMFA/loginEnterMfaCode", sso_base);
210
211 let signin_params = [
212 ("id", "gauth-widget"),
213 ("embedWidget", "true"),
214 ("gauthHost", sso_embed.as_str()),
215 ("service", sso_embed.as_str()),
216 ("source", sso_embed.as_str()),
217 ("redirectAfterAccountLoginUrl", sso_embed.as_str()),
218 ("redirectAfterAccountCreationUrl", sso_embed.as_str()),
219 ];
220
221 let form_data = [
222 ("mfa-code", mfa_code),
223 ("embed", "true"),
224 ("_csrf", csrf_token),
225 ("fromPage", "setupEnterMfaCode"),
226 ];
227
228 let mut headers = HeaderMap::new();
229 headers.insert(USER_AGENT, HeaderValue::from_static(API_USER_AGENT));
230 if let Some(ref referer) = self.last_url {
231 headers.insert(REFERER, HeaderValue::from_str(referer).unwrap());
232 }
233
234 let response = self
235 .client
236 .post(&mfa_url)
237 .query(&signin_params)
238 .headers(headers)
239 .form(&form_data)
240 .send()
241 .await
242 .map_err(GarminError::Http)?;
243
244 let html = response.text().await.map_err(GarminError::Http)?;
245 let title = extract_title(&html)?;
246
247 if title == "Success" {
248 extract_ticket(&html)
249 } else {
250 Err(GarminError::auth(format!("MFA verification failed: {}", title)))
251 }
252 }
253
254 async fn get_oauth1_token(&self, ticket: &str) -> Result<OAuth1Token> {
256 let consumer = self.fetch_oauth_consumer().await?;
258
259 let base_url = format!(
260 "https://connectapi.{}/oauth-service/oauth/",
261 self.domain
262 );
263 let login_url = format!("https://sso.{}/sso/embed", self.domain);
264 let url = format!(
265 "{}preauthorized?ticket={}&login-url={}&accepts-mfa-tokens=true",
266 base_url, ticket, login_url
267 );
268
269 let signer = OAuth1Signer::new(OAuthConsumer {
271 key: consumer.consumer_key.clone(),
272 secret: consumer.consumer_secret.clone(),
273 });
274
275 let auth_header = signer.sign("GET", &url, &[]);
276
277 let oauth_client = Client::builder()
280 .timeout(std::time::Duration::from_secs(30))
281 .build()
282 .map_err(GarminError::Http)?;
283
284 let response = oauth_client
285 .get(&url)
286 .header(USER_AGENT, MOBILE_USER_AGENT)
287 .header("Authorization", auth_header)
288 .send()
289 .await
290 .map_err(GarminError::Http)?;
291
292 let status = response.status();
293
294 if !status.is_success() {
295 return Err(GarminError::auth(format!(
296 "Failed to get OAuth1 token: {}",
297 status
298 )));
299 }
300
301 let body = response.text().await.map_err(GarminError::Http)?;
302 let params = parse_oauth_response(&body);
303
304 let oauth_token = params
305 .get("oauth_token")
306 .ok_or_else(|| GarminError::invalid_response("Missing oauth_token"))?
307 .clone();
308 let oauth_token_secret = params
309 .get("oauth_token_secret")
310 .ok_or_else(|| GarminError::invalid_response("Missing oauth_token_secret"))?
311 .clone();
312 let mfa_token = params.get("mfa_token").cloned();
313
314 let mut token = OAuth1Token::new(oauth_token, oauth_token_secret)
315 .with_domain(&self.domain);
316
317 if let Some(mfa) = mfa_token {
318 token = token.with_mfa(mfa, None);
319 }
320
321 Ok(token)
322 }
323
324 async fn exchange_oauth1_for_oauth2(&self, oauth1: &OAuth1Token) -> Result<OAuth2Token> {
326 let consumer = self.fetch_oauth_consumer().await?;
327
328 let url = format!(
329 "https://connectapi.{}/oauth-service/oauth/exchange/user/2.0",
330 self.domain
331 );
332
333 let signer = OAuth1Signer::new(OAuthConsumer {
335 key: consumer.consumer_key.clone(),
336 secret: consumer.consumer_secret.clone(),
337 })
338 .with_token(OAuthToken {
339 token: oauth1.oauth_token.clone(),
340 secret: oauth1.oauth_token_secret.clone(),
341 });
342
343 let params: Vec<(String, String)> = if let Some(ref mfa_token) = oauth1.mfa_token {
344 vec![("mfa_token".to_string(), mfa_token.clone())]
345 } else {
346 vec![]
347 };
348
349 let auth_header = signer.sign("POST", &url, ¶ms);
350
351 let oauth_client = Client::builder()
353 .timeout(std::time::Duration::from_secs(30))
354 .build()
355 .map_err(GarminError::Http)?;
356
357 let mut request = oauth_client
358 .post(&url)
359 .header(USER_AGENT, MOBILE_USER_AGENT)
360 .header("Authorization", auth_header)
361 .header(CONTENT_TYPE, "application/x-www-form-urlencoded");
362
363 if let Some(ref mfa_token) = oauth1.mfa_token {
364 request = request.form(&[("mfa_token", mfa_token)]);
365 }
366
367 let response = request.send().await.map_err(GarminError::Http)?;
368
369 let status = response.status();
370
371 if !status.is_success() {
372 return Err(GarminError::auth(format!(
373 "Failed to exchange OAuth1 for OAuth2: {}",
374 status
375 )));
376 }
377
378 let mut token: OAuth2Token = response.json().await
379 .map_err(|e| GarminError::invalid_response(format!("Failed to parse OAuth2 token: {}", e)))?;
380
381 let now = SystemTime::now()
383 .duration_since(UNIX_EPOCH)
384 .unwrap()
385 .as_secs() as i64;
386 token.expires_at = now + token.expires_in;
387 token.refresh_token_expires_at = now + token.refresh_token_expires_in;
388
389 Ok(token)
390 }
391
392 async fn fetch_oauth_consumer(&self) -> Result<OAuthConsumerResponse> {
394 let response = self
395 .client
396 .get(OAUTH_CONSUMER_URL)
397 .send()
398 .await
399 .map_err(GarminError::Http)?;
400
401 response
402 .json()
403 .await
404 .map_err(|e| GarminError::invalid_response(format!("Failed to parse OAuth consumer: {}", e)))
405 }
406
407 pub async fn refresh_oauth2(&self, oauth1: &OAuth1Token) -> Result<OAuth2Token> {
409 self.exchange_oauth1_for_oauth2(oauth1).await
410 }
411}
412
413enum LoginResult {
415 Success(String), MfaRequired,
417}
418
419fn extract_csrf_token(html: &str) -> Result<String> {
421 let re = Regex::new(r#"name="_csrf"\s+value="([^"]+)""#).unwrap();
422 re.captures(html)
423 .and_then(|caps| caps.get(1))
424 .map(|m| m.as_str().to_string())
425 .ok_or_else(|| GarminError::invalid_response("Could not find CSRF token"))
426}
427
428fn extract_title(html: &str) -> Result<String> {
430 let re = Regex::new(r"<title>([^<]+)</title>").unwrap();
431 re.captures(html)
432 .and_then(|caps| caps.get(1))
433 .map(|m| m.as_str().to_string())
434 .ok_or_else(|| GarminError::invalid_response("Could not find page title"))
435}
436
437fn extract_ticket(html: &str) -> Result<String> {
439 let re = Regex::new(r#"embed\?ticket=([^"]+)""#).unwrap();
440 re.captures(html)
441 .and_then(|caps| caps.get(1))
442 .map(|m| m.as_str().to_string())
443 .ok_or_else(|| GarminError::invalid_response("Could not find ticket in response"))
444}
445
446#[cfg(test)]
447mod tests {
448 use super::*;
449
450 #[test]
451 fn test_extract_csrf_token() {
452 let html = r#"<input type="hidden" name="_csrf" value="abc123token">"#;
453 let token = extract_csrf_token(html).unwrap();
454 assert_eq!(token, "abc123token");
455 }
456
457 #[test]
458 fn test_extract_csrf_token_missing() {
459 let html = r#"<html><body>No token here</body></html>"#;
460 let result = extract_csrf_token(html);
461 assert!(result.is_err());
462 }
463
464 #[test]
465 fn test_extract_title() {
466 let html = r#"<html><head><title>Success</title></head></html>"#;
467 let title = extract_title(html).unwrap();
468 assert_eq!(title, "Success");
469 }
470
471 #[test]
472 fn test_extract_title_mfa() {
473 let html = r#"<html><head><title>GARMIN > MFA Challenge</title></head></html>"#;
474 let title = extract_title(html).unwrap();
475 assert!(title.contains("MFA"));
476 }
477
478 #[test]
479 fn test_extract_ticket() {
480 let html = r#"<a href="embed?ticket=ST-12345-abc">Continue</a>"#;
481 let ticket = extract_ticket(html).unwrap();
482 assert_eq!(ticket, "ST-12345-abc");
483 }
484
485 #[test]
486 fn test_extract_ticket_missing() {
487 let html = r#"<html><body>No ticket</body></html>"#;
488 let result = extract_ticket(html);
489 assert!(result.is_err());
490 }
491
492 #[test]
493 fn test_sso_client_creation() {
494 let client = SsoClient::new(None);
495 assert!(client.is_ok());
496 }
497
498 #[test]
499 fn test_sso_client_with_custom_domain() {
500 let client = SsoClient::new(Some("garmin.cn")).unwrap();
501 assert_eq!(client.domain, "garmin.cn");
502 }
503}