Skip to main content

garmin_cli/cli/commands/
auth.rs

1//! Authentication commands for garmin-cli
2
3use crate::client::{OAuth1Token, OAuth2Token, SsoClient};
4use crate::config::CredentialStore;
5use crate::error::{GarminError, Result};
6use std::io::{self, Write};
7
8/// Execute the login command
9pub async fn login(email: Option<String>, profile: Option<String>) -> Result<()> {
10    let store = CredentialStore::new(profile.clone())?;
11
12    // Check if already logged in
13    if store.has_credentials() {
14        if let Some((_, oauth2)) = store.load_tokens()? {
15            if !oauth2.is_expired() {
16                println!("Already logged in. Use 'garmin auth logout' to log out first.");
17                return Ok(());
18            }
19        }
20    }
21
22    // Get email
23    let email = match email {
24        Some(e) => e,
25        None => {
26            print!("Email: ");
27            io::stdout().flush()?;
28            let mut input = String::new();
29            io::stdin().read_line(&mut input)?;
30            input.trim().to_string()
31        }
32    };
33
34    // Get password
35    let password = rpassword_prompt("Password: ")?;
36
37    println!("Logging in...");
38
39    // Perform login
40    let mut sso_client = SsoClient::new(None)?;
41    let (oauth1, oauth2) = sso_client
42        .login(&email, &password, Some(prompt_mfa))
43        .await?;
44
45    // Save tokens
46    store.save_tokens(&oauth1, &oauth2)?;
47
48    println!("Successfully logged in!");
49    println!("Profile: {}", store.profile());
50
51    Ok(())
52}
53
54/// Execute the logout command
55pub async fn logout(profile: Option<String>) -> Result<()> {
56    let store = CredentialStore::new(profile)?;
57
58    if !store.has_credentials() {
59        println!("Not logged in.");
60        return Ok(());
61    }
62
63    store.clear()?;
64    // Also try to clear keyring (ignore errors)
65    let _ = store.delete_secret_from_keyring();
66
67    println!("Successfully logged out.");
68    Ok(())
69}
70
71/// Execute the status command
72pub async fn status(profile: Option<String>) -> Result<()> {
73    let store = CredentialStore::new(profile)?;
74
75    if !store.has_credentials() {
76        println!("Status: Not logged in");
77        println!("Run 'garmin auth login' to authenticate.");
78        return Ok(());
79    }
80
81    match store.load_tokens()? {
82        Some((oauth1, oauth2)) => {
83            println!("Status: Logged in");
84            println!("Profile: {}", store.profile());
85            println!("Domain: {}", oauth1.domain);
86
87            if oauth2.is_expired() {
88                println!("Access Token: Expired (will refresh on next request)");
89            } else {
90                let expires_in = oauth2.expires_at - chrono::Utc::now().timestamp();
91                if expires_in > 3600 {
92                    println!(
93                        "Access Token: Valid (expires in {} hours)",
94                        expires_in / 3600
95                    );
96                } else if expires_in > 60 {
97                    println!(
98                        "Access Token: Valid (expires in {} minutes)",
99                        expires_in / 60
100                    );
101                } else {
102                    println!("Access Token: Valid (expires in {} seconds)", expires_in);
103                }
104            }
105
106            if oauth1.mfa_token.is_some() {
107                println!("MFA: Enabled");
108            }
109        }
110        None => {
111            println!("Status: Credentials corrupted");
112            println!("Run 'garmin auth logout' then 'garmin auth login' to fix.");
113        }
114    }
115
116    Ok(())
117}
118
119/// Refresh OAuth2 token using OAuth1 token
120pub async fn refresh_token(store: &CredentialStore) -> Result<(OAuth1Token, OAuth2Token)> {
121    let (oauth1, oauth2) = store.load_tokens()?.ok_or(GarminError::NotAuthenticated)?;
122
123    if !oauth2.is_expired() {
124        return Ok((oauth1, oauth2));
125    }
126
127    println!("Refreshing access token...");
128    let sso_client = SsoClient::new(Some(&oauth1.domain))?;
129    let new_oauth2 = sso_client.refresh_oauth2(&oauth1).await?;
130
131    store.save_oauth2(&new_oauth2)?;
132
133    Ok((oauth1, new_oauth2))
134}
135
136/// Prompt for password without echoing
137fn rpassword_prompt(prompt: &str) -> Result<String> {
138    print!("{}", prompt);
139    io::stdout().flush()?;
140
141    // Use rpassword if available, otherwise just read normally (less secure)
142    let password =
143        rpassword::read_password().map_err(|e| GarminError::Io(io::Error::other(e.to_string())))?;
144
145    Ok(password)
146}
147
148/// Prompt for MFA code
149fn prompt_mfa() -> String {
150    print!("MFA Code: ");
151    io::stdout().flush().unwrap();
152
153    let mut input = String::new();
154    io::stdin().read_line(&mut input).unwrap();
155    input.trim().to_string()
156}
157
158#[cfg(test)]
159mod tests {
160    // Integration tests would go here, but they require actual credentials
161    // For unit testing, we'd use wiremock to mock the SSO endpoints
162}