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/// The scopes `bb` needs, with what each one buys, in the order someone should
11/// decide about them: `read:user:bitbucket` first because login itself fails
12/// without it, then the read scopes a browsing user wants, then the grants that
13/// only some commands need. Ordering is the only guidance a list this long can
14/// give, since Atlassian's picker shows two dozen Bitbucket scopes at once.
15///
16/// This list and the README's "Token scopes" table are the same facts written
17/// twice; `tests/auth.rs::the_readme_scope_table_matches_the_login_walkthrough`
18/// asserts they stay equal, because they did not — the table gained the last
19/// two entries while the walkthrough kept printing four.
20pub const SCOPES: [(&str, &str); 6] = [
21    (
22        "read:user:bitbucket",
23        "required — login verifies the token against /user",
24    ),
25    (
26        "read:pullrequest:bitbucket",
27        "pr list, view, diff, files, commits, mine",
28    ),
29    (
30        "read:repository:bitbucket",
31        "branch list, repo list, default reviewers, the pr mine scan",
32    ),
33    (
34        "write:pullrequest:bitbucket",
35        "pr create, comment, resolve, request-changes",
36    ),
37    (
38        "read:project:bitbucket",
39        "project list, and repo create's project picker",
40    ),
41    (
42        "admin:repository:bitbucket",
43        "repo create — no combination of the read scopes covers it",
44    ),
45];
46
47/// The scope block both the walkthrough and `--help` print, one `scope  why`
48/// line per entry, aligned on the widest name. Rendered from `SCOPES` rather
49/// than written out a second time: the walkthrough, `--help` and the README
50/// each used to carry their own copy of this list, and two of the three fell
51/// behind when `repo create` shipped.
52pub fn scope_lines(indent: &str) -> String {
53    let width = SCOPES.iter().map(|(s, _)| s.len()).max().unwrap_or(0);
54    SCOPES
55        .iter()
56        .map(|(scope, why)| format!("{indent}{scope:<width$}  {why}"))
57        .collect::<Vec<_>>()
58        .join("\n")
59}
60
61/// `--help` carries the same guidance as the interactive walkthrough, because
62/// `--email` with `--token-stdin` skips the walkthrough entirely and a CI user
63/// has nowhere else to read it.
64pub fn login_long_about() -> String {
65    format!(
66        "Store an atlassian api token in the os keyring.
67
68Create the token at {TOKEN_HELP_URL},
69choosing \"Create API token with scopes\" and Bitbucket as the product, then grant:
70
71{}
72
73The first three are the read-only floor. Grant the rest only for the commands
74named beside them: writing to a pull request, listing projects, and creating a
75repository are each a separate grant.",
76        scope_lines("  ")
77    )
78}
79
80/// Printed before the prompts, because a token created without scopes — or with
81/// the wrong ones — fails verification and the user has no way to guess which of
82/// the two dozen Bitbucket scopes this tool wanted. Only for someone who is about
83/// to type values: a caller that passed `--email` and `--token-stdin` already has
84/// a token, and on a CI runner these lines are just noise in the captured log.
85fn print_onboarding() {
86    output::heading("bb authenticates with an atlassian api token");
87    output::info(
88        "atlassian retired the older bitbucket credential on 2026-07-28 — an api token is \
89         the only one left",
90    );
91    println!();
92    output::info(&format!("1. open {TOKEN_HELP_URL}"));
93    output::info("2. choose \"Create API token with scopes\", then pick Bitbucket as the product");
94    output::info("3. grant these scopes:");
95    println!("{}", scope_lines("     "));
96    output::info(
97        "   the first three are the read-only floor; grant the rest only for the commands \
98         beside them",
99    );
100    output::info("4. copy the token — atlassian shows it once — and paste it below");
101    println!();
102}
103
104/// The likely cause of a failed verification, or `None` when the failure says
105/// nothing about credentials. Printed as a warning rather than folded into the
106/// error, so the exit code stays what the http layer decided: `check()` renders
107/// every 401 as "not authenticated" and every 403 as a scope problem in general
108/// terms, neither of which helps someone who has just typed a brand-new token.
109fn verification_hint(err: &BbError) -> Option<&'static str> {
110    match err {
111        BbError::Auth => Some(
112            "the email or token was rejected — the username must be your atlassian account \
113             email, and the password the api token itself, not your atlassian password",
114        ),
115        BbError::Api { status: 403, .. } => Some(
116            "the token was accepted but the request was refused — most likely the \
117             read:user:bitbucket scope is missing; a revoked token or an organisation \
118             access policy gives the same answer",
119        ),
120        _ => None,
121    }
122}
123
124#[derive(Debug, Serialize)]
125pub struct AuthStatus {
126    pub email: String,
127    /// Already redacted. Never holds the real token.
128    pub token: String,
129    pub account: Option<String>,
130}
131
132/// Renders an [`AuthStatus`] as either JSON or the `FIELD | VALUE` human table,
133/// shared by `login` and `status` so they can't drift in shape.
134fn print_status(format: Format, status: &AuthStatus, unverified_label: &str) -> Result<()> {
135    match format {
136        Format::Json => output::print_json(status),
137        Format::Human => {
138            output::print_table(
139                &["FIELD", "VALUE"],
140                vec![
141                    vec!["email".into(), status.email.clone()],
142                    vec!["token".into(), status.token.clone()],
143                    vec![
144                        "account".into(),
145                        status
146                            .account
147                            .clone()
148                            .unwrap_or_else(|| unverified_label.into()),
149                    ],
150                ],
151            );
152            Ok(())
153        }
154    }
155}
156
157pub async fn login(email: Option<String>, token_stdin: bool, format: Format) -> Result<()> {
158    // Never block on input that will not arrive: if stdin is not a terminal and
159    // either value would require a prompt, name the flags instead of hanging.
160    let would_prompt = email.is_none() || !token_stdin;
161
162    if would_prompt && !format.is_json() {
163        print_onboarding();
164    }
165
166    if would_prompt && !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
167        return Err(BbError::Config(
168            "no email/token on a non-interactive stdin — pass --email and --token-stdin".into(),
169        ));
170    }
171
172    let email = match email {
173        Some(value) => value,
174        None => inquire::Text::new("atlassian account email:")
175            .prompt()
176            .map_err(|e| BbError::Config(format!("cancelled: {e}")))?,
177    };
178
179    let token = if token_stdin {
180        let mut buf = String::new();
181        std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?;
182        SecretString::from(buf.trim().to_string())
183    } else {
184        // `Password` never echoes and never confirms into the terminal buffer.
185        let entered = inquire::Password::new("api token:")
186            .with_display_mode(inquire::PasswordDisplayMode::Masked)
187            .without_confirmation()
188            .prompt()
189            .map_err(|e| BbError::Config(format!("cancelled: {e}")))?;
190        SecretString::from(entered)
191    };
192
193    let email = email.trim().to_string();
194    if email.is_empty() || !email.contains('@') {
195        return Err(BbError::Config(
196            "email must be the atlassian account email address".into(),
197        ));
198    }
199
200    let creds = Credentials {
201        email: email.clone(),
202        token: token.clone(),
203    };
204
205    // Verify before persisting, so a bad token is never stored.
206    let spinner = output::spinner("verifying token");
207    let client = Client::from_env(creds.clone())?;
208    let verified = client.get_json::<crate::api::models::User>("/user").await;
209    spinner.finish_and_clear();
210    let user = match verified {
211        Ok(user) => user,
212        Err(err) => {
213            if let Some(hint) = verification_hint(&err) {
214                output::warn(hint);
215            }
216            return Err(err);
217        }
218    };
219
220    credentials::store(&email, &token)?;
221
222    let status = AuthStatus {
223        email,
224        token: creds.redacted_token(),
225        account: user.display_name,
226    };
227
228    if !format.is_json() {
229        output::success("token verified and saved to the os keyring");
230    }
231    print_status(format, &status, "-")?;
232
233    Ok(())
234}
235
236pub async fn status(format: Format) -> Result<()> {
237    let creds = credentials::load()?;
238    let redacted = creds.redacted_token();
239
240    // Best-effort identity check; a network failure must not leak the token.
241    let account = match Client::from_env(creds.clone()) {
242        Ok(client) => client
243            .get_json::<crate::api::models::User>("/user")
244            .await
245            .ok()
246            .and_then(|u| u.display_name),
247        Err(_) => None,
248    };
249
250    let status = AuthStatus {
251        email: creds.email.clone(),
252        token: redacted,
253        account,
254    };
255
256    print_status(format, &status, "unverified")?;
257
258    Ok(())
259}
260
261pub fn logout(format: Format) -> Result<()> {
262    credentials::delete()?;
263    let legacy = credentials::legacy_config_path();
264    let legacy_exists = legacy.exists();
265
266    match format {
267        Format::Json => output::print_json(&serde_json::json!({ "removed": true }))?,
268        Format::Human => {
269            if legacy_exists {
270                output::warn(&format!(
271                    "a legacy plaintext credential file still exists at {} — delete it",
272                    legacy.display()
273                ));
274            }
275            output::success("credentials removed from the os keyring");
276        }
277    }
278    Ok(())
279}