use std::time::{Duration, Instant};
use anyhow::{bail, Context, Result};
use dialoguer::Confirm;
use serde_json::json;
use crate::auth::callback::{self, CallbackMessage, CallbackPayload};
use crate::auth::client::{AuthClient, AuthToken, PollResult, RevokeOutcome};
use crate::config::CliConfig;
use crate::output;
#[derive(Debug, Clone)]
pub struct LoginOptions {
pub api_key: Option<String>,
pub email: Option<String>,
pub code: Option<String>,
pub no_browser: bool,
}
#[derive(Debug, Clone)]
pub struct LogoutOptions {
pub local_only: bool,
}
#[derive(Debug, Clone)]
pub struct WhoamiOptions {
pub json: bool,
pub verify: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct VerifyResult {
pub valid: bool,
pub key_name: Option<String>,
pub server_app_id: Option<String>,
pub app_id_matches: bool,
pub mesh_telemetry: Option<bool>,
pub error: Option<String>,
}
pub async fn ensure_logged_in(interactive: bool) -> Result<()> {
let config = CliConfig::load()?;
if config.effective_api_key().is_some() || config.effective_access_token().is_some() {
return Ok(());
}
if !interactive {
bail!(
"not logged in — run `offline login` first (or set OFFLINE_API_KEY for headless use)"
);
}
output::warn("You are not logged in.");
let login_now = Confirm::new()
.with_prompt("Log in now? (runs `offline login`)")
.default(true)
.interact()
.context("reading confirmation")?;
if !login_now {
bail!("login required — run `offline login` to continue");
}
run_login(LoginOptions {
api_key: None,
email: None,
code: None,
no_browser: false,
})
.await
}
pub async fn run_login(options: LoginOptions) -> Result<()> {
let mut config = CliConfig::load()?;
let client = AuthClient::new(config.effective_api_base())?;
if let Some(key) = options.api_key {
return login_with_api_key(&client, &mut config, &key).await;
}
if options.email.is_some() {
return run_email_login(&client, &mut config, options.email, options.code).await;
}
let no_browser = options.no_browser || looks_headless();
if no_browser && !options.no_browser {
output::note("No browser detected (SSH or no display); using the printed URL instead");
}
if config.has_stored_credentials() {
let overwrite = Confirm::new()
.with_prompt("Already logged in. Overwrite existing credentials?")
.default(false)
.interact()
.context("reading confirmation")?;
if !overwrite {
bail!("login cancelled");
}
}
let device_name = hostname();
if no_browser {
output::note("Requesting login session from the developer portal...");
}
let callback_state = callback::generate_callback_state();
let bound_callback = match callback::bind_callback() {
Ok(bound) => {
if let Some(pinned) = bound.fallback_from {
output::note(&format!(
"{} port {pinned} is in use; using port {} for the login callback",
callback::ENV_CALLBACK_PORT,
bound.port()
));
}
Some(bound)
}
Err(err) => {
output::warn(&format!(
"localhost callback unavailable ({err:#}); continuing with poll-only login"
));
None
}
};
let callback_url = bound_callback.as_ref().map(|b| b.url(&callback_state));
let session = client
.create_auth_session(&device_name, callback_url.as_deref())
.await
.map_err(|e| enrich_login_session_error(e, &config.effective_api_base()))?;
let portal = config.effective_portal_base();
let url = if session.verification_url.starts_with("http") {
session.verification_url.clone()
} else {
format!("{portal}{}", session.verification_url)
};
print_login_instructions(
&url,
session.user_code.as_deref(),
no_browser,
callback_url.as_deref(),
);
if !no_browser {
println!("Opening browser...");
if open::that(&url).is_err() {
output::warn("could not open a browser — open the URL above manually");
}
}
let spinner = if no_browser {
None
} else {
let bar = indicatif::ProgressBar::new_spinner();
bar.set_message("Waiting for authorization...");
bar.enable_steady_tick(Duration::from_millis(120));
Some(bar)
};
if no_browser {
output::note("Waiting for authorization — complete login in your browser.");
}
let deadline = Instant::now() + Duration::from_secs(session.expires_in);
let poll_every = Duration::from_secs(session.poll_interval.max(2));
let mut ticker = tokio::time::interval(poll_every);
ticker.tick().await;
let mut callback_receiver = bound_callback
.map(|bound| callback::start_listener(bound, deadline, callback_state.clone()));
let mut callback_closed = callback_receiver.is_none();
loop {
tokio::select! {
_ = tokio::signal::ctrl_c() => {
if let Some(spinner) = &spinner {
spinner.finish_and_clear();
}
bail!("login cancelled");
}
message = async {
match callback_receiver.as_mut() {
Some((_, rx)) => rx.recv().await,
None => std::future::pending::<Option<CallbackMessage>>().await,
}
}, if !callback_closed => {
match message {
Some(CallbackMessage::DeveloperAuthenticated(auth)) => {
persist_dev_id(&mut config, auth.dev_id).await?;
output::note("Developer authenticated — finish key selection in the browser");
}
Some(CallbackMessage::AuthorizationComplete(payload)) => {
if let Some(spinner) = &spinner {
spinner.finish_and_clear();
}
if let Ok(PollResult::Ready(token)) = client
.poll_auth_session(&session.session_id, session.session_secret.as_deref())
.await
{
if token.access_token.is_some() {
store_from_poll_token(&client, &mut config, token).await?;
return Ok(());
}
}
store_from_callback(&client, &mut config, payload).await?;
terminate_cli_session(
&client,
&session.session_id,
session.session_secret.as_deref(),
)
.await?;
return Ok(());
}
None => {
callback_closed = true;
output::warn(
"callback listener closed; continuing with poll-only login",
);
}
}
}
_ = ticker.tick() => {
if Instant::now() > deadline {
if let Some(spinner) = &spinner {
spinner.finish_and_clear();
}
bail!("login session expired — run `offline login` again");
}
match client
.poll_auth_session(&session.session_id, session.session_secret.as_deref())
.await
{
Ok(PollResult::Pending) => continue,
Ok(PollResult::Denied) => {
if let Some(spinner) = &spinner {
spinner.finish_and_clear();
}
bail!("login was denied or the session expired");
}
Ok(PollResult::Invalid) => {
if let Some(spinner) = &spinner {
spinner.finish_and_clear();
}
bail!("invalid login session — run `offline login` again");
}
Ok(PollResult::Ready(token)) => {
if let Some(spinner) = &spinner {
spinner.finish_and_clear();
}
store_from_poll_token(&client, &mut config, token).await?;
return Ok(());
}
Err(e) => {
if let Some(spinner) = &spinner {
spinner.finish_and_clear();
}
return Err(e).context("polling login session failed");
}
}
}
}
}
}
fn enrich_login_session_error(err: anyhow::Error, api_base: &str) -> anyhow::Error {
let detail = err.to_string();
if detail.contains("404") && detail.contains("gateway route") {
return err.context(format!(
"browser login is not available on {api_base} yet — deploy the CLI auth routes \
on the API gateway (`POST /api/v1/developers/cli/sessions`, see CLI/docs/AUTH.md). \
For local development, run the developer portal stack and use \
`OFFLINE_ENV=dev offline login`. An API key is only needed for headless CI \
(`--api-key`), not for the normal browser flow once those endpoints are live."
));
}
err.context(format!(
"could not start a login session against {api_base} — check your network connection"
))
}
fn print_login_instructions(
url: &str,
user_code: Option<&str>,
no_browser: bool,
callback_url: Option<&str>,
) {
use std::io::Write;
println!();
if no_browser {
output::heading("Complete login in your browser");
}
if let Some(code) = user_code {
println!(
" Confirmation code: {}",
console::style(code).bold().cyan()
);
}
if no_browser {
println!(" Open this URL to approve the CLI login:");
println!(" {}", console::style(url).cyan().underlined());
} else {
println!(
" Approve this login at: {}",
console::style(url).underlined()
);
}
if let Some(callback_url) = callback_url {
output::note(&format!("Callback URL: {callback_url}"));
}
println!();
let _ = std::io::stdout().flush();
}
async fn run_email_login(
client: &AuthClient,
config: &mut CliConfig,
email: Option<String>,
code: Option<String>,
) -> Result<()> {
use dialoguer::{Input, Password};
use std::io::IsTerminal;
let interactive = std::io::stdin().is_terminal();
let email = match email.filter(|e| !e.trim().is_empty()) {
Some(email) => email,
None if interactive => Input::<String>::new()
.with_prompt("Developer email")
.interact_text()
.context("reading email")?,
None => bail!("pass your developer email: `offline login --email you@example.com`"),
};
let otp = match code.map(|c| c.trim().to_string()).filter(|c| !c.is_empty()) {
Some(code) => code,
None => {
output::note(&format!("Sending login code to {email}..."));
client
.request_auth_code(&email)
.await
.context("failed to request login code")?;
if !interactive {
output::success(&format!("Login code sent to {email}"));
println!(
" Finish signing in with: offline login --email {email} --code <code from the email>"
);
return Ok(());
}
Password::new()
.with_prompt("Enter the code from your email")
.interact()
.context("reading OTP")?
}
};
let session = client
.verify_auth_session(&email, &otp)
.await
.context("invalid or expired code — request a new one with `offline login --email`")?;
store_developer_session(
config,
session.token,
session.developer.id,
session.developer.email.clone(),
)?;
choose_default_org(client, config).await;
if session.deletion_cancelled {
output::note(
"Signing in cancelled your account's scheduled deletion. We emailed you a confirmation.",
);
}
Ok(())
}
fn looks_headless() -> bool {
let set = |name: &str| std::env::var_os(name).is_some_and(|v| !v.is_empty());
if set("SSH_CONNECTION") || set("SSH_TTY") {
return true;
}
cfg!(target_os = "linux") && !set("DISPLAY") && !set("WAYLAND_DISPLAY")
}
async fn choose_default_org(client: &AuthClient, config: &mut CliConfig) {
use std::io::IsTerminal;
let Some(token) = config.access_token.clone() else {
return;
};
let Ok(orgs) = client.list_organizations(&token).await else {
return;
};
let keep = config
.org_id
.as_ref()
.is_some_and(|id| orgs.iter().any(|o| &o.id == id));
let chosen = match orgs.as_slice() {
[] => return,
[only] => Some(only.id.clone()),
_ if keep => return,
many if std::io::stdin().is_terminal() => {
let names: Vec<String> = many
.iter()
.map(|o| format!("{} ({})", o.name, o.id))
.collect();
dialoguer::Select::new()
.with_prompt(
"Which organization should the CLI use? (change later with `offline org use`)",
)
.items(&names)
.default(0)
.interact()
.ok()
.map(|i| many[i].id.clone())
}
_ => {
output::note(
"You belong to several organizations; pick one with `offline org use <id>`",
);
None
}
};
if let Some(id) = chosen {
if config.org_id.as_deref() != Some(id.as_str()) {
config.org_id = Some(id);
let _ = config.save();
}
}
}
async fn login_with_api_key(client: &AuthClient, config: &mut CliConfig, key: &str) -> Result<()> {
if key.trim().is_empty() {
bail!("API key must not be empty");
}
let me = client
.cli_me(key)
.await
.context("API key validation failed — is the key correct and active?")?;
let email = config
.email
.clone()
.or(me.email)
.filter(|e| !e.trim().is_empty())
.unwrap_or_else(|| "developer".into());
persist_credentials(config, key.to_string(), config.dev_id, me.app_id, email).await
}
async fn store_from_callback(
client: &AuthClient,
config: &mut CliConfig,
payload: CallbackPayload,
) -> Result<()> {
client
.cli_me(&payload.api_key)
.await
.context("API key validation failed — is the key correct and active?")?;
persist_credentials(
config,
payload.api_key,
Some(payload.dev_id),
Some(payload.app_id),
payload.email,
)
.await
}
async fn store_from_poll_token(
client: &AuthClient,
config: &mut CliConfig,
token: AuthToken,
) -> Result<()> {
let email = token
.email
.clone()
.filter(|e| !e.trim().is_empty())
.context("approved session missing email")?;
let dev_id = token
.dev_id
.filter(|id| *id > 0)
.context("approved session missing dev_id")?;
if let Some(access_token) = token.access_token.clone() {
store_developer_session(config, access_token, dev_id, email)?;
choose_default_org(client, config).await;
return Ok(());
}
let api_key = token
.api_key
.filter(|k| !k.trim().is_empty())
.context("approved session missing credentials")?;
let me = client
.cli_me(&api_key)
.await
.context("API key validation failed — is the key correct and active?")?;
let app_id = token
.app_id
.filter(|id| !id.trim().is_empty())
.or(me.app_id)
.filter(|id| !id.trim().is_empty())
.context("approved session missing app_id")?;
output::note(
"This server signed you in with an application API key only. `offline setup`, `orgs`, \
`apps` and `keys` need a developer session; update the server or run `offline login --email`.",
);
persist_credentials(config, api_key, Some(dev_id), Some(app_id), email).await
}
fn store_developer_session(
config: &mut CliConfig,
access_token: String,
dev_id: i32,
email: String,
) -> Result<()> {
config.access_token = Some(access_token);
config.dev_id = Some(dev_id);
config.email = Some(email.clone());
config.save()?;
output::success(&format!("Signed in as {email}"));
output::note(&format!(
"Developer session stored in {} (0600)",
CliConfig::path()?.display()
));
if !config.has_api_credentials() {
output::note("Run `offline setup` to create an application and API key, or `offline create` to scaffold and auto-provision");
}
Ok(())
}
async fn persist_dev_id(config: &mut CliConfig, dev_id: i32) -> Result<()> {
config.dev_id = Some(dev_id);
config.save()?;
Ok(())
}
async fn persist_credentials(
config: &mut CliConfig,
api_key: String,
dev_id: Option<i32>,
app_id: Option<String>,
email: String,
) -> Result<()> {
config.api_key = Some(api_key);
if let Some(dev_id) = dev_id {
config.dev_id = Some(dev_id);
}
if let Some(app_id) = app_id.filter(|id| !id.trim().is_empty()) {
config.app_id = Some(app_id);
}
config.email = Some(email.clone());
config.save()?;
output::success(&format!("Successfully logged in as {email}"));
output::note(&format!(
"Credentials stored in {} (0600)",
CliConfig::path()?.display()
));
Ok(())
}
async fn terminate_cli_session(
client: &AuthClient,
session_id: &str,
session_secret: Option<&str>,
) -> Result<()> {
match client.poll_auth_session(session_id, session_secret).await {
Ok(PollResult::Ready(_)) | Ok(PollResult::Denied) => Ok(()),
Ok(PollResult::Pending) | Ok(PollResult::Invalid) => Ok(()),
Err(e) => {
output::warn(&format!(
"could not terminate CLI session on server ({e:#}); local credentials were saved"
));
Ok(())
}
}
}
pub async fn run_logout(options: LogoutOptions) -> Result<()> {
let config = CliConfig::load()?;
if let (Some(token), false) = (config.access_token.as_deref(), options.local_only) {
if let Ok(client) = AuthClient::new(config.effective_api_base()) {
let _ = client.revoke_session(token).await;
}
}
let Some(key) = config.api_key.clone() else {
if config.has_stored_credentials() {
CliConfig::delete_config_file()?;
output::success("Logged out");
output::note("Removed the developer session from this machine.");
} else {
output::warn("not logged in");
}
return Ok(());
};
let summary = if options.local_only {
LogoutSummary::LocalOnly
} else {
let client = AuthClient::new(config.effective_api_base())?;
match client.revoke_current_key(&key).await {
Ok(outcome) => LogoutSummary::Server(outcome),
Err(e) => LogoutSummary::Failed(format!("{e:#}")),
}
};
CliConfig::delete_config_file()?;
let (headline, note) = logout_message(&summary);
output::success(&headline);
if let Some(note) = note {
output::note(¬e);
}
Ok(())
}
#[derive(Debug, Clone, PartialEq)]
enum LogoutSummary {
LocalOnly,
Server(RevokeOutcome),
Failed(String),
}
fn logout_message(summary: &LogoutSummary) -> (String, Option<String>) {
const LOGGED_OUT: &str = "Logged out";
match summary {
LogoutSummary::Server(RevokeOutcome::Revoked) => {
("Logged out. The API key was revoked.".into(), None)
}
LogoutSummary::Server(RevokeOutcome::Shared) => (
"Logged out on this machine. The API key is shared with the application and stays \
active; revoke it in the developer portal if you no longer need it."
.into(),
None,
),
LogoutSummary::Server(RevokeOutcome::NotRevoked(reason)) => (
"Logged out on this machine. The API key stays active; revoke it in the developer \
portal if you no longer need it."
.into(),
Some(format!("Server said: {reason}")),
),
LogoutSummary::Server(RevokeOutcome::UnknownKey) => (
LOGGED_OUT.into(),
Some("The server did not recognise the API key, so there was nothing to revoke.".into()),
),
LogoutSummary::Server(RevokeOutcome::Unconfirmed) => (
LOGGED_OUT.into(),
Some(
"The server did not confirm that the API key was revoked; check the developer \
portal if you no longer need it."
.into(),
),
),
LogoutSummary::Failed(err) => (
LOGGED_OUT.into(),
Some(format!(
"Could not revoke the API key on the server ({err}); local credentials were cleared."
)),
),
LogoutSummary::LocalOnly => (
LOGGED_OUT.into(),
Some("The API key was not revoked on the server (--local-only).".into()),
),
}
}
pub async fn run_whoami(options: WhoamiOptions) -> Result<()> {
let config = CliConfig::load()?;
let authenticated =
config.effective_api_key().is_some() || config.effective_access_token().is_some();
if !authenticated {
if options.json {
return output::json(&json!({"authenticated": false}));
}
output::warn("unauthenticated");
return Ok(());
}
let verification = if options.verify && config.effective_api_key().is_some() {
Some(verify_stored_credentials(&config).await?)
} else {
None
};
if let Some(ref result) = verification {
if !result.valid {
if options.json {
return output::json(&json!({
"authenticated": true,
"verified": false,
"email": config.email,
"devId": config.dev_id,
"orgId": config.org_id,
"appId": config.app_id,
"error": result.error,
}));
}
output::warn("stored API key is invalid or revoked");
if let Some(err) = &result.error {
output::note(err);
}
output::note("run `offline login` to sign in again");
return Ok(());
}
if !result.app_id_matches {
if options.json {
return output::json(&json!({
"authenticated": true,
"verified": true,
"appIdMismatch": true,
"email": config.email,
"devId": config.dev_id,
"orgId": config.org_id,
"appId": config.app_id,
"serverAppId": result.server_app_id,
"keyName": result.key_name,
}));
}
output::warn("stored app id does not match the API key on the server");
if let Some(stored) = &config.app_id {
output::note(&format!("stored: {stored}"));
}
if let Some(server) = &result.server_app_id {
output::note(&format!("server: {server}"));
}
output::note("run `offline login` to refresh credentials");
}
}
if options.json {
let mut payload = json!({
"authenticated": true,
"email": config.email,
"devId": config.dev_id,
"orgId": config.org_id,
"appId": config.app_id,
"hasAccessToken": config.effective_access_token().is_some(),
"credential": credential_kind(&config),
});
if let Some(result) = verification {
payload["verified"] = json!(result.valid);
payload["keyName"] = json!(result.key_name);
payload["meshTelemetry"] = json!(result.mesh_telemetry);
if result.valid && !result.app_id_matches {
payload["appIdMismatch"] = json!(true);
payload["serverAppId"] = json!(result.server_app_id);
}
}
return output::json(&payload);
}
output::success(&identity_summary(&config));
if let Some(dev_id) = config.dev_id {
output::note(&format!("developer id: {dev_id}"));
}
if let Some(app_id) = &config.app_id {
output::note(&format!("application: {app_id}"));
}
if config.effective_access_token().is_some() && config.effective_api_key().is_none() {
output::note("run `offline setup` to create an application and API key");
}
if config.effective_access_token().is_none() {
output::note(
"an app key can't run `offline setup`, `orgs`, `apps` or `keys` — run `offline login`",
);
}
if let Some(result) = verification {
if result.valid {
if let Some(key_name) = &result.key_name {
output::note(&format!("API key: {key_name} (verified)"));
} else {
output::note("API key verified");
}
if let Some(enabled) = result.mesh_telemetry {
output::note(&format!(
"mesh telemetry: {}",
if enabled { "enabled" } else { "disabled" }
));
}
}
}
Ok(())
}
fn credential_kind(config: &CliConfig) -> &'static str {
if config.effective_access_token().is_some() {
"developer_session"
} else {
"app_key"
}
}
fn identity_summary(config: &CliConfig) -> String {
if credential_kind(config) == "developer_session" {
let who = config.email.as_deref().unwrap_or("developer");
return match config.org_id.as_deref() {
Some(org) => format!("Signed in as {who} (developer session, org {org})"),
None => format!("Signed in as {who} (developer session)"),
};
}
match config.app_id.as_deref() {
Some(app) => format!("App key for {app} (CI)"),
None => "App key (CI)".to_string(),
}
}
async fn verify_stored_credentials(config: &CliConfig) -> Result<VerifyResult> {
let key = config
.effective_api_key()
.context("no API key in local config")?;
let client = AuthClient::new(config.effective_api_base())?;
match client.cli_me(&key).await {
Ok(me) => {
let app_id_matches = match (&config.app_id, &me.app_id) {
(Some(stored), Some(server)) => stored == server,
(None, _) => true,
(Some(_), None) => false,
};
Ok(VerifyResult {
valid: true,
key_name: me.key_name,
server_app_id: me.app_id,
app_id_matches,
mesh_telemetry: me.product_settings.map(|s| s.mesh_sdk.mesh_telemetry),
error: None,
})
}
Err(err) => Ok(VerifyResult {
valid: false,
key_name: None,
server_app_id: None,
app_id_matches: false,
mesh_telemetry: None,
error: Some(err.to_string()),
}),
}
}
fn hostname() -> String {
std::env::var("HOSTNAME")
.ok()
.filter(|h| !h.is_empty())
.or_else(|| {
std::process::Command::new("hostname")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
})
.unwrap_or_else(|| "unknown-device".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::storage::{self, ENV_CONFIG_DIR, ENV_ENV};
use storage::test_sync::env_lock;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn enrich_login_session_error_detects_missing_gateway_route() {
let err = anyhow::anyhow!("API error 404: No gateway route for this path");
let enriched = enrich_login_session_error(err, "https://api.offlineprotocol.com");
let msg = enriched.to_string();
assert!(msg.contains("browser login is not available"));
assert!(msg.contains("OFFLINE_ENV=dev"));
assert!(msg.contains("not for the normal browser flow"));
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn api_key_login_persists_validated_credentials() {
let _lock = env_lock();
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/developers/cli/me"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"keyName": "Production",
"applicationId": "app_1"
})))
.mount(&server)
.await;
let api_base = format!("{}/api/v1", server.uri());
let dir = tempfile::tempdir().unwrap();
std::env::set_var(ENV_CONFIG_DIR, dir.path());
std::env::set_var("OFFLINE_API_BASE", &api_base);
std::env::remove_var(ENV_ENV);
let mut config = CliConfig {
dev_id: Some(99),
email: Some("dev@example.com".into()),
..Default::default()
};
let client = AuthClient::new(&api_base).unwrap();
login_with_api_key(&client, &mut config, "550e8400-e29b-41d4-a716-446655440000")
.await
.unwrap();
let loaded = CliConfig::load().unwrap();
assert_eq!(
loaded.api_key.as_deref(),
Some("550e8400-e29b-41d4-a716-446655440000")
);
assert_eq!(loaded.dev_id, Some(99));
assert_eq!(loaded.email.as_deref(), Some("dev@example.com"));
std::env::remove_var(ENV_CONFIG_DIR);
std::env::remove_var("OFFLINE_API_BASE");
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn callback_stores_exact_callback_values() {
let _lock = env_lock();
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/developers/cli/me"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"keyName": "Production",
"applicationId": "app_1"
})))
.mount(&server)
.await;
let api_base = format!("{}/api/v1", server.uri());
let dir = tempfile::tempdir().unwrap();
std::env::set_var(ENV_CONFIG_DIR, dir.path());
std::env::set_var("OFFLINE_API_BASE", &api_base);
std::env::set_var(ENV_ENV, "dev");
let mut config = CliConfig::load().unwrap();
let client = AuthClient::new(&api_base).unwrap();
store_from_callback(
&client,
&mut config,
CallbackPayload {
api_key: "550e8400-e29b-41d4-a716-446655440000".into(),
app_id: "app_abc".into(),
dev_id: 42,
email: "real@example.com".into(),
},
)
.await
.unwrap();
let loaded = CliConfig::load().unwrap();
assert_eq!(
loaded.api_key.as_deref(),
Some("550e8400-e29b-41d4-a716-446655440000")
);
assert_eq!(loaded.dev_id, Some(42));
assert_eq!(loaded.app_id.as_deref(), Some("app_abc"));
assert_eq!(loaded.email.as_deref(), Some("real@example.com"));
std::env::remove_var(ENV_CONFIG_DIR);
std::env::remove_var("OFFLINE_API_BASE");
std::env::remove_var(ENV_ENV);
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn invalid_api_key_does_not_create_config() {
let _lock = env_lock();
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/developers/cli/me"))
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
"error": "Invalid API key"
})))
.mount(&server)
.await;
let api_base = format!("{}/api/v1", server.uri());
let dir = tempfile::tempdir().unwrap();
std::env::set_var(ENV_CONFIG_DIR, dir.path());
std::env::set_var("OFFLINE_API_BASE", &api_base);
std::env::set_var(ENV_ENV, "dev");
let mut config = CliConfig::load().unwrap();
let client = AuthClient::new(&api_base).unwrap();
let err = store_from_callback(
&client,
&mut config,
CallbackPayload {
api_key: "550e8400-e29b-41d4-a716-446655440000".into(),
app_id: "app_abc".into(),
dev_id: 42,
email: "real@example.com".into(),
},
)
.await
.unwrap_err();
assert!(err.to_string().contains("validation failed"));
assert!(!CliConfig::path().unwrap().exists());
std::env::remove_var(ENV_CONFIG_DIR);
std::env::remove_var("OFFLINE_API_BASE");
std::env::remove_var(ENV_ENV);
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn logout_deletes_config_file() {
let _lock = env_lock();
let dir = tempfile::tempdir().unwrap();
std::env::set_var(ENV_CONFIG_DIR, dir.path());
let config = CliConfig {
api_key: Some("550e8400-e29b-41d4-a716-446655440000".into()),
..Default::default()
};
config.save().unwrap();
run_logout(LogoutOptions { local_only: true })
.await
.unwrap();
assert!(!CliConfig::path().unwrap().exists());
std::env::remove_var(ENV_CONFIG_DIR);
}
#[test]
fn logout_message_matches_server_outcome() {
let (headline, note) = logout_message(&LogoutSummary::Server(RevokeOutcome::Revoked));
assert_eq!(headline, "Logged out. The API key was revoked.");
assert!(note.is_none());
let (headline, note) = logout_message(&LogoutSummary::Server(RevokeOutcome::Shared));
assert_eq!(
headline,
"Logged out on this machine. The API key is shared with the application and stays \
active; revoke it in the developer portal if you no longer need it."
);
assert!(note.is_none());
let (headline, note) = logout_message(&LogoutSummary::Server(RevokeOutcome::NotRevoked(
"key is pinned".into(),
)));
assert!(headline.starts_with("Logged out on this machine."));
assert!(note.unwrap().contains("key is pinned"));
for summary in [
LogoutSummary::Server(RevokeOutcome::Unconfirmed),
LogoutSummary::Server(RevokeOutcome::UnknownKey),
LogoutSummary::Failed("network request failed".into()),
LogoutSummary::LocalOnly,
] {
let (headline, note) = logout_message(&summary);
assert_eq!(headline, "Logged out", "{summary:?}");
assert!(note.is_some(), "{summary:?}");
}
}
async fn logout_against(status: u16, body: Option<serde_json::Value>) -> bool {
let server = MockServer::start().await;
let mut response = ResponseTemplate::new(status);
if let Some(body) = body {
response = response.set_body_json(body);
}
Mock::given(method("DELETE"))
.and(path("/api/v1/developers/cli/keys/current"))
.respond_with(response)
.expect(1)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
std::env::set_var(ENV_CONFIG_DIR, dir.path());
std::env::set_var("OFFLINE_API_BASE", format!("{}/api/v1", server.uri()));
std::env::remove_var(ENV_ENV);
let config = CliConfig {
api_key: Some("550e8400-e29b-41d4-a716-446655440000".into()),
app_id: Some("app_abc".into()),
..Default::default()
};
config.save().unwrap();
let result = run_logout(LogoutOptions { local_only: false }).await;
let cleared = !CliConfig::path().unwrap().exists();
std::env::remove_var(ENV_CONFIG_DIR);
std::env::remove_var("OFFLINE_API_BASE");
result.unwrap();
cleared
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn logout_clears_credentials_for_every_server_answer() {
let _lock = env_lock();
assert!(logout_against(200, Some(serde_json::json!({ "revoked": true }))).await);
assert!(
logout_against(
200,
Some(serde_json::json!({
"revoked": false,
"reason": "key is shared with the application; revoke it in the portal"
}))
)
.await
);
assert!(logout_against(204, None).await);
assert!(logout_against(401, Some(serde_json::json!({ "error": "Invalid API key" }))).await);
assert!(logout_against(404, None).await);
assert!(logout_against(500, Some(serde_json::json!({ "error": "boom" }))).await);
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn logout_clears_email_login_session() {
let _lock = env_lock();
let dir = tempfile::tempdir().unwrap();
std::env::set_var(ENV_CONFIG_DIR, dir.path());
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/developers/sessions/revoke"))
.and(wiremock::matchers::header("authorization", "Bearer jwt"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({ "data": { "revoked": true } })),
)
.expect(1)
.mount(&server)
.await;
std::env::set_var("OFFLINE_API_BASE", format!("{}/api/v1", server.uri()));
std::env::remove_var(ENV_ENV);
let config = CliConfig {
access_token: Some("jwt".into()),
email: Some("dev@example.com".into()),
..Default::default()
};
config.save().unwrap();
run_logout(LogoutOptions { local_only: false })
.await
.unwrap();
assert!(!CliConfig::path().unwrap().exists());
std::env::remove_var(ENV_CONFIG_DIR);
std::env::remove_var("OFFLINE_API_BASE");
server.verify().await;
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn whoami_reports_unauthenticated_without_config() {
let _lock = env_lock();
let dir = tempfile::tempdir().unwrap();
std::env::set_var(ENV_CONFIG_DIR, dir.path());
std::env::remove_var(storage::ENV_API_KEY);
run_whoami(WhoamiOptions {
json: false,
verify: false,
})
.await
.unwrap();
std::env::remove_var(ENV_CONFIG_DIR);
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn whoami_reads_credentials_from_config_without_api() {
let _lock = env_lock();
let dir = tempfile::tempdir().unwrap();
std::env::set_var(ENV_CONFIG_DIR, dir.path());
let config = CliConfig {
api_key: Some("550e8400-e29b-41d4-a716-446655440000".into()),
dev_id: Some(42),
app_id: Some("app_abc".into()),
email: Some("dev@example.com".into()),
..Default::default()
};
config.save().unwrap();
run_whoami(WhoamiOptions {
json: true,
verify: false,
})
.await
.unwrap();
std::env::remove_var(ENV_CONFIG_DIR);
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn whoami_verify_reports_invalid_key() {
let _lock = env_lock();
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/developers/cli/me"))
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
"error": "Invalid API key"
})))
.mount(&server)
.await;
let api_base = format!("{}/api/v1", server.uri());
let dir = tempfile::tempdir().unwrap();
std::env::set_var(ENV_CONFIG_DIR, dir.path());
std::env::set_var("OFFLINE_API_BASE", &api_base);
std::env::remove_var(ENV_ENV);
let config = CliConfig {
api_key: Some("off_live_revoked".into()),
app_id: Some("app_abc".into()),
email: Some("dev@example.com".into()),
..Default::default()
};
config.save().unwrap();
run_whoami(WhoamiOptions {
json: true,
verify: true,
})
.await
.unwrap();
std::env::remove_var(ENV_CONFIG_DIR);
std::env::remove_var("OFFLINE_API_BASE");
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn whoami_verify_succeeds_for_valid_key() {
let _lock = env_lock();
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/developers/cli/me"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"keyName": "Production",
"applicationId": "app_abc"
})))
.mount(&server)
.await;
let api_base = format!("{}/api/v1", server.uri());
let dir = tempfile::tempdir().unwrap();
std::env::set_var(ENV_CONFIG_DIR, dir.path());
std::env::set_var("OFFLINE_API_BASE", &api_base);
std::env::remove_var(ENV_ENV);
let config = CliConfig {
api_key: Some("550e8400-e29b-41d4-a716-446655440000".into()),
app_id: Some("app_abc".into()),
email: Some("dev@example.com".into()),
..Default::default()
};
config.save().unwrap();
let result = verify_stored_credentials(&CliConfig::load().unwrap())
.await
.unwrap();
assert!(result.valid);
assert_eq!(result.key_name.as_deref(), Some("Production"));
assert!(result.app_id_matches);
std::env::remove_var(ENV_CONFIG_DIR);
std::env::remove_var("OFFLINE_API_BASE");
}
#[test]
fn whoami_names_the_developer_session_and_org() {
let config = CliConfig {
access_token: Some("jwt".into()),
email: Some("you@x.com".into()),
org_id: Some("Acme".into()),
api_key: Some("key".into()),
..Default::default()
};
assert_eq!(
identity_summary(&config),
"Signed in as you@x.com (developer session, org Acme)"
);
assert_eq!(credential_kind(&config), "developer_session");
}
#[test]
fn whoami_labels_an_app_key_as_ci() {
let config = CliConfig {
api_key: Some("key".into()),
app_id: Some("app_xxx".into()),
..Default::default()
};
assert_eq!(identity_summary(&config), "App key for app_xxx (CI)");
assert_eq!(credential_kind(&config), "app_key");
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn browser_approval_with_a_session_stores_the_developer_session() {
let _lock = env_lock();
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/developers/organizations"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": { "organizations": [{ "id": "org_1", "name": "Acme" }] }
})))
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
std::env::set_var(ENV_CONFIG_DIR, dir.path());
std::env::remove_var(ENV_ENV);
let mut config = CliConfig::load().unwrap();
let client = AuthClient::new(format!("{}/api/v1", server.uri())).unwrap();
store_from_poll_token(
&client,
&mut config,
AuthToken {
access_token: Some("jwt".into()),
api_key: None,
email: Some("you@x.com".into()),
dev_id: Some(7),
app_id: None,
},
)
.await
.unwrap();
let loaded = CliConfig::load().unwrap();
assert_eq!(loaded.access_token.as_deref(), Some("jwt"));
assert_eq!(
loaded.org_id.as_deref(),
Some("org_1"),
"the only org is picked"
);
assert_eq!(loaded.api_key, None);
assert_eq!(loaded.dev_id, Some(7));
assert_eq!(loaded.email.as_deref(), Some("you@x.com"));
std::env::remove_var(ENV_CONFIG_DIR);
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn browser_approval_without_a_session_keeps_the_api_key_fallback() {
let _lock = env_lock();
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/developers/cli/me"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"keyName": "Production",
"applicationId": "app_1"
})))
.mount(&server)
.await;
let api_base = format!("{}/api/v1", server.uri());
let dir = tempfile::tempdir().unwrap();
std::env::set_var(ENV_CONFIG_DIR, dir.path());
std::env::set_var("OFFLINE_API_BASE", &api_base);
std::env::remove_var(ENV_ENV);
let mut config = CliConfig::load().unwrap();
let client = AuthClient::new(&api_base).unwrap();
store_from_poll_token(
&client,
&mut config,
AuthToken {
access_token: None,
api_key: Some("550e8400-e29b-41d4-a716-446655440000".into()),
email: Some("you@x.com".into()),
dev_id: Some(7),
app_id: Some("app_1".into()),
},
)
.await
.unwrap();
let loaded = CliConfig::load().unwrap();
assert_eq!(loaded.access_token, None);
assert_eq!(
loaded.api_key.as_deref(),
Some("550e8400-e29b-41d4-a716-446655440000")
);
assert_eq!(loaded.app_id.as_deref(), Some("app_1"));
std::env::remove_var(ENV_CONFIG_DIR);
std::env::remove_var("OFFLINE_API_BASE");
}
}