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