9.0 KiB · text History b868907
//! Signing in, signing out, and finding out who you are.
//!
//! Pairing verifies before it persists, as the Android client does:
//! `GET /me` also reports whether the token carries the full mobile scope
//! bundle. A token minted with, say, only `repo:read` authenticates fine
//! and then fails on every command but `repos`, so the mismatch is worth
//! naming here rather than leaving it to be discovered one dead command
//! at a time.

use anyhow::{Context, Result, anyhow, bail};
use std::io::{IsTerminal, Write};

use crate::api::Client;
use crate::cli::LoginArgs;
use crate::config::{self, Config, Credentials};
use crate::output::{Tone, heading, note, paint};

/// The scopes the Android client's "Mobile app" bundle grants. Used only
/// to explain a partial token — the server is the authority on each call.
const MOBILE_SCOPES: [&str; 9] = [
    "repo:read",
    "ci:read",
    "ci:write",
    "issues:read",
    "issues:write",
    "pulls:read",
    "pulls:write",
    "wiki:read",
    "packages:read",
];

pub async fn login(
    args: &LoginArgs,
    flag_url: Option<&str>,
    flag_token: Option<&str>,
    json: bool,
) -> Result<()> {
    let mut cfg = Config::load()?;

    let (url, token) = match &args.pairing_uri {
        Some(uri) => parse_pairing_uri(uri)?,
        None => {
            let url = match flag_url {
                Some(url) => config::normalize_url(url),
                None => config::normalize_url(&prompt("Server URL: ")?),
            };
            let token = if args.token_stdin {
                read_token_from_stdin()?
            } else {
                match flag_token {
                    Some(token) => token.to_string(),
                    None => prompt_secret("API token: ")?,
                }
            };
            (url, token)
        }
    };

    if token.trim().is_empty() {
        bail!("no API token given");
    }

    let creds = Credentials {
        url: config::coerce_https(&url),
        token: token.trim().to_string(),
    };

    let me = Client::new(&creds)?
        .me()
        .await
        .context("the forge rejected these credentials")?;

    cfg.url = Some(creds.url.clone());
    cfg.token = Some(creds.token.clone());
    if let Some(repo) = &args.default_repo {
        // Parsed for its own sake, so a typo is caught now rather than on
        // the first command that falls back to it.
        crate::api::RepoRef::parse(repo)?;
        cfg.default_repo = Some(repo.clone());
    }
    let path = cfg.save()?;

    if json {
        return crate::output::print_json(&me.raw);
    }

    let instance = me
        .instance
        .as_ref()
        .and_then(|i| i.name.clone())
        .unwrap_or_else(|| creds.url.clone());
    println!(
        "{} as {} on {instance}",
        paint("Signed in", Tone::Good),
        crate::output::bold(me.user.label())
    );
    note(&format!("credentials saved to {}", path.display()));

    if !me.mobile {
        let missing: Vec<&str> = MOBILE_SCOPES
            .iter()
            .copied()
            .filter(|s| !me.scopes.iter().any(|held| held == s))
            .collect();
        println!(
            "\n{}",
            paint(
                "This token doesn't carry the full mobile scope bundle.",
                Tone::Busy
            )
        );
        if !missing.is_empty() {
            note(&format!("missing: {}", missing.join(", ")));
        }
        note(
            "Re-create it under Settings → API tokens with “Mobile app” ticked if that wasn't intended.",
        );
    }
    Ok(())
}

pub fn logout(json: bool) -> Result<()> {
    let mut cfg = Config::load()?;
    let had_credentials = cfg.token.is_some();
    cfg.clear_credentials();
    let path = cfg.save()?;

    if json {
        return crate::output::print_json(&serde_json::json!({
            "signed_out": had_credentials,
            "config": path.display().to_string(),
        }));
    }

    if had_credentials {
        println!(
            "Signed out. The token itself still exists on the forge — revoke it under Settings → API tokens."
        );
    } else {
        note("Nothing to sign out of.");
    }
    Ok(())
}

pub async fn whoami(ctx: &crate::commands::Ctx) -> Result<()> {
    let me = ctx.api.me().await?;
    if ctx.emitted(&me.raw)? {
        return Ok(());
    }

    heading(me.user.label());
    if let Some(handle) = &me.user.handle {
        println!("handle    @{handle}");
    }
    if let Some(email) = &me.user.email {
        println!("email     {email}");
    }
    println!("forge     {}", ctx.base_url);
    if let Some(name) = me.instance.as_ref().and_then(|i| i.name.as_deref()) {
        println!("instance  {name}");
    }
    println!(
        "scopes    {}",
        if me.scopes.is_empty() {
            "(none reported)".to_string()
        } else {
            me.scopes.join(", ")
        }
    );
    if !me.mobile {
        note("this token is missing part of the mobile scope bundle; some commands will refuse");
    }
    Ok(())
}

