garmin_cli/client/
sso.rs

1//! Garmin SSO Authentication
2//!
3//! Implements the Garmin Connect SSO login flow, ported from the Python Garth library.
4
5use 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
16/// Default Garmin domain
17const DEFAULT_DOMAIN: &str = "garmin.com";
18
19/// User agent mimicking the Garmin mobile app
20const MOBILE_USER_AGENT: &str = "com.garmin.android.apps.connectmobile";
21
22/// User agent for Connect API requests
23const API_USER_AGENT: &str = "GCM-iOS-5.7.2.1";
24
25/// URL to fetch OAuth consumer credentials
26const OAUTH_CONSUMER_URL: &str = "https://thegarth.s3.amazonaws.com/oauth_consumer.json";
27
28/// OAuth consumer credentials fetched from Garth's S3
29#[derive(Debug, Deserialize)]
30struct OAuthConsumerResponse {
31    consumer_key: String,
32    consumer_secret: String,
33}
34
35/// SSO Client for Garmin authentication
36pub struct SsoClient {
37    client: Client,
38    domain: String,
39    last_url: Option<String>,
40}
41
42impl SsoClient {
43    /// Create a new SSO client
44    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    /// Perform full login flow
60    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        // Step 1: Initialize session and get CSRF token
67        let csrf_token = self.init_session_and_get_csrf().await?;
68
69        // Step 2: Submit login form
70        let login_result = self.submit_login(email, password, &csrf_token).await?;
71
72        // Step 3: Handle MFA if required
73        let ticket = match login_result {
74            LoginResult::Success(ticket) => ticket,
75            LoginResult::MfaRequired => {
76                let mfa_code = mfa_callback.ok_or_else(|| GarminError::MfaRequired)?();
77                self.submit_mfa(&mfa_code, &csrf_token).await?
78            }
79        };
80
81        // Step 4: Exchange ticket for OAuth1 token
82        let oauth1 = self.get_oauth1_token(&ticket).await?;
83
84        // Step 5: Exchange OAuth1 for OAuth2 token
85        let oauth2 = self.exchange_oauth1_for_oauth2(&oauth1).await?;
86
87        Ok((oauth1, oauth2))
88    }
89
90    /// Initialize session and extract CSRF token
91    async fn init_session_and_get_csrf(&mut self) -> Result<String> {
92        let sso_base = format!("https://sso.{}/sso", self.domain);
93        let sso_embed = format!("{}/embed", sso_base);
94
95        // First request to set cookies (gauthHost points to SSO without /embed)
96        let embed_params = [
97            ("id", "gauth-widget"),
98            ("embedWidget", "true"),
99            ("gauthHost", sso_base.as_str()),
100        ];
101
102        let resp = self
103            .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        // Consume the response body
112        let _ = resp.text().await;
113
114        // Second request to get CSRF token (gauthHost now points to SSO_EMBED)
115        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    /// Submit login form with email and password
142    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        // Check response title
193        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!(
202                "Unexpected login response: {}",
203                title
204            )))
205        }
206    }
207
208    /// Submit MFA code
209    async fn submit_mfa(&mut self, mfa_code: &str, csrf_token: &str) -> Result<String> {
210        let sso_base = format!("https://sso.{}/sso", self.domain);
211        let sso_embed = format!("{}/embed", sso_base);
212        let mfa_url = format!("{}/verifyMFA/loginEnterMfaCode", sso_base);
213
214        let signin_params = [
215            ("id", "gauth-widget"),
216            ("embedWidget", "true"),
217            ("gauthHost", sso_embed.as_str()),
218            ("service", sso_embed.as_str()),
219            ("source", sso_embed.as_str()),
220            ("redirectAfterAccountLoginUrl", sso_embed.as_str()),
221            ("redirectAfterAccountCreationUrl", sso_embed.as_str()),
222        ];
223
224        let form_data = [
225            ("mfa-code", mfa_code),
226            ("embed", "true"),
227            ("_csrf", csrf_token),
228            ("fromPage", "setupEnterMfaCode"),
229        ];
230
231        let mut headers = HeaderMap::new();
232        headers.insert(USER_AGENT, HeaderValue::from_static(API_USER_AGENT));
233        if let Some(ref referer) = self.last_url {
234            headers.insert(REFERER, HeaderValue::from_str(referer).unwrap());
235        }
236
237        let response = self
238            .client
239            .post(&mfa_url)
240            .query(&signin_params)
241            .headers(headers)
242            .form(&form_data)
243            .send()
244            .await
245            .map_err(GarminError::Http)?;
246
247        let html = response.text().await.map_err(GarminError::Http)?;
248        let title = extract_title(&html)?;
249
250        if title == "Success" {
251            extract_ticket(&html)
252        } else {
253            Err(GarminError::auth(format!(
254                "MFA verification failed: {}",
255                title
256            )))
257        }
258    }
259
260    /// Exchange ticket for OAuth1 token
261    async fn get_oauth1_token(&self, ticket: &str) -> Result<OAuth1Token> {
262        // Fetch OAuth consumer credentials
263        let consumer = self.fetch_oauth_consumer().await?;
264
265        let base_url = format!("https://connectapi.{}/oauth-service/oauth/", self.domain);
266        let login_url = format!("https://sso.{}/sso/embed", self.domain);
267        let url = format!(
268            "{}preauthorized?ticket={}&login-url={}&accepts-mfa-tokens=true",
269            base_url, ticket, login_url
270        );
271
272        // Create OAuth1 signer with just consumer credentials
273        let signer = OAuth1Signer::new(OAuthConsumer {
274            key: consumer.consumer_key.clone(),
275            secret: consumer.consumer_secret.clone(),
276        });
277
278        let auth_header = signer.sign("GET", &url, &[]);
279
280        // IMPORTANT: Use a NEW client without cookies for OAuth1 requests
281        // This matches Python's behavior where OAuth1Session is separate from SSO session
282        let oauth_client = Client::builder()
283            .timeout(std::time::Duration::from_secs(30))
284            .build()
285            .map_err(GarminError::Http)?;
286
287        let response = oauth_client
288            .get(&url)
289            .header(USER_AGENT, MOBILE_USER_AGENT)
290            .header("Authorization", auth_header)
291            .send()
292            .await
293            .map_err(GarminError::Http)?;
294
295        let status = response.status();
296
297        if !status.is_success() {
298            return Err(GarminError::auth(format!(
299                "Failed to get OAuth1 token: {}",
300                status
301            )));
302        }
303
304        let body = response.text().await.map_err(GarminError::Http)?;
305        let params = parse_oauth_response(&body);
306
307        let oauth_token = params
308            .get("oauth_token")
309            .ok_or_else(|| GarminError::invalid_response("Missing oauth_token"))?
310            .clone();
311        let oauth_token_secret = params
312            .get("oauth_token_secret")
313            .ok_or_else(|| GarminError::invalid_response("Missing oauth_token_secret"))?
314            .clone();
315        let mfa_token = params.get("mfa_token").cloned();
316
317        let mut token = OAuth1Token::new(oauth_token, oauth_token_secret).with_domain(&self.domain);
318
319        if let Some(mfa) = mfa_token {
320            token = token.with_mfa(mfa, None);
321        }
322
323        Ok(token)
324    }
325
326    /// Exchange OAuth1 token for OAuth2 token
327    async fn exchange_oauth1_for_oauth2(&self, oauth1: &OAuth1Token) -> Result<OAuth2Token> {
328        let consumer = self.fetch_oauth_consumer().await?;
329
330        let url = format!(
331            "https://connectapi.{}/oauth-service/oauth/exchange/user/2.0",
332            self.domain
333        );
334
335        // Create OAuth1 signer with token
336        let signer = OAuth1Signer::new(OAuthConsumer {
337            key: consumer.consumer_key.clone(),
338            secret: consumer.consumer_secret.clone(),
339        })
340        .with_token(OAuthToken {
341            token: oauth1.oauth_token.clone(),
342            secret: oauth1.oauth_token_secret.clone(),
343        });
344
345        let params: Vec<(String, String)> = if let Some(ref mfa_token) = oauth1.mfa_token {
346            vec![("mfa_token".to_string(), mfa_token.clone())]
347        } else {
348            vec![]
349        };
350
351        let auth_header = signer.sign("POST", &url, &params);
352
353        // Use a separate client without cookies (matches Python's OAuth1Session behavior)
354        let oauth_client = Client::builder()
355            .timeout(std::time::Duration::from_secs(30))
356            .build()
357            .map_err(GarminError::Http)?;
358
359        let mut request = oauth_client
360            .post(&url)
361            .header(USER_AGENT, MOBILE_USER_AGENT)
362            .header("Authorization", auth_header)
363            .header(CONTENT_TYPE, "application/x-www-form-urlencoded");
364
365        if let Some(ref mfa_token) = oauth1.mfa_token {
366            request = request.form(&[("mfa_token", mfa_token)]);
367        }
368
369        let response = request.send().await.map_err(GarminError::Http)?;
370
371        let status = response.status();
372
373        if !status.is_success() {
374            return Err(GarminError::auth(format!(
375                "Failed to exchange OAuth1 for OAuth2: {}",
376                status
377            )));
378        }
379
380        let mut token: OAuth2Token = response.json().await.map_err(|e| {
381            GarminError::invalid_response(format!("Failed to parse OAuth2 token: {}", e))
382        })?;
383
384        // Set expiration timestamps
385        let now = SystemTime::now()
386            .duration_since(UNIX_EPOCH)
387            .unwrap()
388            .as_secs() as i64;
389        token.expires_at = now + token.expires_in;
390        token.refresh_token_expires_at = now + token.refresh_token_expires_in;
391
392        Ok(token)
393    }
394
395    /// Fetch OAuth consumer credentials from Garth's S3
396    async fn fetch_oauth_consumer(&self) -> Result<OAuthConsumerResponse> {
397        let response = self
398            .client
399            .get(OAUTH_CONSUMER_URL)
400            .send()
401            .await
402            .map_err(GarminError::Http)?;
403
404        response.json().await.map_err(|e| {
405            GarminError::invalid_response(format!("Failed to parse OAuth consumer: {}", e))
406        })
407    }
408
409    /// Refresh OAuth2 token using OAuth1 token
410    pub async fn refresh_oauth2(&self, oauth1: &OAuth1Token) -> Result<OAuth2Token> {
411        self.exchange_oauth1_for_oauth2(oauth1).await
412    }
413}
414
415/// Result of login attempt
416enum LoginResult {
417    Success(String), // ticket
418    MfaRequired,
419}
420
421/// Extract CSRF token from HTML
422fn extract_csrf_token(html: &str) -> Result<String> {
423    let re = Regex::new(r#"name="_csrf"\s+value="([^"]+)""#).unwrap();
424    re.captures(html)
425        .and_then(|caps| caps.get(1))
426        .map(|m| m.as_str().to_string())
427        .ok_or_else(|| GarminError::invalid_response("Could not find CSRF token"))
428}
429
430/// Extract page title from HTML
431fn extract_title(html: &str) -> Result<String> {
432    let re = Regex::new(r"<title>([^<]+)</title>").unwrap();
433    re.captures(html)
434        .and_then(|caps| caps.get(1))
435        .map(|m| m.as_str().to_string())
436        .ok_or_else(|| GarminError::invalid_response("Could not find page title"))
437}
438
439/// Extract ticket from success HTML
440fn extract_ticket(html: &str) -> Result<String> {
441    let re = Regex::new(r#"embed\?ticket=([^"]+)""#).unwrap();
442    re.captures(html)
443        .and_then(|caps| caps.get(1))
444        .map(|m| m.as_str().to_string())
445        .ok_or_else(|| GarminError::invalid_response("Could not find ticket in response"))
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    #[test]
453    fn test_extract_csrf_token() {
454        let html = r#"<input type="hidden" name="_csrf" value="abc123token">"#;
455        let token = extract_csrf_token(html).unwrap();
456        assert_eq!(token, "abc123token");
457    }
458
459    #[test]
460    fn test_extract_csrf_token_missing() {
461        let html = r#"<html><body>No token here</body></html>"#;
462        let result = extract_csrf_token(html);
463        assert!(result.is_err());
464    }
465
466    #[test]
467    fn test_extract_title() {
468        let html = r#"<html><head><title>Success</title></head></html>"#;
469        let title = extract_title(html).unwrap();
470        assert_eq!(title, "Success");
471    }
472
473    #[test]
474    fn test_extract_title_mfa() {
475        let html = r#"<html><head><title>GARMIN > MFA Challenge</title></head></html>"#;
476        let title = extract_title(html).unwrap();
477        assert!(title.contains("MFA"));
478    }
479
480    #[test]
481    fn test_extract_ticket() {
482        let html = r#"<a href="embed?ticket=ST-12345-abc">Continue</a>"#;
483        let ticket = extract_ticket(html).unwrap();
484        assert_eq!(ticket, "ST-12345-abc");
485    }
486
487    #[test]
488    fn test_extract_ticket_missing() {
489        let html = r#"<html><body>No ticket</body></html>"#;
490        let result = extract_ticket(html);
491        assert!(result.is_err());
492    }
493
494    #[test]
495    fn test_sso_client_creation() {
496        let client = SsoClient::new(None);
497        assert!(client.is_ok());
498    }
499
500    #[test]
501    fn test_sso_client_with_custom_domain() {
502        let client = SsoClient::new(Some("garmin.cn")).unwrap();
503        assert_eq!(client.domain, "garmin.cn");
504    }
505}