use std::io::IsTerminal;
use anyhow::{Context, Result, anyhow};
use clap::{Args, ValueEnum};
use dialoguer::{Select, theme::ColorfulTheme};
use super::api_key;
use super::oidc::{self, DiscoveryOverrides, OidcConfig, OidcTokens, discovery};
use crate::credentials::{self, Profile};
use crate::defaults::LOCAL_SERVE_URL;
const URL_ENV: &str = "BOXLITE_REST_URL";
#[derive(Args, Debug, Clone)]
pub struct LoginArgs {
#[arg(long)]
pub url: Option<String>,
#[arg(long)]
pub api_key_stdin: bool,
#[arg(long, value_enum)]
pub method: Option<Method>,
#[arg(long, conflicts_with_all = ["api_key_stdin"])]
pub no_browser: bool,
#[arg(long)]
pub issuer: Option<String>,
#[arg(long)]
pub client_id: Option<String>,
#[arg(long)]
pub audience: Option<String>,
#[arg(long)]
pub callback_port: Option<u16>,
}
#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
#[value(rename_all = "kebab-case")]
pub enum Method {
ApiKey,
Browser,
Device,
}
pub async fn run(args: LoginArgs, profile_name: &str) -> Result<()> {
let method = resolve_method(&args)?;
let url = resolve_target(&args)?;
match method {
Method::ApiKey => api_key::run(Some(&url), args.api_key_stdin, profile_name).await,
Method::Browser => run_browser(&args, &url, profile_name).await,
Method::Device => run_device(&args, &url, profile_name).await,
}
}
async fn run_browser(args: &LoginArgs, url: &str, profile_name: &str) -> Result<()> {
let http = discovery::http_client()?;
let overrides = overrides_from_args(args);
let cfg = discovery::resolve_config(url, &overrides, &http).await?;
let port = args
.callback_port
.unwrap_or(oidc::auth_code::DEFAULT_CALLBACK_PORT);
let tokens = oidc::auth_code::run(&cfg, &http, port).await?;
persist_oidc(url, profile_name, &cfg, tokens).await
}
async fn run_device(args: &LoginArgs, url: &str, profile_name: &str) -> Result<()> {
let http = discovery::http_client()?;
let overrides = overrides_from_args(args);
let cfg = discovery::resolve_config(url, &overrides, &http).await?;
let tokens = oidc::device_code::run(&cfg, &http).await?;
persist_oidc(url, profile_name, &cfg, tokens).await
}
async fn persist_oidc(
url: &str,
profile_name: &str,
cfg: &OidcConfig,
tokens: OidcTokens,
) -> Result<()> {
let mut profile = Profile {
url: url.to_string(),
..Profile::default()
};
oidc::refresh::apply_tokens(&mut profile, cfg, tokens);
let principal = verify_principal(&profile).await?;
profile.path_prefix = principal.path_prefix.clone();
credentials::save_named(profile_name, &profile).context("saving credentials")?;
let who = principal.email.as_deref().unwrap_or(principal.sub.as_str());
match principal.path_prefix.as_deref() {
Some(path_prefix) => println!("Logged in as {} (path prefix: {})", who, path_prefix),
None => println!(
"Logged in as {} (no path prefix; use --path-prefix or BOXLITE_REST_PATH_PREFIX for multi-tenant servers)",
who
),
}
Ok(())
}
async fn verify_principal(profile: &Profile) -> Result<boxlite::Principal> {
use boxlite::BoxliteRuntime;
let opts = credentials::into_rest_options(profile.clone());
let runtime = BoxliteRuntime::rest(opts)
.map_err(|e| anyhow!("failed to construct REST runtime: {}", e))?;
let auth = runtime
.auth()
.map_err(|e| anyhow!("failed to construct REST runtime: {}", e))?;
auth.whoami()
.await
.map_err(|err| anyhow!("server rejected new OIDC session: {err}"))
}
fn overrides_from_args(args: &LoginArgs) -> DiscoveryOverrides {
DiscoveryOverrides {
issuer: args.issuer.clone(),
client_id: args.client_id.clone(),
audience: args.audience.clone(),
}
}
fn resolve_method(args: &LoginArgs) -> Result<Method> {
if args.api_key_stdin {
return Ok(Method::ApiKey);
}
if let Some(m) = args.method {
return Ok(if args.no_browser && m == Method::Browser {
Method::Device
} else {
m
});
}
if args.no_browser {
return Ok(Method::Device);
}
if !std::io::stdin().is_terminal() {
return Ok(Method::ApiKey);
}
prompt_method()
}
fn prompt_method() -> Result<Method> {
let items = [
"API key (paste)",
"Browser (OIDC)",
"Device code (headless / SSH)",
];
let chosen = Select::with_theme(&ColorfulTheme::default())
.with_prompt("How do you want to log in?")
.items(&items)
.default(0)
.interact()
.map_err(|e| anyhow!("reading login method choice: {e}"))?;
Ok(match chosen {
0 => Method::ApiKey,
1 => {
if should_use_device_flow() {
eprintln!(
"no browser available (headless environment detected); using device code"
);
Method::Device
} else {
Method::Browser
}
}
2 => Method::Device,
_ => unreachable!("dialoguer::Select returns an index in 0..items.len()"),
})
}
fn resolve_target(args: &LoginArgs) -> Result<String> {
if let Some(url) = args.url.as_deref() {
return Ok(url.to_string());
}
if let Ok(env_url) = std::env::var(URL_ENV)
&& !env_url.is_empty()
{
return Ok(env_url);
}
Ok(LOCAL_SERVE_URL.to_string())
}
fn should_use_device_flow() -> bool {
std::env::var_os("SSH_CONNECTION").is_some()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn api_key_stdin_forces_api_key_method() {
let args = LoginArgs {
url: None,
api_key_stdin: true,
method: Some(Method::Browser),
no_browser: false,
issuer: None,
client_id: None,
audience: None,
callback_port: None,
};
assert_eq!(resolve_method(&args).unwrap(), Method::ApiKey);
}
#[test]
fn no_browser_overrides_browser_method() {
let args = LoginArgs {
url: None,
api_key_stdin: false,
method: Some(Method::Browser),
no_browser: true,
issuer: None,
client_id: None,
audience: None,
callback_port: None,
};
assert_eq!(resolve_method(&args).unwrap(), Method::Device);
}
#[test]
fn resolve_target_defaults_to_local_serve() {
unsafe {
std::env::remove_var(URL_ENV);
}
let args = LoginArgs {
url: None,
api_key_stdin: false,
method: None,
no_browser: false,
issuer: None,
client_id: None,
audience: None,
callback_port: None,
};
assert_eq!(resolve_target(&args).unwrap(), LOCAL_SERVE_URL);
}
}