use std::io::IsTerminal;
use anyhow::{Context, Result, anyhow, bail};
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, CredentialStore, Profile};
use crate::defaults::LOCAL_SERVE_URL;
const URL_ENV: &str = "BOXLITE_REST_URL";
#[derive(Args, Debug, Clone)]
pub struct LoginArgs {
#[arg(
long,
value_parser = clap::builder::NonEmptyStringValueParser::new()
)]
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, value_parser = clap::value_parser!(u16).range(1..))]
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, store: &CredentialStore) -> Result<()> {
let method = resolve_method(&args)?;
let stored_url = store.load_named(profile_name)?.map(|profile| profile.url);
let url = resolve_target(&args, stored_url.as_deref())?;
match method {
Method::ApiKey => api_key::run(Some(&url), args.api_key_stdin, profile_name, store).await,
Method::Browser => run_browser(&args, &url, profile_name, store).await,
Method::Device => run_device(&args, &url, profile_name, store).await,
}
}
async fn run_browser(
args: &LoginArgs,
url: &str,
profile_name: &str,
store: &CredentialStore,
) -> 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, store).await
}
async fn run_device(
args: &LoginArgs,
url: &str,
profile_name: &str,
store: &CredentialStore,
) -> 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, store).await
}
async fn persist_oidc(
url: &str,
profile_name: &str,
cfg: &OidcConfig,
tokens: OidcTokens,
store: &CredentialStore,
) -> 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();
store
.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 {} (the server returned no path prefix)", 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> {
let method = if args.api_key_stdin {
Method::ApiKey
} else if let Some(m) = args.method {
m
} else if args.no_browser {
Method::Device
} else if !std::io::stdin().is_terminal() {
Method::ApiKey
} else {
prompt_method()?
};
validate_method_options(args, method)?;
Ok(method)
}
fn validate_method_options(args: &LoginArgs, method: Method) -> Result<()> {
if args.api_key_stdin && args.method.is_some_and(|method| method != Method::ApiKey) {
bail!("--api-key-stdin cannot be combined with a non-api-key --method");
}
if args.no_browser && args.method == Some(Method::Browser) {
bail!("--no-browser cannot be combined with --method browser");
}
if method == Method::ApiKey {
if args.no_browser {
bail!("--no-browser cannot be used with --method api-key");
}
for (flag, is_set) in [
("--issuer", args.issuer.is_some()),
("--client-id", args.client_id.is_some()),
("--audience", args.audience.is_some()),
] {
if is_set {
bail!("{flag} applies only to browser or device login");
}
}
}
if method != Method::Browser && args.callback_port.is_some() {
bail!("--callback-port applies only to browser login");
}
Ok(())
}
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, stored_url: Option<&str>) -> Result<String> {
if let Some(url) = args.url.as_deref() {
return Ok(url.to_string());
}
match std::env::var(URL_ENV) {
Ok(env_url) if !env_url.is_empty() => return Ok(env_url),
Ok(_) | Err(std::env::VarError::NotPresent) => {}
Err(std::env::VarError::NotUnicode(_)) => {
bail!("{URL_ENV} must contain valid UTF-8")
}
}
if let Some(stored_url) = stored_url.filter(|url| !url.is_empty()) {
return Ok(stored_url.to_string());
}
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_selects_api_key_method() {
let args = LoginArgs {
url: None,
api_key_stdin: true,
method: None,
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_rejects_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!(resolve_method(&args).is_err());
}
#[test]
fn login_rejects_options_the_selected_method_cannot_use() {
let cases = [
LoginArgs {
url: None,
api_key_stdin: false,
method: Some(Method::ApiKey),
no_browser: true,
issuer: None,
client_id: None,
audience: None,
callback_port: None,
},
LoginArgs {
url: None,
api_key_stdin: false,
method: Some(Method::ApiKey),
no_browser: false,
issuer: Some("https://issuer.example.test".to_string()),
client_id: None,
audience: None,
callback_port: None,
},
LoginArgs {
url: None,
api_key_stdin: false,
method: Some(Method::Device),
no_browser: false,
issuer: None,
client_id: None,
audience: None,
callback_port: Some(5555),
},
LoginArgs {
url: None,
api_key_stdin: true,
method: Some(Method::Device),
no_browser: false,
issuer: None,
client_id: None,
audience: None,
callback_port: None,
},
LoginArgs {
url: None,
api_key_stdin: true,
method: Some(Method::Browser),
no_browser: false,
issuer: None,
client_id: None,
audience: None,
callback_port: None,
},
];
for args in cases {
assert!(resolve_method(&args).is_err(), "accepted {args:?}");
}
}
#[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, None).unwrap(), LOCAL_SERVE_URL);
}
#[test]
fn resolve_target_reuses_the_selected_profiles_url() {
let args = LoginArgs {
url: None,
api_key_stdin: true,
method: None,
no_browser: false,
issuer: None,
client_id: None,
audience: None,
callback_port: None,
};
assert_eq!(
resolve_target(&args, Some("https://cloud.example.test")).unwrap(),
"https://cloud.example.test"
);
}
}