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}
106
107impl SsoClient {
108 pub fn new() -> Result<Self> {
110 let cookie_jar = Arc::new(Jar::default());
111 let client = Client::builder()
112 .cookie_provider(cookie_jar)
113 .timeout(std::time::Duration::from_secs(30))
114 .build()
115 .map_err(GarminError::Http)?;
116
117 Ok(Self { client })
118 }
119
120 fn sso_page_headers() -> HeaderMap {
122 let mut headers = HeaderMap::new();
123 headers.insert(USER_AGENT, HeaderValue::from_static(SSO_USER_AGENT));
124 headers.insert(
125 "Accept",
126 HeaderValue::from_static(
127 "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
128 ),
129 );
130 headers.insert(
131 "Accept-Language",
132 HeaderValue::from_static("en-US,en;q=0.9"),
133 );
134 headers.insert("Sec-Fetch-Mode", HeaderValue::from_static("navigate"));
135 headers.insert("Sec-Fetch-Dest", HeaderValue::from_static("document"));
136 headers
137 }
138
139 fn sso_api_headers() -> HeaderMap {
141 let mut headers = HeaderMap::new();
142 headers.insert(USER_AGENT, HeaderValue::from_static(MOBILE_USER_AGENT));
143 headers.insert(
144 "Accept",
145 HeaderValue::from_static("application/json, text/plain, */*"),
146 );
147 headers.insert(
148 "Accept-Language",
149 HeaderValue::from_static("en-US,en;q=0.9"),
150 );
151 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
152 headers.insert("Origin", HeaderValue::from_static("https://sso.garmin.com"));
153 headers.insert(
154 "Referer",
155 HeaderValue::from_static("https://sso.garmin.com/mobile/sso/en/sign-in"),
156 );
157 headers
158 }
159
160 pub async fn login(
162 &mut self,
163 email: &str,
164 password: &str,
165 mfa_callback: Option<impl FnOnce() -> String>,
166 ) -> Result<(OAuth1Token, OAuth2Token)> {
167 let service_url = format!("https://mobile.integration.{}/gcm/android", DEFAULT_DOMAIN);
168 let login_params = [
169 ("clientId", CLIENT_ID),
170 ("locale", "en-US"),
171 ("service", service_url.as_str()),
172 ];
173
174 let sign_in_url = format!("https://sso.{}/mobile/sso/en/sign-in", DEFAULT_DOMAIN);
176 let mut headers = Self::sso_page_headers();
177 headers.insert("Sec-Fetch-Site", HeaderValue::from_static("none"));
178
179 let _ = self
180 .client
181 .get(&sign_in_url)
182 .query(&[("clientId", CLIENT_ID)])
183 .headers(headers)
184 .send()
185 .await
186 .map_err(GarminError::Http)?
187 .text()
188 .await;
189
190 let login_url = format!("https://sso.{}/mobile/api/login", DEFAULT_DOMAIN);
192 let login_body = LoginRequest {
193 username: email,
194 password,
195 remember_me: false,
196 captcha_token: "",
197 };
198
199 let response = self
200 .client
201 .post(&login_url)
202 .query(&login_params)
203 .headers(Self::sso_api_headers())
204 .json(&login_body)
205 .send()
206 .await
207 .map_err(GarminError::Http)?;
208
209 let status_code = response.status();
210 let body_text = response.text().await.unwrap_or_default();
211
212 if status_code.as_u16() == 429 {
213 return Err(GarminError::auth(format!(
214 "Rate limited by Garmin (429). Response body: {}",
215 &body_text[..body_text.len().min(400)]
216 )));
217 }
218 if !status_code.is_success() && status_code.as_u16() != 200 {
219 return Err(GarminError::auth(format!(
220 "SSO HTTP {}. Response body: {}",
221 status_code,
222 &body_text[..body_text.len().min(400)]
223 )));
224 }
225
226 let sso_resp: SsoResponse = serde_json::from_str(&body_text).map_err(|e| {
227 GarminError::invalid_response(format!(
228 "Failed to parse SSO response: {} | body: {}",
229 e,
230 &body_text[..body_text.len().min(200)]
231 ))
232 })?;
233
234 let resp_type = sso_resp
235 .response_status
236 .as_ref()
237 .map(|s| s.response_type.as_str())
238 .unwrap_or("UNKNOWN");
239
240 let ticket = match resp_type {
241 "SUCCESSFUL" => sso_resp
242 .service_ticket_id
243 .ok_or_else(|| GarminError::invalid_response("Missing serviceTicketId"))?,
244
245 "MFA_REQUIRED" => {
246 let mfa_method = sso_resp
247 .customer_mfa_info
248 .and_then(|info| info.mfa_last_method_used)
249 .unwrap_or_else(|| "email".to_string());
250
251 let mfa_code = mfa_callback.ok_or_else(|| GarminError::MfaRequired)?();
252
253 self.submit_mfa(&mfa_code, &mfa_method, &login_params)
254 .await?
255 }
256
257 _ => {
258 let message = sso_resp
259 .response_status
260 .map(|s| {
261 if s.message.is_empty() {
262 s.response_type
263 } else {
264 format!("{}: {}", s.response_type, s.message)
265 }
266 })
267 .unwrap_or_else(|| "Unknown error".to_string());
268 return Err(GarminError::auth(format!("SSO error: {}", message)));
269 }
270 };
271
272 self.complete_login(&ticket).await
274 }
275
276 async fn submit_mfa(
278 &self,
279 mfa_code: &str,
280 mfa_method: &str,
281 login_params: &[(&str, &str)],
282 ) -> Result<String> {
283 let mfa_url = format!("https://sso.{}/mobile/api/mfa/verifyCode", DEFAULT_DOMAIN);
284 let mfa_body = MfaVerifyRequest {
285 mfa_method,
286 mfa_verification_code: mfa_code,
287 remember_my_browser: false,
288 reconsent_list: vec![],
289 mfa_setup: false,
290 };
291
292 let response = self
293 .client
294 .post(&mfa_url)
295 .query(login_params)
296 .headers(Self::sso_api_headers())
297 .json(&mfa_body)
298 .send()
299 .await
300 .map_err(GarminError::Http)?;
301
302 let sso_resp: SsoResponse = response.json().await.map_err(|e| {
303 GarminError::invalid_response(format!("Failed to parse MFA response: {}", e))
304 })?;
305
306 let resp_type = sso_resp
307 .response_status
308 .as_ref()
309 .map(|s| s.response_type.as_str())
310 .unwrap_or("UNKNOWN");
311
312 if resp_type != "SUCCESSFUL" {
313 let message = sso_resp
314 .response_status
315 .map(|s| s.message)
316 .unwrap_or_default();
317 return Err(GarminError::auth(format!(
318 "MFA verification failed: {}",
319 message
320 )));
321 }
322
323 sso_resp
324 .service_ticket_id
325 .ok_or_else(|| GarminError::invalid_response("Missing serviceTicketId after MFA"))
326 }
327
328 async fn complete_login(&self, ticket: &str) -> Result<(OAuth1Token, OAuth2Token)> {
330 let portal_url = format!("https://sso.{}/portal/sso/embed", DEFAULT_DOMAIN);
332 let mut headers = Self::sso_page_headers();
333 headers.insert("Sec-Fetch-Site", HeaderValue::from_static("same-origin"));
334 let _ = self.client.get(&portal_url).headers(headers).send().await;
335
336 let oauth1 = self.get_oauth1_token(ticket).await?;
338
339 let oauth2 = self.exchange_oauth1_for_oauth2(&oauth1, true).await?;
341
342 Ok((oauth1, oauth2))
343 }
344
345 async fn get_oauth1_token(&self, ticket: &str) -> Result<OAuth1Token> {
347 let consumer = self.fetch_oauth_consumer().await?;
348
349 let base_url = format!("https://connectapi.{}/oauth-service/oauth/", DEFAULT_DOMAIN);
350 let login_url = format!("https://mobile.integration.{}/gcm/android", DEFAULT_DOMAIN);
351 let url = format!(
352 "{}preauthorized?ticket={}&login-url={}&accepts-mfa-tokens=true",
353 base_url, ticket, login_url
354 );
355
356 let signer = OAuth1Signer::new(OAuthConsumer {
357 key: consumer.consumer_key.clone(),
358 secret: consumer.consumer_secret.clone(),
359 });
360
361 let auth_header = signer.sign("GET", &url, &[]);
362
363 let oauth_client = Client::builder()
365 .timeout(std::time::Duration::from_secs(30))
366 .build()
367 .map_err(GarminError::Http)?;
368
369 let response = oauth_client
370 .get(&url)
371 .header(USER_AGENT, MOBILE_USER_AGENT)
372 .header("Authorization", auth_header)
373 .send()
374 .await
375 .map_err(GarminError::Http)?;
376
377 let status = response.status();
378 if !status.is_success() {
379 return Err(GarminError::auth(format!(
380 "Failed to get OAuth1 token: {}",
381 status
382 )));
383 }
384
385 let body = response.text().await.map_err(GarminError::Http)?;
386 let params = parse_oauth_response(&body);
387
388 let oauth_token = params
389 .get("oauth_token")
390 .ok_or_else(|| GarminError::invalid_response("Missing oauth_token"))?
391 .clone();
392 let oauth_token_secret = params
393 .get("oauth_token_secret")
394 .ok_or_else(|| GarminError::invalid_response("Missing oauth_token_secret"))?
395 .clone();
396 let mfa_token = params.get("mfa_token").cloned();
397
398 let mut token = OAuth1Token::new(oauth_token, oauth_token_secret);
399
400 if let Some(mfa) = mfa_token {
401 token = token.with_mfa(mfa, None);
402 }
403
404 Ok(token)
405 }
406
407 async fn exchange_oauth1_for_oauth2(
409 &self,
410 oauth1: &OAuth1Token,
411 login: bool,
412 ) -> Result<OAuth2Token> {
413 let consumer = self.fetch_oauth_consumer().await?;
414
415 let url = format!(
416 "https://connectapi.{}/oauth-service/oauth/exchange/user/2.0",
417 DEFAULT_DOMAIN
418 );
419
420 let signer = OAuth1Signer::new(OAuthConsumer {
421 key: consumer.consumer_key.clone(),
422 secret: consumer.consumer_secret.clone(),
423 })
424 .with_token(OAuthToken {
425 token: oauth1.oauth_token.clone(),
426 secret: oauth1.oauth_token_secret.clone(),
427 });
428
429 let mut form_params: Vec<(String, String)> = vec![];
430 if login {
431 form_params.push((
432 "audience".to_string(),
433 "GARMIN_CONNECT_MOBILE_ANDROID_DI".to_string(),
434 ));
435 }
436 if let Some(ref mfa_token) = oauth1.mfa_token {
437 form_params.push(("mfa_token".to_string(), mfa_token.clone()));
438 }
439
440 let auth_header = signer.sign("POST", &url, &form_params);
441
442 let oauth_client = Client::builder()
443 .timeout(std::time::Duration::from_secs(30))
444 .build()
445 .map_err(GarminError::Http)?;
446
447 let mut request = oauth_client
448 .post(&url)
449 .header(USER_AGENT, MOBILE_USER_AGENT)
450 .header("Authorization", auth_header)
451 .header(CONTENT_TYPE, "application/x-www-form-urlencoded");
452
453 if !form_params.is_empty() {
454 request = request.form(&form_params);
455 }
456
457 let response = request.send().await.map_err(GarminError::Http)?;
458
459 let status = response.status();
460 if !status.is_success() {
461 return Err(GarminError::auth(format!(
462 "Failed to exchange OAuth1 for OAuth2: {}",
463 status
464 )));
465 }
466
467 let mut token: OAuth2Token = response.json().await.map_err(|e| {
468 GarminError::invalid_response(format!("Failed to parse OAuth2 token: {}", e))
469 })?;
470
471 let now = SystemTime::now()
472 .duration_since(UNIX_EPOCH)
473 .unwrap()
474 .as_secs() as i64;
475 token.expires_at = now + token.expires_in;
476 token.refresh_token_expires_at = now + token.refresh_token_expires_in;
477
478 Ok(token)
479 }
480
481 async fn fetch_oauth_consumer(&self) -> Result<OAuthConsumerResponse> {
483 let response = self
484 .client
485 .get(OAUTH_CONSUMER_URL)
486 .send()
487 .await
488 .map_err(GarminError::Http)?;
489
490 response.json().await.map_err(|e| {
491 GarminError::invalid_response(format!("Failed to parse OAuth consumer: {}", e))
492 })
493 }
494
495 pub async fn refresh_oauth2(&self, oauth1: &OAuth1Token) -> Result<OAuth2Token> {
497 self.exchange_oauth1_for_oauth2(oauth1, false).await
498 }
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504
505 #[test]
506 fn test_sso_client_creation() {
507 let client = SsoClient::new();
508 assert!(client.is_ok());
509 }
510
511 #[test]
512 fn test_parse_successful_sso_response() {
513 let json = r#"{
514 "responseStatus": {"type": "SUCCESSFUL", "message": ""},
515 "serviceTicketId": "ST-12345-abc"
516 }"#;
517 let resp: SsoResponse = serde_json::from_str(json).unwrap();
518 assert_eq!(resp.response_status.unwrap().response_type, "SUCCESSFUL");
519 assert_eq!(resp.service_ticket_id.unwrap(), "ST-12345-abc");
520 }
521
522 #[test]
523 fn test_parse_mfa_required_response() {
524 let json = r#"{
525 "responseStatus": {"type": "MFA_REQUIRED", "message": ""},
526 "customerMfaInfo": {"mfaLastMethodUsed": "email"}
527 }"#;
528 let resp: SsoResponse = serde_json::from_str(json).unwrap();
529 assert_eq!(resp.response_status.unwrap().response_type, "MFA_REQUIRED");
530 assert_eq!(
531 resp.customer_mfa_info
532 .unwrap()
533 .mfa_last_method_used
534 .unwrap(),
535 "email"
536 );
537 }
538
539 #[test]
540 fn test_parse_failed_sso_response() {
541 let json = r#"{
542 "responseStatus": {"type": "FAIL", "message": "Invalid credentials"}
543 }"#;
544 let resp: SsoResponse = serde_json::from_str(json).unwrap();
545 let status = resp.response_status.unwrap();
546 assert_eq!(status.response_type, "FAIL");
547 assert_eq!(status.message, "Invalid credentials");
548 }
549
550 #[test]
551 fn test_parse_missing_response_status() {
552 let json = r#"{}"#;
554 let resp: SsoResponse = serde_json::from_str(json).unwrap();
555 assert!(resp.response_status.is_none());
556 assert!(resp.service_ticket_id.is_none());
557 }
558}