Skip to main content

bb_cli/commands/
auth.rs

1use crate::api::Client;
2use crate::credentials::{self, Credentials};
3use crate::error::{BbError, Result};
4use crate::output::{self, Format};
5use crate::secret::SecretString;
6use serde::Serialize;
7
8const TOKEN_HELP_URL: &str = "https://id.atlassian.com/manage-profile/security/api-tokens";
9
10#[derive(Debug, Serialize)]
11pub struct AuthStatus {
12    pub email: String,
13    /// Already redacted. Never holds the real token.
14    pub token: String,
15    pub account: Option<String>,
16}
17
18/// Renders an [`AuthStatus`] as either JSON or the `FIELD | VALUE` human table,
19/// shared by `login` and `status` so they can't drift in shape.
20fn print_status(format: Format, status: &AuthStatus, unverified_label: &str) -> Result<()> {
21    match format {
22        Format::Json => output::print_json(status),
23        Format::Human => {
24            output::print_table(
25                &["FIELD", "VALUE"],
26                vec![
27                    vec!["email".into(), status.email.clone()],
28                    vec!["token".into(), status.token.clone()],
29                    vec![
30                        "account".into(),
31                        status
32                            .account
33                            .clone()
34                            .unwrap_or_else(|| unverified_label.into()),
35                    ],
36                ],
37            );
38            Ok(())
39        }
40    }
41}
42
43pub async fn login(email: Option<String>, token_stdin: bool, format: Format) -> Result<()> {
44    if !format.is_json() {
45        output::info("bb authenticates with an atlassian api token");
46        output::info(&format!("create one at {TOKEN_HELP_URL}"));
47    }
48
49    // Never block on input that will not arrive: if stdin is not a terminal and
50    // either value would require a prompt, name the flags instead of hanging.
51    let would_prompt = email.is_none() || !token_stdin;
52    if would_prompt && !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
53        return Err(BbError::Config(
54            "no email/token on a non-interactive stdin — pass --email and --token-stdin".into(),
55        ));
56    }
57
58    let email = match email {
59        Some(value) => value,
60        None => inquire::Text::new("atlassian account email:")
61            .prompt()
62            .map_err(|e| BbError::Config(format!("cancelled: {e}")))?,
63    };
64
65    let token = if token_stdin {
66        let mut buf = String::new();
67        std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?;
68        SecretString::from(buf.trim().to_string())
69    } else {
70        // `Password` never echoes and never confirms into the terminal buffer.
71        let entered = inquire::Password::new("api token:")
72            .with_display_mode(inquire::PasswordDisplayMode::Masked)
73            .without_confirmation()
74            .prompt()
75            .map_err(|e| BbError::Config(format!("cancelled: {e}")))?;
76        SecretString::from(entered)
77    };
78
79    let email = email.trim().to_string();
80    if email.is_empty() || !email.contains('@') {
81        return Err(BbError::Config(
82            "email must be the atlassian account email address".into(),
83        ));
84    }
85
86    let creds = Credentials {
87        email: email.clone(),
88        token: token.clone(),
89    };
90
91    // Verify before persisting, so a bad token is never stored.
92    let spinner = output::spinner("verifying token");
93    let client = Client::from_env(creds.clone())?;
94    let user: crate::api::models::User = client.get_json("/user").await?;
95    spinner.finish_and_clear();
96
97    credentials::store(&email, &token)?;
98
99    let status = AuthStatus {
100        email,
101        token: creds.redacted_token(),
102        account: user.display_name,
103    };
104
105    if !format.is_json() {
106        output::success("token verified and saved to the os keyring");
107    }
108    print_status(format, &status, "-")?;
109
110    Ok(())
111}
112
113pub async fn status(format: Format) -> Result<()> {
114    let creds = credentials::load()?;
115    let redacted = creds.redacted_token();
116
117    // Best-effort identity check; a network failure must not leak the token.
118    let account = match Client::from_env(creds.clone()) {
119        Ok(client) => client
120            .get_json::<crate::api::models::User>("/user")
121            .await
122            .ok()
123            .and_then(|u| u.display_name),
124        Err(_) => None,
125    };
126
127    let status = AuthStatus {
128        email: creds.email.clone(),
129        token: redacted,
130        account,
131    };
132
133    print_status(format, &status, "unverified")?;
134
135    Ok(())
136}
137
138pub fn logout(format: Format) -> Result<()> {
139    credentials::delete()?;
140    let legacy = credentials::legacy_config_path();
141    let legacy_exists = legacy.exists();
142
143    match format {
144        Format::Json => output::print_json(&serde_json::json!({ "removed": true }))?,
145        Format::Human => {
146            if legacy_exists {
147                output::warn(&format!(
148                    "a legacy plaintext credential file still exists at {} — delete it",
149                    legacy.display()
150                ));
151            }
152            output::success("credentials removed from the os keyring");
153        }
154    }
155    Ok(())
156}