Skip to main content

gor/cmd/
auth.rs

1//! Implementation of the `gor auth` subcommand.
2//!
3//! Handles login (OAuth device flow), logout, and status.
4
5#![allow(clippy::print_stdout)]
6
7use crate::auth::{device, token};
8use crate::cli::AuthCommand;
9use crate::client::Client;
10use crate::host::Host;
11use anyhow::Context;
12
13/// Run the `gor auth` subcommand.
14///
15/// # Errors
16///
17/// Returns an error if authentication fails, the keyring is unavailable,
18/// or the network request fails.
19pub fn run(cmd: AuthCommand) -> anyhow::Result<()> {
20    match cmd {
21        AuthCommand::Login {
22            hostname,
23            scopes,
24            with_token,
25        } => login(
26            hostname.as_deref().unwrap_or("github.com"),
27            scopes.as_deref(),
28            with_token,
29        ),
30        AuthCommand::Logout { hostname } => logout(hostname.as_deref().unwrap_or("github.com")),
31        AuthCommand::Status { hostname } => status(hostname.as_deref().unwrap_or("github.com")),
32        AuthCommand::SetupGit { hostname } => {
33            setup_git(hostname.as_deref().unwrap_or("github.com"))
34        }
35        AuthCommand::Token {
36            hostname,
37            refresh,
38            scopes,
39            secure,
40        } => token(
41            hostname.as_deref().unwrap_or("github.com"),
42            refresh,
43            &scopes,
44            secure,
45        ),
46    }
47}
48
49/// Configure git to use gor as a credential helper for HTTPS.
50///
51/// Writes `credential.https://<host>.helper` and `credential.helper`
52/// git config entries that invoke `gor auth git-credential`.
53///
54/// # Errors
55///
56/// Returns an error if git config commands fail.
57fn setup_git(hostname: &str) -> anyhow::Result<()> {
58    use std::process::Command;
59
60    let helper_value = "!gor auth git-credential";
61    let cred_key = format!("credential.https://{hostname}.helper");
62
63    // Check if gor is already configured for this host
64    let existing = Command::new("git")
65        .args(["config", "--global", "--get", &cred_key])
66        .output()
67        .ok();
68
69    let already_configured = existing.as_ref().is_some_and(|o| {
70        o.status.success() && String::from_utf8_lossy(&o.stdout).trim() == helper_value
71    });
72
73    if already_configured {
74        println!("git is already configured to use gor for {hostname} credentials");
75        return Ok(());
76    }
77
78    // Check if a non-gor credential helper exists for this host
79    let has_existing = existing.as_ref().is_some_and(|o| {
80        o.status.success() && !String::from_utf8_lossy(&o.stdout).trim().is_empty()
81    });
82
83    if has_existing {
84        // Preserve existing non-gor helper by using --add
85        println!("$ git config --global --add {cred_key} {helper_value}");
86        let output = Command::new("git")
87            .args(["config", "--global", "--add", &cred_key, helper_value])
88            .output()
89            .context("failed to run git config --add")?;
90        if !output.status.success() {
91            let stderr = String::from_utf8_lossy(&output.stderr);
92            anyhow::bail!("git config failed: {stderr}");
93        }
94    } else {
95        // Set the credential helper for the specific host
96        println!("$ git config --global {cred_key} {helper_value}");
97        let output = Command::new("git")
98            .args(["config", "--global", &cred_key, helper_value])
99            .output()
100            .context("failed to run git config")?;
101        if !output.status.success() {
102            let stderr = String::from_utf8_lossy(&output.stderr);
103            anyhow::bail!("git config failed: {stderr}");
104        }
105    }
106
107    // Also set the generic credential.helper if not already set
108    let generic_key = "credential.helper";
109    let generic_existing = Command::new("git")
110        .args(["config", "--global", "--get", generic_key])
111        .output()
112        .ok();
113
114    let has_generic = generic_existing.as_ref().is_some_and(|o| {
115        o.status.success() && !String::from_utf8_lossy(&o.stdout).trim().is_empty()
116    });
117
118    if !has_generic {
119        println!("$ git config --global {generic_key} {helper_value}");
120        let output = Command::new("git")
121            .args(["config", "--global", generic_key, helper_value])
122            .output()
123            .context("failed to run git config")?;
124        if !output.status.success() {
125            let stderr = String::from_utf8_lossy(&output.stderr);
126            anyhow::bail!("git config failed: {stderr}");
127        }
128    }
129
130    println!("✓ git is now configured to use gor for {hostname} credentials");
131    Ok(())
132}
133
134/// Log in to a GitHub account using the OAuth device flow.
135fn login(hostname: &str, scopes: Option<&str>, with_token: bool) -> anyhow::Result<()> {
136    let host = Host::new(hostname);
137
138    if with_token {
139        // Read token from stdin.
140        let token = token::read_token_from_stdin()
141            .map_err(|e| anyhow::anyhow!("failed to read token from stdin: {e}"))?;
142        if token.is_empty() {
143            anyhow::bail!("no token provided on stdin");
144        }
145
146        // Verify the token.
147        let login = token::verify_token(&host, &token)
148            .map_err(|e| anyhow::anyhow!("token verification failed: {e}"))?;
149
150        // Store it.
151        let client = Client::with_token(hostname, &token)?;
152        client.save_token()?;
153        client.save_user(&login)?;
154
155        println!("Authenticated as {login}");
156        return Ok(());
157    }
158
159    // Start the OAuth device flow.
160    let device_resp = device::request_device_code(&host, scopes)
161        .map_err(|e| anyhow::anyhow!("failed to request device code: {e}"))?;
162
163    // Show the user the code and URL.
164    device::display_instructions(&device_resp.user_code, &device_resp.verification_uri);
165
166    // Poll for the token.
167    let access_token = device::poll_for_token(
168        &host,
169        &device_resp.device_code,
170        device_resp.interval,
171        device_resp.expires_in,
172    )
173    .map_err(|e| anyhow::anyhow!("{e}"))?;
174
175    // Verify the token.
176    let login = token::verify_token(&host, &access_token)
177        .map_err(|e| anyhow::anyhow!("token verification failed: {e}"))?;
178
179    // Store it.
180    let client = Client::with_token(hostname, &access_token)?;
181    client.save_token()?;
182    client.save_user(&login)?;
183
184    println!("Authenticated as {login}");
185    Ok(())
186}
187
188/// Log out of a GitHub account by removing the stored token.
189fn logout(hostname: &str) -> anyhow::Result<()> {
190    let client = Client::new(hostname)?;
191    if !client.is_authenticated() {
192        println!("Not logged in to {hostname}");
193        return Ok(());
194    }
195
196    crate::keyring_store::delete_token(hostname)
197        .map_err(|e| anyhow::anyhow!("failed to remove token: {e}"))?;
198    println!("Logged out of {hostname}");
199    Ok(())
200}
201
202/// Print or refresh the authentication token for the given host.
203///
204/// When `refresh` is true, runs the OAuth device flow to obtain a new token.
205/// Optional `scopes` can be specified to request additional OAuth scopes.
206/// When `secure` is true, only the first and last 4 characters are shown.
207///
208/// # Errors
209///
210/// Returns an error if no token is found, the device flow fails,
211/// or the keyring cannot be read.
212fn token(hostname: &str, refresh: bool, scopes: &[String], secure: bool) -> anyhow::Result<()> {
213    if refresh {
214        let host = Host::new(hostname);
215
216        // Build the scopes string from the provided scopes, or use defaults.
217        let scope_str = if scopes.is_empty() {
218            None
219        } else {
220            Some(scopes.join(","))
221        };
222
223        // Start the OAuth device flow.
224        let device_resp = device::request_device_code(&host, scope_str.as_deref())
225            .map_err(|e| anyhow::anyhow!("failed to request device code: {e}"))?;
226
227        // Show the user the code and URL.
228        device::display_instructions(&device_resp.user_code, &device_resp.verification_uri);
229
230        // Poll for the token.
231        let access_token = device::poll_for_token(
232            &host,
233            &device_resp.device_code,
234            device_resp.interval,
235            device_resp.expires_in,
236        )
237        .map_err(|e| anyhow::anyhow!("{e}"))?;
238
239        // Store the new token.
240        crate::keyring_store::set_token(hostname, &access_token)
241            .map_err(|e| anyhow::anyhow!("failed to store token: {e}"))?;
242
243        // Print the token.
244        print_token(&access_token, secure);
245        return Ok(());
246    }
247
248    // Try to get the token from the keyring first.
249    let token = crate::keyring_store::get_token(hostname)
250        .map_err(|e| anyhow::anyhow!("failed to read token: {e}"))?
251        .or_else(|| std::env::var("GITHUB_TOKEN").ok())
252        .or_else(|| std::env::var("GH_TOKEN").ok());
253
254    match token {
255        Some(t) => {
256            print_token(&t, secure);
257            Ok(())
258        }
259        None => anyhow::bail!(
260            "no token found for {hostname}. Run 'gor auth login' first or set GITHUB_TOKEN."
261        ),
262    }
263}
264
265/// Print a token to stdout, optionally masking it for security.
266///
267/// When `secure` is true, only the first 4 and last 4 characters are shown
268/// with `...` in between. If the token is 8 characters or fewer, it is
269/// printed in full even in secure mode.
270fn print_token(token: &str, secure: bool) {
271    if secure && token.len() > 8 {
272        let first4 = &token[..4];
273        let last4 = &token[token.len() - 4..];
274        println!("{first4}...{last4}");
275    } else {
276        println!("{token}");
277    }
278}
279
280/// Show the current authentication status.
281fn status(hostname: &str) -> anyhow::Result<()> {
282    let client = Client::new(hostname)?;
283    match client.token() {
284        Some(token) => {
285            let host = Host::new(hostname);
286            match token::verify_token(&host, token) {
287                Ok(login) => {
288                    println!("Logged in to {hostname} as {login}");
289                }
290                Err(_) => {
291                    println!("Logged in to {hostname} but token is invalid or expired");
292                }
293            }
294        }
295        None => {
296            println!("Not logged in to {hostname}");
297        }
298    }
299    Ok(())
300}