//! 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());
}
}
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
9.0 KiB · text
History
1294137