/// Parse the `gitgud://pair?base=<server>&token=<api-token>` URI the forge
/// encodes into the QR on Settings → API tokens.
fn parse_pairing_uri(raw: &str) -> Result<(String, String)> {
    let uri =
        url::Url::parse(raw.trim()).map_err(|_| anyhow!("that isn't a pairing URI: {raw}"))?;
    if !uri.scheme().eq_ignore_ascii_case("gitgud") || uri.host_str() != Some("pair") {
        bail!("that isn't a GitGud pairing URI — expected gitgud://pair?base=…&token=…");
    }

    let mut base = None;
    let mut token = None;
    for (key, value) in uri.query_pairs() {
        match key.as_ref() {
            "base" if !value.trim().is_empty() => base = Some(value.trim().to_string()),
            "token" if !value.trim().is_empty() => token = Some(value.trim().to_string()),
            _ => {}
        }
    }

    let base = base.ok_or_else(|| anyhow!("the pairing URI has no `base`"))?;
    let token = token.ok_or_else(|| anyhow!("the pairing URI has no `token`"))?;
    let parsed = url::Url::parse(&base).map_err(|_| anyhow!("`base` isn't a URL: {base}"))?;
    if !matches!(parsed.scheme(), "http" | "https") {
        bail!("`base` must be an http(s) URL, got {base}");
    }
    Ok((base.trim_end_matches('/').to_string(), token))
}

fn prompt(label: &str) -> Result<String> {
    if !std::io::stdin().is_terminal() {
        bail!(
            "{} — pass it as a flag when running non-interactively",
            label.trim_end_matches([':', ' '])
        );
    }
    print!("{label}");
    std::io::stdout().flush()?;
    let mut line = String::new();
    std::io::stdin().read_line(&mut line)?;
    Ok(line.trim().to_string())
}

fn read_token_from_stdin() -> Result<String> {
    use std::io::Read;
    let mut buf = String::new();
    std::io::stdin().read_to_string(&mut buf)?;
    Ok(buf.trim().to_string())
}

/// Read a secret without echoing it.
///
/// Raw mode rather than a password crate: the terminal is already a
/// dependency here for the dashboard, and a token that lands in a scrollback
/// buffer is a token that has to be revoked.
fn prompt_secret(label: &str) -> Result<String> {
    use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
    use ratatui::crossterm::terminal;

    if !std::io::stdin().is_terminal() {
        bail!("no API token — pass --token, --token-stdin, or a pairing URI");
    }

    print!("{label}");
    std::io::stdout().flush()?;

    terminal::enable_raw_mode()?;
    let mut secret = String::new();
    let outcome = loop {
        match event::read() {
            Ok(Event::Key(key)) if key.kind == KeyEventKind::Press => match key.code {
                KeyCode::Enter => break Ok(()),
                KeyCode::Backspace => {
                    secret.pop();
                }
                KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                    break Err(anyhow!("cancelled"));
                }
                KeyCode::Char(c) => secret.push(c),
                _ => {}
            },
            Ok(_) => {}
            Err(e) => break Err(e.into()),
        }
    };
    terminal::disable_raw_mode()?;
    println!();
    outcome?;
    Ok(secret)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_a_pairing_uri() {
        let (base, token) =
            parse_pairing_uri("gitgud://pair?base=https://forge.example.com&token=ggp_abc")
                .unwrap();
        assert_eq!(base, "https://forge.example.com");
        assert_eq!(token, "ggp_abc");
    }

    #[test]
    fn rejects_other_qr_payloads() {
        // A QR scanner will happily hand over a WiFi config or a blog URL.
        for bad in [
            "WIFI:S:home;T:WPA;P:hunter2;;",
            "https://example.com/",
            "gitgud://pair?base=https://forge.example.com",
            "gitgud://other?base=https://forge.example.com&token=t",
        ] {
            assert!(parse_pairing_uri(bad).is_err(), "{bad} should not parse");
        }
    }

    #[test]
    fn rejects_non_http_bases() {
        assert!(parse_pairing_uri("gitgud://pair?base=file:///etc&token=t").is_err());
    }
}