Skip to main content

runtime_cli/commands/
auth.rs

1//! `runtime auth` — login / logout / status.
2//!
3//! `login` follows a two-step API dance:
4//! 1. `signIn(username, password)` → session token.
5//! 2. `createPersonalAccessToken { name, scopes: [REPO_ADMIN] }` →
6//!    persistent PAT.
7//!
8//! The PAT is what we keep on disk; the short-lived session token is
9//! discarded. The public hosted Runtime remote is the default host.
10
11use std::io::{self, BufRead, Write};
12
13use anyhow::{anyhow, bail, Context, Result};
14use clap::Subcommand;
15use serde_json::json;
16
17use crate::client::Client;
18use crate::config::Config;
19
20pub const DEFAULT_REMOTE_HOST: &str = "https://gitruntime.com";
21
22#[derive(Debug, Subcommand)]
23pub enum AuthCmd {
24    /// Authenticate against a Runtime instance and store a PAT.
25    Login(LoginArgs),
26    /// Forget the saved credentials.
27    Logout,
28    /// Print the current host and username.
29    Status,
30}
31
32#[derive(Debug, clap::Args)]
33pub struct LoginArgs {
34    /// Base URL of the Runtime instance.
35    #[arg(long, env = "RUNTIME_HOST", default_value = DEFAULT_REMOTE_HOST)]
36    pub host: Option<String>,
37    /// Username for the account to log in as.
38    #[arg(long, env = "RUNTIME_USER")]
39    pub username: Option<String>,
40    /// Read the password from stdin (one line). Otherwise prompt
41    /// interactively.
42    #[arg(long, default_value_t = false)]
43    pub password_stdin: bool,
44    /// PAT display name (helps you find it in the UI later).
45    #[arg(long, default_value = "runtime-cli")]
46    pub token_name: String,
47}
48
49pub fn run(cmd: AuthCmd) -> Result<()> {
50    match cmd {
51        AuthCmd::Login(a) => login(a),
52        AuthCmd::Logout => logout(),
53        AuthCmd::Status => status(),
54    }
55}
56
57fn login(args: LoginArgs) -> Result<()> {
58    let mut stdout = io::stdout().lock();
59    let host = args.host.unwrap_or_else(|| DEFAULT_REMOTE_HOST.to_string());
60    let username = match args.username {
61        Some(u) => u,
62        None => prompt(&mut stdout, "Username: ")?,
63    };
64    let password = if args.password_stdin {
65        let mut buf = String::new();
66        io::stdin()
67            .lock()
68            .read_line(&mut buf)
69            .context("read password from stdin")?;
70        trim_input_line(&buf)
71    } else {
72        rpassword::prompt_password("Password: ").context("read password")?
73    };
74
75    let client = Client::new(host.clone(), None)?;
76
77    // Step 1: signIn → session token. We don't support TOTP in v1; if
78    // the API returns a partial token we surface a helpful error.
79    let signin = client.execute(
80        SIGN_IN,
81        json!({ "input": { "username": username, "password": password } }),
82    )?;
83    if let Some(partial) = signin["signIn"]["partialToken"].as_str() {
84        let _ = partial;
85        bail!("TOTP-enabled accounts are not yet supported by `runtime auth login`");
86    }
87    let session_token = signin["signIn"]["token"]
88        .as_str()
89        .ok_or_else(|| anyhow!("signIn returned no token"))?
90        .to_string();
91
92    // Step 2: createPersonalAccessToken with REPO_ADMIN scope.
93    let authed = Client::new(host.clone(), Some(session_token))?;
94    // Mint a broadly-scoped PAT so every subcommand works. We include
95    // the read/write/admin tier plus workflow dispatch so `runtime run
96    // cancel` is callable.
97    let pat = authed.execute(
98        CREATE_PAT,
99        json!({
100            "input": {
101                "name": args.token_name,
102                "scopes": ["REPO_READ", "REPO_WRITE", "REPO_ADMIN", "WORKFLOW_DISPATCH"],
103            }
104        }),
105    )?;
106    let token = pat["createPersonalAccessToken"]["token"]
107        .as_str()
108        .ok_or_else(|| anyhow!("createPersonalAccessToken returned no token"))?
109        .to_string();
110
111    let cfg = login_config(host, username, token);
112    let path = cfg.save()?;
113    writeln!(stdout, "{}", login_success_message(&cfg, path.display()))?;
114    Ok(())
115}
116
117fn logout() -> Result<()> {
118    let removed = Config::delete()?;
119    let mut stdout = io::stdout().lock();
120    if removed {
121        writeln!(stdout, "Removed saved credentials")?;
122    } else {
123        writeln!(stdout, "Nothing to do — no saved credentials")?;
124    }
125    Ok(())
126}
127
128fn status() -> Result<()> {
129    let mut stdout = io::stdout().lock();
130    match Config::load() {
131        Ok(cfg) => {
132            for line in status_lines(&cfg) {
133                writeln!(stdout, "{line}")?;
134            }
135            Ok(())
136        }
137        Err(e) => {
138            writeln!(stdout, "Not logged in ({e:#})")?;
139            Ok(())
140        }
141    }
142}
143
144fn prompt(stdout: &mut io::StdoutLock<'_>, label: &str) -> Result<String> {
145    write!(stdout, "{label}")?;
146    stdout.flush()?;
147    let mut buf = String::new();
148    io::stdin().lock().read_line(&mut buf)?;
149    Ok(trim_input_line(&buf))
150}
151
152fn trim_input_line(input: &str) -> String {
153    input.trim_end_matches(['\n', '\r']).to_string()
154}
155
156fn login_config(host: String, username: String, token: String) -> Config {
157    Config {
158        host: host.trim_end_matches('/').to_string(),
159        username,
160        token,
161    }
162}
163
164fn login_success_message(cfg: &Config, path: impl std::fmt::Display) -> String {
165    format!(
166        "Logged in to {} as {} — credentials saved to {}",
167        cfg.host, cfg.username, path
168    )
169}
170
171fn status_lines(cfg: &Config) -> [String; 3] {
172    [
173        format!("Host: {}", cfg.host),
174        format!("User: {}", cfg.username),
175        "Token: ********".to_string(),
176    ]
177}
178
179const SIGN_IN: &str = "mutation($input: SignInInput!) { \
180    signIn(input: $input) { token partialToken } \
181}";
182
183const CREATE_PAT: &str = "mutation($input: CreatePatInput!) { \
184    createPersonalAccessToken(input: $input) { token record { id name } } \
185}";
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn trim_input_line_removes_only_line_endings() {
193        assert_eq!(trim_input_line("secret\n"), "secret");
194        assert_eq!(trim_input_line("secret\r\n"), "secret");
195        assert_eq!(trim_input_line(" secret \n"), " secret ");
196    }
197
198    #[test]
199    fn login_config_trims_trailing_host_slashes_and_preserves_secret() {
200        let cfg = login_config(
201            "https://runtime.test///".into(),
202            "bri".into(),
203            "token-value".into(),
204        );
205
206        assert_eq!(cfg.host, "https://runtime.test");
207        assert_eq!(cfg.username, "bri");
208        assert_eq!(cfg.token, "token-value");
209    }
210
211    #[test]
212    fn login_success_and_status_messages_never_print_token_value() {
213        let cfg = Config {
214            host: "https://runtime.test".into(),
215            username: "bri".into(),
216            token: "super-secret-token".into(),
217        };
218
219        let login = login_success_message(&cfg, "/tmp/runtime-config.json");
220        assert_eq!(
221            login,
222            "Logged in to https://runtime.test as bri — credentials saved to /tmp/runtime-config.json"
223        );
224        assert!(!login.contains("super-secret-token"));
225
226        let status = status_lines(&cfg);
227        assert_eq!(status[0], "Host: https://runtime.test");
228        assert_eq!(status[1], "User: bri");
229        assert_eq!(status[2], "Token: ********");
230        assert!(status
231            .iter()
232            .all(|line| !line.contains("super-secret-token")));
233    }
234}