Skip to main content

runx_cli/
login.rs

1// rust-style-allow: large-file - public API login keeps parse, HTTP exchange,
2// encrypted-token storage, and focused tests together while the public auth API
3// is still small.
4use 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;