1use crate::client::oauth1::{parse_oauth_response, OAuth1Signer, OAuthConsumer, OAuthToken};
20use crate::client::tokens::{OAuth1Token, OAuth2Token};
21use crate::error::{GarminError, Result};
22use reqwest::cookie::Jar;
23use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE, USER_AGENT};
24use reqwest::Client;
25use serde::{Deserialize, Serialize};
26use std::sync::Arc;
27use std::time::{SystemTime, UNIX_EPOCH};
28
29const DEFAULT_DOMAIN: &str = "garmin.com";
31
32const CLIENT_ID: &str = "GCM_ANDROID_DARK";
34
35const MOBILE_USER_AGENT: &str = "com.garmin.android.apps.connectmobile";
37
38const SSO_USER_AGENT: &str = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148";
40
41const OAUTH_CONSUMER_URL: &str = "https://thegarth.s3.amazonaws.com/oauth_consumer.json";
43
44#[derive(Debug, Deserialize)]
46struct OAuthConsumerResponse {
47 consumer_key: String,
48 consumer_secret: String,
49}
50
51#[derive(Serialize)]
53#[serde(rename_all = "camelCase")]
54struct LoginRequest<'a> {
55 username: &'a str,
56 password: &'a str,
57 remember_me: bool,
58 captcha_token: &'a str,
59}
60
61#[derive(Serialize)]
63#[serde(rename_all = "camelCase")]
64struct MfaVerifyRequest<'a> {
65 mfa_method: &'a str,
66 mfa_verification_code: &'a str,
67 remember_my_browser: bool,
68 reconsent_list: Vec<String>,
69 mfa_setup: bool,
70}
71
72#[derive(Debug, Deserialize)]
74#[serde(rename_all = "camelCase")]
75struct SsoResponseStatus {
76 #[serde(rename = "type")]
77 response_type: String,
78 #[serde(default)]
79 message: String,
80}
81
82#[derive(Debug, Deserialize)]
84#[serde(rename_all = "camelCase")]
85struct SsoResponse {
86 #[serde(default)]
87 response_status: Option<SsoResponseStatus>,
88 #[serde(default)]
89 service_ticket_id: Option<String>,
90 #[serde(default)]
91 customer_mfa_info: Option<MfaInfo>,
92}
93
94#[derive(Debug, Deserialize)]
96#[serde(rename_all = "camelCase")]
97struct MfaInfo {
98 #[serde(default)]
99 mfa_last_method_used: Option<String>,
100}
101
102pub struct SsoClient {
104 client: Client,
105 domain: String,
106}
107
108impl SsoClient {
109 pub fn new(domain: Option<&str>) -> Result<Self> {
111 let cookie_jar = Arc::new(Jar::default());
112 let client = Client::builder()
113 .cookie_provider(cookie_jar)
114 .timeout(std::time::Duration::from_secs(30))
115 .build()
116 .map_err(GarminError::Http)?;
117
118 Ok(Self {
119 client,
120 domain: domain.unwrap_or(DEFAULT_DOMAIN).to_string(),
121 })
122 }
123
124 fn sso_headers() -> HeaderMap {
126 let mut headers = HeaderMap::new();
127 headers.insert(USER_AGENT, HeaderValue::from_static(SSO_USER_AGENT));
128 headers.insert(
129 "Accept",
130 HeaderValue::from_static(
131 "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
132 ),
133 );
134 headers.insert(
135 "Accept-Language",
136 HeaderValue::from_static("en-US,en;q=0.9"),
137 );
138 headers.insert("Sec-Fetch-Mode", HeaderValue::from_static("navigate"));
139 headers.insert("Sec-Fetch-Dest", HeaderValue::from_static("document"));
140 headers
141 }
142
143 pub async fn login(
145 &mut self,
146 email: &str,
147 password: &str,
148 mfa_callback: Option<impl FnOnce() -> String>,
149 ) -> Result<(OAuth1Token, OAuth2Token)> {
150 let service_url = format!("https://mobile.integration.{}/gcm/android", self.domain);
151 let login_params = [
152 ("clientId", CLIENT_ID),
153 ("locale", "en-US"),
154 ("service", service_url.as_str()),
155 ];
156
157 let sign_in_url = format!("https://sso.{}/mobile/sso/en/sign-in", self.domain);
159 let mut headers = Self::sso_headers();
160 headers.insert("Sec-Fetch-Site", HeaderValue::from_static("none"));
161
162 let _ = self
163 .client
164 .get(&sign_in_url)
165 .query(&[("clientId", CLIENT_ID)])
166 .headers(headers)
167 .send()
168 .await
169 .map_err(GarminError::Http)?
170 .text()
171 .await;
172
173 let login_url = format!("https://sso.{}/mobile/api/login", self.domain);
175 let login_body = LoginRequest {
176 username: email,
177 password,
178 remember_me: false,
179 captcha_token: "",
180 };
181
182 let response = self
183 .client
184 .post(&login_url)
185 .query(&login_params)
186 .headers(Self::sso_headers())
187 .json(&login_body)
188 .send()
189 .await
190 .map_err(GarminError::Http)?;
191
192 let status_code = response.status();
193 if status_code.as_u16() == 429 {
194 return Err(GarminError::auth(
195 "Rate limited by Garmin (429). Too many login attempts. Wait 15-30 minutes and try again.".to_string()
196 ));
197 }
198 if !status_code.is_success() && status_code.as_u16() != 200 {
199 let body = response.text().await.unwrap_or_default();
200 return Err(GarminError::auth(format!(
201 "SSO HTTP {}: {}",
202 status_code,
203 &body[..body.len().min(200)]
204 )));
205 }
206
207 let body_text = response.text().await.map_err(GarminError::Http)?;
208
209 let sso_resp: SsoResponse = serde_json::from_str(&body_text).map_err(|e| {
210 GarminError::invalid_response(format!(
211 "Failed to parse SSO response: {} | body: {}",
212 e,
213 &body_text[..body_text.len().min(200)]
214 ))
215 })?;
216
217 let resp_type = sso_resp
218 .response_status
219 .as_ref()
220 .map(|s| s.response_type.as_str())
221 .unwrap_or("UNKNOWN");
222
223 let ticket = match resp_type {
224 "SUCCESSFUL" => sso_resp
225 .service_ticket_id
226 .ok_or_else(|| GarminError::invalid_response("Missing serviceTicketId"))?,
227
228 "MFA_REQUIRED" => {
229 let mfa_method = sso_resp
230 .customer_mfa_info
231 .and_then(|info| info.mfa_last_method_used)
232 .unwrap_or_else(|| "email".to_string());
233
234 let mfa_code = mfa_callback.ok_or_else(|| GarminError::MfaRequired)?();
235
236 self.submit_mfa(&mfa_code, &mfa_method, &login_params)
237 .await?
238 }
239
240 _ => {
241 let message = sso_resp
242 .response_status
243 .map(|s| {
244 if s.message.is_empty() {
245 s.response_type
246 } else {
247 format!("{}: {}", s.response_type, s.message)
248 }
249 })
250 .unwrap_or_else(|| "Unknown error".to_string());
251 return Err(GarminError::auth(format!("SSO error: {}", message)));
252 }
253 };
254
255 self.complete_login(&ticket).await
257 }
258
259 async fn submit_mfa(
261 &self,
262 mfa_code: &str,
263 mfa_method: &str,
264 login_params: &[(&str, &str)],
265 ) -> Result<String> {
266 let mfa_url = format!("https://sso.{}/mobile/api/mfa/verifyCode", self.domain);
267 let mfa_body = MfaVerifyRequest {
268 mfa_method,
269 mfa_verification_code: mfa_code,
270 remember_my_browser: false,
271 reconsent_list: vec![],
272 mfa_setup: false,
273 };
274
275 let response = self
276 .client
277 .post(&mfa_url)
278 .query(login_params)
279 .headers(Self::sso_headers())
280 .json(&mfa_body)
281 .send()
282 .await
283 .map_err(GarminError::Http)?;
284
285 let sso_resp: SsoResponse = response.json().await.map_err(|e| {
286 GarminError::invalid_response(format!("Failed to parse MFA response: {}", e))
287 })?;
288
289 let resp_type = sso_resp
290 .response_status
291 .as_ref()
292 .map(|s| s.response_type.as_str())
293 .unwrap_or("UNKNOWN");
294
295 if resp_type != "SUCCESSFUL" {
296 let message = sso_resp
297 .response_status
298 .map(|s| s.message)
299 .unwrap_or_default();
300 return Err(GarminError::auth(format!(
301 "MFA verification failed: {}",
302 message
303 )));
304 }
305
306 sso_resp
307 .service_ticket_id
308 .ok_or_else(|| GarminError::invalid_response("Missing serviceTicketId after MFA"))
309 }
310
311 async fn complete_login(&self, ticket: &str) -> Result<(OAuth1Token, OAuth2Token)> {
313 let portal_url = format!("https://sso.{}/portal/sso/embed", self.domain);
315 let mut headers = Self::sso_headers();
316 headers.insert("Sec-Fetch-Site", HeaderValue::from_static("same-origin"));
317 let _ = self.client.get(&portal_url).headers(headers).send().await;
318
319 let oauth1 = self.get_oauth1_token(ticket).await?;
321
322 let oauth2 = self.exchange_oauth1_for_oauth2(&oauth1, true).await?;
324
325 Ok((oauth1, oauth2))
326 }
327
328 async fn get_oauth1_token(&self, ticket: &str) -> Result<OAuth1Token> {
330 let consumer = self.fetch_oauth_consumer().await?;
331
332 let base_url = format!("https://connectapi.{}/oauth-service/oauth/", self.domain);
333 let login_url = format!("https://mobile.integration.{}/gcm/android", self.domain);
334 let url = format!(
335 "{}preauthorized?ticket={}&login-url={}&accepts-mfa-tokens=true",
336 base_url, ticket, login_url
337 );
338
339 let signer = OAuth1Signer::new(OAuthConsumer {
340 key: consumer.consumer_key.clone(),
341 secret: consumer.consumer_secret.clone(),
342 });
343
344 let auth_header = signer.sign("GET", &url, &[]);
345
346 let oauth_client = Client::builder()
348 .timeout(std::time::Duration::from_secs(30))
349 .build()
350 .map_err(GarminError::Http)?;
351
352 let response = oauth_client
353 .get(&url)
354 .header(USER_AGENT, MOBILE_USER_AGENT)
355 .header("Authorization", auth_header)
356 .send()
357 .await
358 .map_err(GarminError::Http)?;
359
360 let status = response.status();
361 if !status.is_success() {
362 return Err(GarminError::auth(format!(
363 "Failed to get OAuth1 token: {}",
364 status
365 )));
366 }
367
368 let body = response.text().await.map_err(GarminError::Http)?;
369 let params = parse_oauth_response(&body);
370
371 let oauth_token = params
372 .get("oauth_token")
373 .ok_or_else(|| GarminError::invalid_response("Missing oauth_token"))?
374 .clone();
375 let oauth_token_secret = params
376 .get("oauth_token_secret")
377 .ok_or_else(|| GarminError::invalid_response("Missing oauth_token_secret"))?
378 .clone();
379 let mfa_token = params.get("mfa_token").cloned();
380
381 let mut token = OAuth1Token::new(oauth_token, oauth_token_secret).with_domain(&self.domain);
382
383 if let Some(mfa) = mfa_token {
384 token = token.with_mfa(mfa, None);
385 }
386
387 Ok(token)
388 }
389
390 async fn exchange_oauth1_for_oauth2(
392 &self,
393 oauth1: &OAuth1Token,
394 login: bool,
395 ) -> Result<OAuth2Token> {
396 let consumer = self.fetch_oauth_consumer().await?;
397
398 let url = format!(
399 "https://connectapi.{}/oauth-service/oauth/exchange/user/2.0",
400 self.domain
401 );
402
403 let signer = OAuth1Signer::new(OAuthConsumer {
404 key: consumer.consumer_key.clone(),
405 secret: consumer.consumer_secret.clone(),
406 })
407 .with_token(OAuthToken {
408 token: oauth1.oauth_token.clone(),
409 secret: oauth1.oauth_token_secret.clone(),
410 });
411
412 let mut form_params: Vec<(String, String)> = vec![];
413 if login {
414 form_params.push((
415 "audience".to_string(),
416 "GARMIN_CONNECT_MOBILE_ANDROID_DI".to_string(),
417 ));
418 }
419 if let Some(ref mfa_token) = oauth1.mfa_token {
420 form_params.push(("mfa_token".to_string(), mfa_token.clone()));
421 }
422
423 let auth_header = signer.sign("POST", &url, &form_params);
424
425 let oauth_client = Client::builder()
426 .timeout(std::time::Duration::from_secs(30))
427 .build()
428 .map_err(GarminError::Http)?;
429
430 let mut request = oauth_client
431 .post(&url)
432 .header(USER_AGENT, MOBILE_USER_AGENT)
433 .header("Authorization", auth_header)
434 .header(CONTENT_TYPE, "application/x-www-form-urlencoded");
435
436 if !form_params.is_empty() {
437 request = request.form(&form_params);
438 }
439
440 let response = request.send().await.map_err(GarminError::Http)?;
441
442 let status = response.status();
443 if !status.is_success() {
444 return Err(GarminError::auth(format!(
445 "Failed to exchange OAuth1 for OAuth2: {}",
446 status
447 )));
448 }
449
450 let mut token: OAuth2Token = response.json().await.map_err(|e| {
451 GarminError::invalid_response(format!("Failed to parse OAuth2 token: {}", e))
452 })?;
453
454 let now = SystemTime::now()
455 .duration_since(UNIX_EPOCH)
456 .unwrap()
457 .as_secs() as i64;
458 token.expires_at = now + token.expires_in;
459 token.refresh_token_expires_at = now + token.refresh_token_expires_in;
460
461 Ok(token)
462 }
463
464 async fn fetch_oauth_consumer(&self) -> Result<OAuthConsumerResponse> {
466 let response = self
467 .client
468 .get(OAUTH_CONSUMER_URL)
469 .send()
470 .await
471 .map_err(GarminError::Http)?;
472
473 response.json().await.map_err(|e| {
474 GarminError::invalid_response(format!("Failed to parse OAuth consumer: {}", e))
475 })
476 }
477
478 pub async fn refresh_oauth2(&self, oauth1: &OAuth1Token) -> Result<OAuth2Token> {
480 self.exchange_oauth1_for_oauth2(oauth1, false).await
481 }
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487
488 #[test]
489 fn test_sso_client_creation() {
490 let client = SsoClient::new(None);
491 assert!(client.is_ok());
492 }
493
494 #[test]
495 fn test_sso_client_with_custom_domain() {
496 let client = SsoClient::new(Some("garmin.cn")).unwrap();
497 assert_eq!(client.domain, "garmin.cn");
498 }
499
500 #[test]
501 fn test_parse_successful_sso_response() {
502 let json = r#"{
503 "responseStatus": {"type": "SUCCESSFUL", "message": ""},
504 "serviceTicketId": "ST-12345-abc"
505 }"#;
506 let resp: SsoResponse = serde_json::from_str(json).unwrap();
507 assert_eq!(resp.response_status.unwrap().response_type, "SUCCESSFUL");
508 assert_eq!(resp.service_ticket_id.unwrap(), "ST-12345-abc");
509 }
510
511 #[test]
512 fn test_parse_mfa_required_response() {
513 let json = r#"{
514 "responseStatus": {"type": "MFA_REQUIRED", "message": ""},
515 "customerMfaInfo": {"mfaLastMethodUsed": "email"}
516 }"#;
517 let resp: SsoResponse = serde_json::from_str(json).unwrap();
518 assert_eq!(resp.response_status.unwrap().response_type, "MFA_REQUIRED");
519 assert_eq!(
520 resp.customer_mfa_info
521 .unwrap()
522 .mfa_last_method_used
523 .unwrap(),
524 "email"
525 );
526 }
527
528 #[test]
529 fn test_parse_failed_sso_response() {
530 let json = r#"{
531 "responseStatus": {"type": "FAIL", "message": "Invalid credentials"}
532 }"#;
533 let resp: SsoResponse = serde_json::from_str(json).unwrap();
534 let status = resp.response_status.unwrap();
535 assert_eq!(status.response_type, "FAIL");
536 assert_eq!(status.message, "Invalid credentials");
537 }
538
539 #[test]
540 fn test_parse_missing_response_status() {
541 let json = r#"{}"#;
543 let resp: SsoResponse = serde_json::from_str(json).unwrap();
544 assert!(resp.response_status.is_none());
545 assert!(resp.service_ticket_id.is_none());
546 }
547}