runtime_cli/commands/
auth.rs1use 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 Login(LoginArgs),
26 Logout,
28 Status,
30}
31
32#[derive(Debug, clap::Args)]
33pub struct LoginArgs {
34 #[arg(long, env = "RUNTIME_HOST", default_value = DEFAULT_REMOTE_HOST)]
36 pub host: Option<String>,
37 #[arg(long, env = "RUNTIME_USER")]
39 pub username: Option<String>,
40 #[arg(long, default_value_t = false)]
43 pub password_stdin: bool,
44 #[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 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 let authed = Client::new(host.clone(), Some(session_token))?;
94 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}