Skip to main content

garmin_cli/client/
sso.rs

1//! Garmin SSO Authentication
2//!
3//! Implements the Garmin Connect SSO login flow using the mobile JSON API.
4//!
5//! ## Background
6//!
7//! The previous implementation scraped `<title>` tags from Garmin's SSO HTML
8//! page to determine login status (matching `garth < 0.8.0`). Garmin changed
9//! their SSO page structure, breaking this approach.
10//!
11//! This implementation ports the approach from `garth 0.8.0`, which switched
12//! to Garmin's mobile JSON API endpoints:
13//! - `GET  /mobile/sso/en/sign-in` — establish session cookies
14//! - `POST /mobile/api/login` — submit credentials, receive ticket or MFA challenge
15//! - `POST /mobile/api/mfa/verifyCode` — verify MFA code if required
16//!
17//! Reference: <https://github.com/matin/garth/blob/main/garth/sso.py>
18
19use 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
29/// Default Garmin domain
30const DEFAULT_DOMAIN: &str = "garmin.com";
31
32/// Client ID for mobile API login
33const CLIENT_ID: &str = "GCM_ANDROID_DARK";
34
35/// User agent mimicking the Garmin mobile app (for OAuth endpoints)
36const MOBILE_USER_AGENT: &str = "com.garmin.android.apps.connectmobile";
37
38/// Browser-like user agent for SSO pages (avoids Cloudflare challenges)
39const 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
41/// URL to fetch OAuth consumer credentials
42const OAUTH_CONSUMER_URL: &str = "https://thegarth.s3.amazonaws.com/oauth_consumer.json";
43
44/// OAuth consumer credentials fetched from Garth's S3
45#[derive(Debug, Deserialize)]
46struct OAuthConsumerResponse {
47    consumer_key: String,
48    consumer_secret: String,
49}
50
51/// SSO login request body
52#[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/// SSO MFA verification request body
62#[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/// SSO response status
73#[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/// SSO login/MFA response
83#[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/// MFA information from login response
95#[derive(Debug, Deserialize)]
96#[serde(rename_all = "camelCase")]
97struct MfaInfo {
98    #[serde(default)]
99    mfa_last_method_used: Option<String>,
100}
101
102/// SSO Client for Garmin authentication
103pub struct SsoClient {
104    client: Client,
105    domain: String,
106}
107
108impl SsoClient {
109    /// Create a new SSO client
110    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    /// Build SSO page headers (browser-like to avoid Cloudflare)
125    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    /// Perform full login flow using mobile JSON API
144    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        // Step 1: Set cookies by visiting the sign-in page
158        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        // Step 2: Submit login via JSON API
174        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        // Step 3: Complete login (set Cloudflare LB cookie, get OAuth tokens)
256        self.complete_login(&ticket).await
257    }
258
259    /// Submit MFA verification code via JSON API
260    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    /// Complete login: set Cloudflare LB cookie and exchange for OAuth tokens
312    async fn complete_login(&self, ticket: &str) -> Result<(OAuth1Token, OAuth2Token)> {
313        // Best-effort: set Cloudflare LB cookie for backend pinning
314        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        // Exchange ticket for OAuth1 token
320        let oauth1 = self.get_oauth1_token(ticket).await?;
321
322        // Exchange OAuth1 for OAuth2 token
323        let oauth2 = self.exchange_oauth1_for_oauth2(&oauth1, true).await?;
324
325        Ok((oauth1, oauth2))
326    }
327
328    /// Exchange ticket for OAuth1 token
329    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        // Use a separate client without cookies (matches garth's OAuth1Session behavior)
347        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    /// Exchange OAuth1 token for OAuth2 token
391    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    /// Fetch OAuth consumer credentials from Garth's S3
465    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    /// Refresh OAuth2 token using OAuth1 token
479    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        // Handles unexpected/empty JSON gracefully
542        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}