use std::io::{self, Write};
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use crate::atlassian::auth::{self, AtlassianAuth, AtlassianCredentials, AuthMode};
use crate::atlassian::client::{AtlassianClient, AuthService};
use crate::utils::env::SystemEnv;
use crate::utils::settings::{active_profile_from, profile_suffix, Settings};
#[derive(Parser)]
pub struct AuthCommand {
#[command(subcommand)]
pub command: AuthSubcommands,
}
#[derive(Subcommand)]
pub enum AuthSubcommands {
Login(LoginCommand),
Logout(LogoutCommand),
Status(StatusCommand),
}
impl AuthCommand {
pub async fn execute(self) -> Result<()> {
match self.command {
AuthSubcommands::Login(cmd) => cmd.execute(),
AuthSubcommands::Logout(cmd) => cmd.execute(),
AuthSubcommands::Status(cmd) => cmd.execute().await,
}
}
}
#[derive(Parser)]
pub struct LoginCommand {
#[arg(long, value_enum, default_value = "basic")]
pub auth_mode: AuthMode,
}
impl LoginCommand {
pub fn execute(self) -> Result<()> {
println!("Configure Atlassian credentials\n");
let instance_url = prompt("Instance URL (e.g., https://myorg.atlassian.net): ")?;
let credentials = match self.auth_mode {
AuthMode::Basic => AtlassianAuth::Basic {
email: prompt("Email: ")?,
api_token: prompt_line("API token: ")?.into(),
},
AuthMode::Bearer => AtlassianAuth::Bearer {
token: prompt_line("Personal Access Token: ")?.into(),
},
};
let path = Settings::get_settings_path()?;
let profile = active_profile_from(&SystemEnv);
check_login_sources(&path, profile.as_deref(), &credentials, &SystemEnv)?;
save_login_to(&path, profile.as_deref(), &instance_url, credentials)
}
}
fn check_login_sources(
path: &std::path::Path,
profile: Option<&str>,
auth: &AtlassianAuth,
raw: &impl crate::utils::env::EnvSource,
) -> Result<()> {
let (selected, opposite) = match auth {
AtlassianAuth::Basic { .. } => (auth::ATLASSIAN_API_TOKEN, auth::ATLASSIAN_PAT),
AtlassianAuth::Bearer { .. } => (auth::ATLASSIAN_PAT, auth::ATLASSIAN_API_TOKEN),
};
Settings::ensure_secrets_replaceable(path, profile, &[selected], raw)?;
for key in [selected, opposite] {
if crate::utils::secret_env::secret_var_is_set(raw, key) {
anyhow::bail!(
"{key} has a process environment source; unset it before saving credentials"
);
}
}
Ok(())
}
fn save_login_to(
settings_path: &std::path::Path,
profile: Option<&str>,
instance_url: &str,
auth: AtlassianAuth,
) -> Result<()> {
let credentials = AtlassianCredentials {
instance_url: instance_url.to_string(),
auth,
};
auth::save_credentials_to(settings_path, profile, &credentials)?;
println!(
"\nCredentials saved to ~/.omni-dev/settings.json{}",
profile_suffix(profile)
);
println!(" Instance: {instance_url}");
println!(" Authentication: {:?}", credentials.auth.mode());
if let AtlassianAuth::Basic { email, .. } = &credentials.auth {
println!(" Email: {email}");
}
println!("\nRun `omni-dev atlassian auth status` to verify.");
Ok(())
}
#[derive(Parser)]
pub struct LogoutCommand;
impl LogoutCommand {
pub fn execute(self) -> Result<()> {
run_logout(
&Settings::get_settings_path()?,
active_profile_from(&SystemEnv).as_deref(),
)
}
}
fn run_logout(settings_path: &std::path::Path, profile: Option<&str>) -> Result<()> {
let removed = auth::remove_credentials_at(settings_path, profile)?;
if removed {
println!(
"Atlassian credentials removed from ~/.omni-dev/settings.json{}",
profile_suffix(profile)
);
} else {
println!("No Atlassian credentials were configured.");
}
Ok(())
}
#[derive(Parser)]
pub struct StatusCommand {
#[command(flatten)]
pub instance: super::InstanceArg,
#[arg(long, value_enum, default_value = "jira")]
pub service: AuthService,
}
impl StatusCommand {
pub async fn execute(self) -> Result<()> {
self.instance.apply();
let credentials = auth::load_credentials()?;
let client = AtlassianClient::from_credentials(&credentials)?;
println!("Authentication: {:?}", credentials.auth.mode());
run_auth_status(&client, &credentials.instance_url, self.service).await
}
}
async fn run_auth_status(
client: &AtlassianClient,
instance_url: &str,
service: AuthService,
) -> Result<()> {
println!("Checking authentication to {instance_url}...");
let user = client.auth_identity(service).await?;
println!("Authenticated as: {}", user.display_name);
if let Some(ref email) = user.email_address {
println!("Email: {email}");
}
if let Some(id) = user.account_id {
println!("Account ID: {id}");
}
if let Some(name) = user.username {
println!("Username: {name}");
}
if let Some(key) = user.key {
println!("User key: {key}");
}
println!("Instance: {instance_url}");
Ok(())
}
fn prompt(message: &str) -> Result<String> {
Ok(prompt_line(message)?.trim().to_string())
}
fn prompt_line(message: &str) -> Result<String> {
print!("{message}");
io::stdout().flush().context("Failed to flush stdout")?;
read_prompt_input(&mut io::stdin().lock())
}
fn read_prompt_input(reader: &mut impl io::BufRead) -> Result<String> {
let mut input = String::new();
reader
.read_line(&mut input)
.context("Failed to read user input")?;
if input.ends_with('\n') {
input.pop();
if input.ends_with('\r') {
input.pop();
}
}
Ok(input)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn auth_command_login_dispatch() {
let cmd = AuthCommand {
command: AuthSubcommands::Login(LoginCommand {
auth_mode: AuthMode::Basic,
}),
};
assert!(matches!(cmd.command, AuthSubcommands::Login(_)));
}
#[test]
fn auth_command_logout_dispatch() {
let cmd = AuthCommand {
command: AuthSubcommands::Logout(LogoutCommand),
};
assert!(matches!(cmd.command, AuthSubcommands::Logout(_)));
}
#[test]
fn auth_command_status_dispatch() {
let cmd = AuthCommand {
command: AuthSubcommands::Status(StatusCommand {
instance: crate::cli::atlassian::InstanceArg::default(),
service: AuthService::Jira,
}),
};
assert!(matches!(cmd.command, AuthSubcommands::Status(_)));
}
fn basic_auth(email: &str, token: &str) -> AtlassianAuth {
AtlassianAuth::Basic {
email: email.to_string(),
api_token: token.into(),
}
}
#[test]
fn secret_prompt_preserves_bytes_and_only_removes_line_terminator() {
for (input, expected) in [
(" token \n", " token "),
(" token \r\n", " token "),
("token\r", "token\r"),
("token", "token"),
] {
assert_eq!(
read_prompt_input(&mut io::Cursor::new(input)).unwrap(),
expected
);
}
}
fn temp_settings() -> (tempfile::TempDir, std::path::PathBuf) {
std::fs::create_dir_all("tmp").ok();
let dir = tempfile::TempDir::new_in("tmp").unwrap();
let path = dir.path().join(".omni-dev").join("settings.json");
(dir, path)
}
#[test]
fn run_login_rejects_empty_instance_url() {
let (_dir, path) = temp_settings();
let err = save_login_to(&path, None, "", basic_auth("me@test.com", "tok")).unwrap_err();
assert!(err.to_string().contains("Instance URL"));
}
#[test]
fn run_login_rejects_empty_email() {
let (_dir, path) = temp_settings();
let err = save_login_to(
&path,
None,
"https://org.atlassian.net",
basic_auth("", "tok"),
)
.unwrap_err();
assert!(err.to_string().contains("ATLASSIAN_EMAIL"));
}
#[test]
fn run_login_rejects_empty_api_token() {
let (_dir, path) = temp_settings();
let err = save_login_to(
&path,
None,
"https://org.atlassian.net",
basic_auth("me@test.com", ""),
)
.unwrap_err();
assert!(err.to_string().contains("token"));
}
#[test]
fn run_login_to_persists_credentials() {
let (_dir, settings_path) = temp_settings();
save_login_to(
&settings_path,
None,
"https://org.atlassian.net",
basic_auth("me@test.com", "tok-1"),
)
.unwrap();
let val: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&settings_path).unwrap()).unwrap();
assert_eq!(
val["env"]["ATLASSIAN_INSTANCE_URL"],
"https://org.atlassian.net"
);
assert_eq!(val["env"]["ATLASSIAN_EMAIL"], "me@test.com");
assert_eq!(val["env"]["ATLASSIAN_API_TOKEN"], "tok-1");
}
#[test]
fn run_login_to_with_profile_persists_under_profile() {
let (_dir, settings_path) = temp_settings();
save_login_to(
&settings_path,
Some("work"),
"https://work.atlassian.net",
basic_auth("me@work.com", "tok-w"),
)
.unwrap();
let val: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&settings_path).unwrap()).unwrap();
assert_eq!(
val["profiles"]["work"]["env"]["ATLASSIAN_EMAIL"],
"me@work.com"
);
assert!(val["env"].get("ATLASSIAN_EMAIL").is_none());
}
#[test]
fn run_logout_removes_credentials_when_present() {
use crate::atlassian::auth::{
ATLASSIAN_API_TOKEN, ATLASSIAN_EMAIL, ATLASSIAN_INSTANCE_URL,
};
let (dir, settings_path) = temp_settings();
std::fs::create_dir_all(dir.path().join(".omni-dev")).unwrap();
std::fs::write(
&settings_path,
r#"{"env": {
"ATLASSIAN_INSTANCE_URL": "https://org.atlassian.net",
"ATLASSIAN_EMAIL": "me@test.com",
"ATLASSIAN_API_TOKEN": "tok",
"OTHER": "keep"
}}"#,
)
.unwrap();
run_logout(&settings_path, None).unwrap();
let val: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&settings_path).unwrap()).unwrap();
assert!(val["env"].get(ATLASSIAN_INSTANCE_URL).is_none());
assert!(val["env"].get(ATLASSIAN_EMAIL).is_none());
assert!(val["env"].get(ATLASSIAN_API_TOKEN).is_none());
assert_eq!(val["env"]["OTHER"], "keep");
}
#[test]
fn run_logout_is_idempotent_when_no_credentials() {
let (_dir, settings_path) = temp_settings();
run_logout(&settings_path, None).unwrap();
}
#[test]
fn run_logout_with_profile_removes_profile_credentials_and_keeps_base() {
use crate::atlassian::auth::ATLASSIAN_EMAIL;
let (dir, settings_path) = temp_settings();
std::fs::create_dir_all(dir.path().join(".omni-dev")).unwrap();
std::fs::write(
&settings_path,
r#"{
"env": {"ATLASSIAN_EMAIL": "base@test.com"},
"profiles": {"work": {"env": {"ATLASSIAN_EMAIL": "work@test.com"}}}
}"#,
)
.unwrap();
run_logout(&settings_path, Some("work")).unwrap();
let val: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&settings_path).unwrap()).unwrap();
assert!(val["profiles"]["work"]["env"]
.get(ATLASSIAN_EMAIL)
.is_none());
assert_eq!(val["env"]["ATLASSIAN_EMAIL"], "base@test.com");
}
#[tokio::test]
async fn auth_command_execute_logout_arm() {
use crate::atlassian::auth::ATLASSIAN_EMAIL;
let guard = crate::atlassian::auth::test_util::EnvGuard::take();
let dir = guard.clear_credentials();
let omni_dir = dir.path().join(".omni-dev");
std::fs::create_dir_all(&omni_dir).unwrap();
let settings_path = omni_dir.join("settings.json");
std::fs::write(
&settings_path,
r#"{"env": {"ATLASSIAN_EMAIL": "me@test.com"}}"#,
)
.unwrap();
AuthCommand {
command: AuthSubcommands::Logout(LogoutCommand),
}
.execute()
.await
.unwrap();
let val: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&settings_path).unwrap()).unwrap();
assert!(val["env"].get(ATLASSIAN_EMAIL).is_none());
}
#[test]
fn logout_command_execute_resolves_default_settings_path() {
use crate::atlassian::auth::ATLASSIAN_EMAIL;
let guard = crate::atlassian::auth::test_util::EnvGuard::take();
let dir = guard.clear_credentials();
let omni_dir = dir.path().join(".omni-dev");
std::fs::create_dir_all(&omni_dir).unwrap();
let settings_path = omni_dir.join("settings.json");
std::fs::write(
&settings_path,
r#"{"env": {"ATLASSIAN_EMAIL": "me@test.com"}}"#,
)
.unwrap();
LogoutCommand.execute().unwrap();
let val: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&settings_path).unwrap()).unwrap();
assert!(val["env"].get(ATLASSIAN_EMAIL).is_none());
}
fn mock_client(base_url: &str) -> AtlassianClient {
AtlassianClient::new(base_url, "user@test.com", "token").unwrap()
}
#[tokio::test]
async fn run_auth_status_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/myself"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"accountId": "abc123",
"displayName": "Alice",
"emailAddress": "alice@test.com"
})),
)
.mount(&server)
.await;
let client = mock_client(&server.uri());
assert!(run_auth_status(&client, &server.uri(), AuthService::Jira)
.await
.is_ok());
}
#[tokio::test]
async fn run_auth_status_no_email() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/myself"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"accountId": "abc123",
"displayName": "Alice"
})),
)
.mount(&server)
.await;
let client = mock_client(&server.uri());
assert!(run_auth_status(&client, &server.uri(), AuthService::Jira)
.await
.is_ok());
}
#[tokio::test]
async fn run_auth_status_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/myself"))
.respond_with(wiremock::ResponseTemplate::new(401).set_body_string("Unauthorized"))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let err = run_auth_status(&client, &server.uri(), AuthService::Jira)
.await
.unwrap_err();
assert!(err.to_string().contains("401"));
}
#[test]
fn auth_flags_parse_defaults_and_explicit_modes() {
let cmd = AuthCommand::try_parse_from(["auth", "login"]).unwrap();
assert!(matches!(
cmd.command,
AuthSubcommands::Login(LoginCommand {
auth_mode: AuthMode::Basic
})
));
let cmd = AuthCommand::try_parse_from(["auth", "login", "--auth-mode", "bearer"]).unwrap();
assert!(matches!(
cmd.command,
AuthSubcommands::Login(LoginCommand {
auth_mode: AuthMode::Bearer
})
));
let cmd =
AuthCommand::try_parse_from(["auth", "status", "--service", "confluence"]).unwrap();
assert!(matches!(
cmd.command,
AuthSubcommands::Status(StatusCommand {
service: AuthService::Confluence,
..
})
));
assert!(AuthCommand::try_parse_from(["auth", "login", "--auth-mode", "oauth"]).is_err());
}
#[test]
fn bearer_login_persists_without_email_and_rejects_blank() {
let (_dir, path) = temp_settings();
save_login_to(
&path,
None,
"https://self.example",
AtlassianAuth::Bearer {
token: "pat".into(),
},
)
.unwrap();
let saved: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(saved["env"][auth::ATLASSIAN_PAT], "pat");
assert!(saved["env"].get(auth::ATLASSIAN_EMAIL).is_none());
assert!(save_login_to(
&path,
None,
"https://self.example",
AtlassianAuth::Bearer { token: " ".into() }
)
.is_err());
}
#[test]
fn login_refuses_process_sources_and_selected_helper() {
use crate::test_support::env::MapEnv;
let (_dir, path) = temp_settings();
let credentials = AtlassianAuth::Bearer {
token: "pat".into(),
};
for key in [
auth::ATLASSIAN_PAT,
"ATLASSIAN_PAT_FILE",
"ATLASSIAN_PAT_COMMAND",
auth::ATLASSIAN_API_TOKEN,
"ATLASSIAN_API_TOKEN_FILE",
"ATLASSIAN_API_TOKEN_COMMAND",
] {
assert!(check_login_sources(
&path,
None,
&credentials,
&MapEnv::new().with(key, "source")
)
.is_err());
assert!(!path.exists());
}
assert!(check_login_sources(&path, None, &credentials, &MapEnv::new()).is_ok());
}
}