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}
106
107impl SsoClient {
108    /// Create a new SSO client
109    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    /// Build SSO page headers (browser-like to establish session cookies)
121    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    /// Build mobile JSON API headers for login/MFA endpoints
140    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    /// Perform full login flow using mobile JSON API
161    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        // Step 1: Set cookies by visiting the sign-in page
175        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        // Step 2: Submit login via JSON API
191        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        // Step 3: Complete login (set Cloudflare LB cookie, get OAuth tokens)
273        self.complete_login(&ticket).await
274    }
275
276    /// Submit MFA verification code via JSON API
277    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    /// Complete login: set Cloudflare LB cookie and exchange for OAuth tokens
329    async fn complete_login(&self, ticket: &str) -> Result<(OAuth1Token, OAuth2Token)> {
330        // Best-effort: set Cloudflare LB cookie for backend pinning
331        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        // Exchange ticket for OAuth1 token
337        let oauth1 = self.get_oauth1_token(ticket).await?;
338
339        // Exchange OAuth1 for OAuth2 token
340        let oauth2 = self.exchange_oauth1_for_oauth2(&oauth1, true).await?;
341
342        Ok((oauth1, oauth2))
343    }
344
345    /// Exchange ticket for OAuth1 token
346    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        // Use a separate client without cookies (matches garth's OAuth1Session behavior)
364        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    /// Exchange OAuth1 token for OAuth2 token
408    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    /// Fetch OAuth consumer credentials from Garth's S3
482    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    /// Refresh OAuth2 token using OAuth1 token
496    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        // Handles unexpected/empty JSON gracefully
553        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}