1use std::collections::BTreeMap;
5use std::ffi::OsString;
6use std::fmt;
7use std::path::Path;
8use std::process::ExitCode;
9use std::thread;
10use std::time::{Duration, Instant};
11
12use runx_runtime::registry::{
13 HttpMethod, HttpRequest, RuntimeHttpError, RuntimeHttpHeader, Transport,
14};
15use runx_runtime::{
16 ConfigError, ConfigKey, load_runx_config_file, resolve_runx_home_dir, update_runx_config_value,
17 write_runx_config_file,
18};
19use serde::Deserialize;
20
21use crate::cli_args::{flag_value, os_arg, split_flag};
22
23const DEFAULT_LOGIN_TIMEOUT_SECONDS: u64 = 180;
24
25#[derive(Debug, Eq, PartialEq)]
26pub struct LoginPlan {
27 pub api_base_url: Option<String>,
28 pub provider: Option<String>,
29 pub purpose: Option<String>,
30 pub allow_local_api: bool,
31 pub json: bool,
32}
33
34#[derive(Debug)]
35pub enum LoginCliError {
36 UnknownFlag(String),
37 TransportInit(RuntimeHttpError),
38 Http(LoginHttpError),
39 MissingSigninUrl,
40 LoginTimedOut,
41 MissingToken,
42 Config(ConfigError),
43 Serialize(serde_json::Error),
44}
45
46impl fmt::Display for LoginCliError {
47 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
48 match self {
49 Self::UnknownFlag(flag) => write!(formatter, "unknown login flag {flag}"),
50 Self::TransportInit(error) => {
51 write!(formatter, "failed to initialize HTTP transport: {error}")
52 }
53 Self::Http(error) => write!(formatter, "{error}"),
54 Self::MissingSigninUrl => write!(
55 formatter,
56 "public API login response did not include a browser sign-in URL"
57 ),
58 Self::LoginTimedOut => {
59 write!(formatter, "public API login timed out before completion")
60 }
61 Self::MissingToken => {
62 write!(formatter, "public API login completed without an API token")
63 }
64 Self::Config(error) => write!(formatter, "{error}"),
65 Self::Serialize(error) => {
66 write!(formatter, "failed to serialize login result: {error}")
67 }
68 }
69 }
70}
71
72impl std::error::Error for LoginCliError {}
73
74impl From<ConfigError> for LoginCliError {
75 fn from(error: ConfigError) -> Self {
76 Self::Config(error)
77 }
78}
79
80impl From<LoginHttpError> for LoginCliError {
81 fn from(error: LoginHttpError) -> Self {
82 Self::Http(error)
83 }
84}
85
86impl From<serde_json::Error> for LoginCliError {
87 fn from(error: serde_json::Error) -> Self {
88 Self::Serialize(error)
89 }
90}
91
92#[derive(Debug)]
93pub enum LoginHttpError {
94 RuntimeHttp(RuntimeHttpError),
95 HttpStatus { status: u16, body: String },
96 InvalidJson(String),
97 RunxApi { code: String, detail: String },
98}
99
100impl fmt::Display for LoginHttpError {
101 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
102 match self {
103 Self::RuntimeHttp(error) => write!(formatter, "{error}"),
104 Self::HttpStatus { status, body } => {
105 write!(formatter, "runx-api login returned HTTP {status}: {body}")
106 }
107 Self::InvalidJson(message) => {
108 write!(formatter, "runx-api login returned invalid JSON: {message}")
109 }
110 Self::RunxApi { code, detail } => {
111 write!(
112 formatter,
113 "runx-api login returned error [{code}]: {detail}"
114 )
115 }
116 }
117 }
118}
119
120impl std::error::Error for LoginHttpError {}
121
122impl From<RuntimeHttpError> for LoginHttpError {
123 fn from(error: RuntimeHttpError) -> Self {
124 Self::RuntimeHttp(error)
125 }
126}
127
128#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
129struct LoginStartResponse {
130 status: String,
131 session_id: String,
132 login_token: String,
133 #[serde(default)]
134 authorization_url: Option<String>,
135 #[serde(default)]
136 poll_after_ms: Option<u64>,
137}
138
139#[derive(Clone, Debug, serde::Serialize, PartialEq, Eq)]
140struct LoginStartRequest<'a> {
141 #[serde(skip_serializing_if = "Option::is_none")]
142 provider: Option<&'a str>,
143 #[serde(skip_serializing_if = "Option::is_none")]
144 purpose: Option<&'a str>,
145}
146
147#[derive(Clone, Debug, serde::Serialize, PartialEq, Eq)]
148struct LoginCompleteRequest<'a> {
149 login_token: &'a str,
150}
151
152#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
153struct LoginCompleteResponse {
154 status: String,
155 session_id: String,
156 #[serde(default)]
157 principal_id: Option<String>,
158 #[serde(default)]
159 credential_id: Option<String>,
160 #[serde(default)]
161 token: Option<String>,
162 #[serde(default)]
163 poll_after_ms: Option<u64>,
164}
165
166#[derive(Clone, Debug, serde::Serialize, PartialEq, Eq)]
167struct LoginResult {
168 status: &'static str,
169 principal_id: String,
170 credential_id: String,
171}
172
173pub fn parse_login_plan(args: &[OsString]) -> Result<LoginPlan, String> {
174 let mut api_base_url = None;
175 let mut provider = None;
176 let mut purpose = None;
177 let mut allow_local_api = false;
178 let mut json = false;
179 let mut index = 1;
180 while index < args.len() {
181 let arg = os_arg(args, index, "login")?;
182 if !arg.starts_with('-') {
183 return Err(format!("unexpected login argument {arg}"));
184 }
185 let (flag, inline_value) = split_flag(arg);
186 match flag {
187 "--json" | "-j" => {
188 if inline_value.is_some() {
189 return Err("--json does not take a value".to_owned());
190 }
191 json = true;
192 index += 1;
193 }
194 "--api-base-url" => {
195 let (value, next_index) = flag_value(args, index, flag, inline_value, "login")?;
196 api_base_url = Some(value);
197 index = next_index;
198 }
199 "--provider" => {
200 let (value, next_index) = flag_value(args, index, flag, inline_value, "login")?;
201 provider = Some(value);
202 index = next_index;
203 }
204 "--for" | "--purpose" => {
205 let (value, next_index) = flag_value(args, index, flag, inline_value, "login")?;
206 purpose = Some(value);
207 index = next_index;
208 }
209 "--allow-local-api" => {
210 if inline_value.is_some() {
211 return Err("--allow-local-api does not take a value".to_owned());
212 }
213 allow_local_api = true;
214 index += 1;
215 }
216 _ => return Err(LoginCliError::UnknownFlag(flag.to_owned()).to_string()),
217 }
218 }
219 Ok(LoginPlan {
220 api_base_url,
221 provider,
222 purpose,
223 allow_local_api,
224 json,
225 })
226}
227
228pub fn run_native_login(plan: LoginPlan) -> ExitCode {
229 let cwd = match std::env::current_dir() {
230 Ok(cwd) => cwd,
231 Err(error) => {
232 let _ignored = crate::cli_io::write_stderr(&format!(
233 "runx login: failed to resolve cwd: {error}\n"
234 ));
235 return ExitCode::from(1);
236 }
237 };
238 match run_login_command(&plan, &crate::history::env_map(), &cwd) {
239 Ok(output) => crate::cli_io::write_stdout_code(&output, 0),
240 Err(error) => {
241 if plan.json {
242 let body = serde_json::json!({
243 "status": "failure",
244 "error": {
245 "message": error.to_string(),
246 "code": "login_failed",
247 },
248 });
249 let serialized = serde_json::to_string_pretty(&body)
250 .unwrap_or_else(|_| "{\"status\":\"failure\"}".to_owned());
251 return crate::cli_io::write_stdout_code(&format!("{serialized}\n"), 1);
252 }
253 let _ignored = crate::cli_io::write_stderr(&format!("runx login: {error}\n"));
254 ExitCode::from(1)
255 }
256 }
257}
258
259pub fn run_login_command(
260 plan: &LoginPlan,
261 env: &BTreeMap<String, String>,
262 cwd: &Path,
263) -> Result<String, LoginCliError> {
264 let transport = crate::public_api::transport(allow_local_api(plan, env))
265 .map_err(LoginCliError::TransportInit)?;
266 run_login_command_with_transport(plan, env, cwd, &transport, thread::sleep)
267}
268
269fn run_login_command_with_transport<T: Transport>(
270 plan: &LoginPlan,
271 env: &BTreeMap<String, String>,
272 cwd: &Path,
273 transport: &T,
274 sleep: impl Fn(Duration),
275) -> Result<String, LoginCliError> {
276 let base_url = resolve_public_api_base_url(plan, env);
277 let started = start_login_session(
278 transport,
279 &base_url,
280 plan.provider.as_deref(),
281 plan.purpose.as_deref(),
282 )?;
283 let signin_url = started
284 .authorization_url
285 .as_deref()
286 .filter(|value| !value.trim().is_empty())
287 .ok_or(LoginCliError::MissingSigninUrl)?;
288 if !plan.json {
289 let _ignored = crate::cli_io::write_stderr(&format!(
290 "Open this URL to sign in to runx:\n{signin_url}\n\nWaiting for public API login...\n"
291 ));
292 }
293
294 let deadline = Instant::now() + Duration::from_secs(DEFAULT_LOGIN_TIMEOUT_SECONDS);
295 let mut poll_after = Duration::from_millis(started.poll_after_ms.unwrap_or(1000));
296 loop {
297 let completed = complete_login_session(
298 transport,
299 &base_url,
300 &started.session_id,
301 &started.login_token,
302 )?;
303 if completed.status == "success" {
304 let token = completed
305 .token
306 .as_deref()
307 .filter(|value| !value.trim().is_empty())
308 .ok_or(LoginCliError::MissingToken)?;
309 store_public_api_token(env, cwd, token)?;
310 let result = LoginResult {
311 status: "success",
312 principal_id: completed.principal_id.unwrap_or_default(),
313 credential_id: completed.credential_id.unwrap_or_default(),
314 };
315 return render_login_result(plan.json, &result);
316 }
317 if Instant::now() >= deadline {
318 return Err(LoginCliError::LoginTimedOut);
319 }
320 if let Some(next_poll_after) = completed.poll_after_ms {
321 poll_after = Duration::from_millis(next_poll_after);
322 }
323 sleep(poll_after);
324 }
325}
326
327fn store_public_api_token(
328 env: &BTreeMap<String, String>,
329 cwd: &Path,
330 token: &str,
331) -> Result<(), LoginCliError> {
332 let config_dir = resolve_runx_home_dir(env, cwd);
333 let config_path = config_dir.join("config.json");
334 let config = load_runx_config_file(&config_path)?;
335 let next = update_runx_config_value(config, ConfigKey::PublicApiToken, token, &config_dir)?;
336 write_runx_config_file(&config_path, &next)?;
337 Ok(())
338}
339
340fn allow_local_api(plan: &LoginPlan, env: &BTreeMap<String, String>) -> bool {
341 crate::public_api::private_network_allowed(
342 plan.allow_local_api,
343 env,
344 "RUNX_LOGIN_ALLOW_LOCAL_API",
345 )
346}
347
348fn resolve_public_api_base_url(plan: &LoginPlan, env: &BTreeMap<String, String>) -> String {
349 crate::public_api::resolve_base_url(plan.api_base_url.as_deref(), env)
350}
351
352fn start_login_session<T: Transport>(
353 transport: &T,
354 base_url: &str,
355 provider: Option<&str>,
356 purpose: Option<&str>,
357) -> Result<LoginStartResponse, LoginHttpError> {
358 let request = LoginStartRequest {
359 provider: provider.map(str::trim).filter(|value| !value.is_empty()),
360 purpose: purpose.map(str::trim).filter(|value| !value.is_empty()),
361 };
362 let response = transport.send(HttpRequest {
363 method: HttpMethod::Post,
364 url: format!("{}/v1/login/sessions", base_url.trim_end_matches('/')),
365 headers: vec![RuntimeHttpHeader::new("content-type", "application/json")],
366 body: Some(
367 serde_json::to_string(&request)
368 .map_err(|error| LoginHttpError::InvalidJson(error.to_string()))?,
369 ),
370 })?;
371 json_response(response.status, &response.body)
372}
373
374fn complete_login_session<T: Transport>(
375 transport: &T,
376 base_url: &str,
377 session_id: &str,
378 login_token: &str,
379) -> Result<LoginCompleteResponse, LoginHttpError> {
380 let body = serde_json::to_string(&LoginCompleteRequest { login_token })
381 .map_err(|error| LoginHttpError::InvalidJson(error.to_string()))?;
382 let response = transport.send(HttpRequest {
383 method: HttpMethod::Post,
384 url: format!(
385 "{}/v1/login/sessions/{}/complete",
386 base_url.trim_end_matches('/'),
387 session_id
388 ),
389 headers: vec![RuntimeHttpHeader::new("content-type", "application/json")],
390 body: Some(body),
391 })?;
392 json_response(response.status, &response.body)
393}
394
395fn json_response<T: for<'de> Deserialize<'de>>(
396 status: u16,
397 body: &str,
398) -> Result<T, LoginHttpError> {
399 if !(200..=299).contains(&status) {
400 if let Some(error) = crate::public_api::parse_error(body) {
401 return Err(LoginHttpError::RunxApi {
402 code: error.code,
403 detail: error.detail,
404 });
405 }
406 return Err(LoginHttpError::HttpStatus {
407 status,
408 body: body.to_owned(),
409 });
410 }
411 serde_json::from_str(body).map_err(|error| LoginHttpError::InvalidJson(error.to_string()))
412}
413
414fn render_login_result(json: bool, result: &LoginResult) -> Result<String, LoginCliError> {
415 if json {
416 return serde_json::to_string_pretty(result)
417 .map(|value| format!("{value}\n"))
418 .map_err(LoginCliError::Serialize);
419 }
420 Ok(format!(
421 "\n ✓ login success\n principal {}\n credential {}\n\n",
422 result.principal_id, result.credential_id
423 ))
424}
425
426#[cfg(test)]
427#[path = "login_tests.rs"]
428mod login_tests;