Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ new binary becomes the creator of fresh Keychain entries. **Click
"Always Allow"** on the two prompts macOS shows during re-store —
otherwise future commands will prompt again.

For CI or headless boxes, pass credentials via env vars or flags
(e.g. `JR_EMAIL="..." JR_API_TOKEN="$TOKEN" jr --no-input auth refresh`)
so the flow completes without a TTY.

Tracked in [#207](https://github.com/Zious11/jira-cli/issues/207). A
longer-term fix (Developer ID signing) is tracked as a separate issue.

Expand All @@ -86,6 +90,12 @@ jr auth login
# Or authenticate with OAuth 2.0 (requires your own OAuth app)
jr auth login --oauth

# Non-interactive (CI / agents): flags or env vars, no TTY required.
# Prefer env vars for secrets — bare CLI args can leak via process lists.
JR_EMAIL="you@example.com" JR_API_TOKEN="$TOKEN" jr --no-input auth login
JR_OAUTH_CLIENT_ID="$ID" JR_OAUTH_CLIENT_SECRET="$SECRET" \
jr --no-input auth login --oauth

# View your current sprint/board issues
jr issue list --project FOO

Expand Down Expand Up @@ -134,7 +144,8 @@ jr issue comment KEY-123 "Deployed to staging"
| Command | Description |
|---------|-------------|
| `jr init` | Configure Jira instance and authenticate |
| `jr auth login` | Authenticate with API token (default) or `--oauth` for OAuth 2.0 |
| `jr auth login` | Authenticate with API token (default) or `--oauth` for OAuth 2.0. Non-interactive: `--email`/`--token` or `JR_EMAIL`/`JR_API_TOKEN`; `--client-id`/`--client-secret` or `JR_OAUTH_CLIENT_ID`/`JR_OAUTH_CLIENT_SECRET` for OAuth |
| `jr auth refresh` | Clear stored credentials and re-run login (same flags/env vars as `auth login`) |
| `jr auth status` | Show authentication status |
| `jr me` | Show current user info |
| `jr issue list` | List issues (`--assignee`, `--reporter`, `--recent`, `--status`, `--open`, `--team`, `--asset KEY`, `--jql`, `--limit`/`--all`, `--points`, `--assets`) |
Expand Down Expand Up @@ -227,6 +238,7 @@ board_id = 42
- `--url-only` prints URLs instead of opening a browser
- State-changing commands are idempotent (exit 0 if already in target state)
- Structured exit codes (see [Exit Codes](#exit-codes) table)
- `auth login` / `auth refresh` accept credentials via flags (`--email`, `--token`, `--client-id`, `--client-secret`) or env vars (`JR_EMAIL`, `JR_API_TOKEN`, `JR_OAUTH_CLIENT_ID`, `JR_OAUTH_CLIENT_SECRET`) — no TTY required. Prefer env vars for secrets.

```bash
# AI agent workflow example
Expand Down
297 changes: 271 additions & 26 deletions src/cli/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,77 @@ use dialoguer::{Input, Password};

use crate::api::auth;
use crate::config::Config;
use crate::error::JrError;
use crate::output;

/// Environment variable names for the four auth credentials.
///
/// Flag > env > prompt precedence is implemented by [`resolve_credential`].
/// Callers pass the matching `flag_name` so error messages can cite both
/// names verbatim.
pub(crate) const ENV_EMAIL: &str = "JR_EMAIL";
pub(crate) const ENV_API_TOKEN: &str = "JR_API_TOKEN";
pub(crate) const ENV_OAUTH_CLIENT_ID: &str = "JR_OAUTH_CLIENT_ID";
pub(crate) const ENV_OAUTH_CLIENT_SECRET: &str = "JR_OAUTH_CLIENT_SECRET";

/// Resolve a credential value via flag → env → TTY prompt, or error under
/// `--no-input`.
///
/// Order of precedence:
/// 1. `flag_value` (explicit CLI arg wins).
/// 2. `env::var(env_name)` if non-empty.
/// 3. If `no_input` is true, return a `JrError::UserError` naming the flag
/// and env var so scripts/agents can recover. `hint` — if supplied —
/// is appended to the error so first-time agents learn *where to obtain*
/// the credential, not just how to pass it (relevant for OAuth where
/// users must first create an app at developer.atlassian.com).
/// 4. Otherwise, prompt interactively. `is_password` chooses between
/// `dialoguer::Password` (masked) and `Input` (visible).
///
/// Empty env values are ignored so an accidentally-exported-but-unset var
/// doesn't silently substitute for real input.
pub(crate) fn resolve_credential(
flag_value: Option<String>,
env_name: &str,
flag_name: &str,
prompt_label: &str,
is_password: bool,
no_input: bool,
hint: Option<&str>,
) -> Result<String> {
if let Some(v) = flag_value.filter(|v| !v.is_empty()) {
return Ok(v);
}
if let Ok(v) = std::env::var(env_name)
&& !v.is_empty()
{
return Ok(v);
}
if no_input {
let base = format!("{prompt_label} is required. Provide {flag_name} or set ${env_name}.");
let msg = match hint {
Some(h) => format!("{base} {h}"),
None => base,
};
return Err(JrError::UserError(msg).into());
}
if is_password {
Password::new()
.with_prompt(prompt_label)
.interact()
.with_context(|| format!("failed to read {prompt_label}"))
} else {
Input::new()
.with_prompt(prompt_label)
.interact_text()
.with_context(|| format!("failed to read {prompt_label}"))
}
}

/// Hint for OAuth client_id / client_secret errors so first-time agents
/// discover they must create an OAuth app before passing credentials.
const OAUTH_APP_HINT: &str = "Create one at https://developer.atlassian.com/console/myapps/.";

/// Which auth flow `jr auth refresh` should dispatch to.
///
/// Internal detail of the `refresh` command; kept module-private so it
Expand Down Expand Up @@ -44,38 +113,68 @@ fn chosen_flow(config: &Config, oauth_override: bool) -> AuthFlow {
}
}

/// Prompt for email and API token, then store in keychain.
pub async fn login_token() -> Result<()> {
let email: String = dialoguer::Input::new()
.with_prompt("Jira email")
.interact_text()
.context("failed to read Jira email")?;

let token: String = dialoguer::Password::new()
.with_prompt("API token")
.interact()
.context("failed to read API token")?;
/// Resolve email and API token (flag → env → prompt), then store in keychain.
pub async fn login_token(
email: Option<String>,
token: Option<String>,
no_input: bool,
) -> Result<()> {
let email = resolve_credential(
email,
ENV_EMAIL,
"--email",
"Jira email",
false,
no_input,
None,
)?;
let token = resolve_credential(
token,
ENV_API_TOKEN,
"--token",
"API token",
true,
no_input,
None,
)?;

auth::store_api_token(&email, &token)?;
eprintln!("Credentials stored in keychain.");
Ok(())
}

/// Run the OAuth 2.0 (3LO) login flow and persist site configuration.
/// Prompts the user for their own OAuth app credentials.
pub async fn login_oauth() -> Result<()> {
eprintln!("OAuth 2.0 requires your own Atlassian OAuth app.");
eprintln!("Create one at: https://developer.atlassian.com/console/myapps/\n");

let client_id: String = Input::new()
.with_prompt("OAuth Client ID")
.interact_text()
.context("failed to read OAuth client ID")?;
///
/// Credentials resolved via flag → env → prompt, so CI/agent workflows can
/// pipe them in without a TTY.
pub async fn login_oauth(
client_id: Option<String>,
client_secret: Option<String>,
no_input: bool,
) -> Result<()> {
if !no_input {
eprintln!("OAuth 2.0 requires your own Atlassian OAuth app.");
eprintln!("Create one at: https://developer.atlassian.com/console/myapps/\n");
}

let client_secret: String = Password::new()
.with_prompt("OAuth Client Secret")
.interact()
.context("failed to read OAuth client secret")?;
let client_id = resolve_credential(
client_id,
ENV_OAUTH_CLIENT_ID,
"--client-id",
"OAuth Client ID",
false,
no_input,
Some(OAUTH_APP_HINT),
)?;
let client_secret = resolve_credential(
client_secret,
ENV_OAUTH_CLIENT_SECRET,
"--client-secret",
"OAuth Client Secret",
true,
no_input,
Some(OAUTH_APP_HINT),
)?;

// Store OAuth app credentials in keychain
crate::api::auth::store_oauth_app_credentials(&client_id, &client_secret)?;
Expand Down Expand Up @@ -152,6 +251,11 @@ fn refresh_success_payload(flow: AuthFlow) -> serde_json::Value {
/// before the error is propagated.
pub async fn refresh_credentials(
oauth_override: bool,
email: Option<String>,
token: Option<String>,
client_id: Option<String>,
client_secret: Option<String>,
no_input: bool,
output: &crate::cli::OutputFormat,
) -> Result<()> {
let config = Config::load()?;
Expand All @@ -162,8 +266,8 @@ pub async fn refresh_credentials(
)?;

let login_result = match flow {
AuthFlow::Token => login_token().await,
AuthFlow::OAuth => login_oauth().await,
AuthFlow::Token => login_token(email, token, no_input).await,
AuthFlow::OAuth => login_oauth(client_id, client_secret, no_input).await,
};

if let Err(err) = login_result {
Expand Down Expand Up @@ -263,4 +367,145 @@ mod tests {
assert_eq!(payload["status"], "refreshed");
assert_eq!(payload["auth_method"], "oauth");
}

// ── resolve_credential ───────────────────────────────────────────
//
// Env-reading tests must serialize process-environment mutation across
// parallel test threads. `std::env::set_var` / `remove_var` are unsafe
// in Rust 2024 because concurrent env access (even on different keys)
// is UB — C's getenv/setenv aren't thread-safe. `EnvGuard` holds
// `ENV_LOCK` for its full lifetime and removes the var on drop so a
// panic mid-test doesn't leak state to later tests in the same
// process. Matches the pattern in src/config.rs::ENV_MUTEX.

static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

struct EnvGuard {
key: &'static str,
_lock: std::sync::MutexGuard<'static, ()>,
}

impl EnvGuard {
fn set(key: &'static str, value: &str) -> Self {
let lock = ENV_LOCK.lock().unwrap();
// SAFETY: test env mutation is serialized by ENV_LOCK, held for
// this guard's lifetime. The Drop impl unsets the same
// test-local key before releasing the lock.
unsafe {
std::env::set_var(key, value);
}
EnvGuard { key, _lock: lock }
}
}

impl Drop for EnvGuard {
fn drop(&mut self) {
// SAFETY: matches the test-local key set in `EnvGuard::set`
// while `_lock` is still held by this `EnvGuard`.
unsafe {
std::env::remove_var(self.key);
}
}
}

#[test]
fn resolve_credential_prefers_flag_over_env() {
let _guard = EnvGuard::set("_JR_TEST_PREFERS_FLAG", "from-env");
let got = resolve_credential(
Some("from-flag".into()),
"_JR_TEST_PREFERS_FLAG",
"--email",
"Jira email",
false,
true,
None,
)
.unwrap();
assert_eq!(got, "from-flag");
}

#[test]
fn resolve_credential_falls_back_to_env_when_flag_absent() {
let _guard = EnvGuard::set("_JR_TEST_FALLS_BACK", "from-env");
let got = resolve_credential(
None,
"_JR_TEST_FALLS_BACK",
"--email",
"Jira email",
false,
true,
None,
)
.unwrap();
assert_eq!(got, "from-env");
}

#[test]
fn resolve_credential_ignores_empty_flag_and_env() {
// Empty values should fall through to the no_input error path.
let _guard = EnvGuard::set("_JR_TEST_EMPTY", "");
let err = resolve_credential(
Some(String::new()),
"_JR_TEST_EMPTY",
"--email",
"Jira email",
false,
true,
None,
)
.unwrap_err();
assert!(
err.downcast_ref::<JrError>()
.is_some_and(|e| matches!(e, JrError::UserError(_))),
"Expected JrError::UserError for empty inputs, got: {err}"
);
}

#[test]
fn resolve_credential_no_input_errors_when_missing() {
// resolve_credential reads env via std::env::var — hold ENV_LOCK to
// serialize against set/remove calls in sibling tests.
let _lock = ENV_LOCK.lock().unwrap();
let err = resolve_credential(
None,
"_JR_TEST_UNSET_MISSING",
"--email",
"Jira email",
false,
true,
None,
)
.unwrap_err();
let msg = err.to_string();
assert!(
err.downcast_ref::<JrError>()
.is_some_and(|e| matches!(e, JrError::UserError(_))),
"Expected JrError::UserError, got: {err}"
);
assert!(
msg.contains("--email") && msg.contains("$_JR_TEST_UNSET_MISSING"),
"Error should cite both flag and env var: {msg}"
);
}

#[test]
fn resolve_credential_oauth_hint_appears_in_error() {
// Same env-read serialization as the test above.
let _lock = ENV_LOCK.lock().unwrap();
let err = resolve_credential(
None,
"_JR_TEST_UNSET_OAUTH",
"--client-id",
"OAuth Client ID",
false,
true,
Some(OAUTH_APP_HINT),
)
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("developer.atlassian.com/console/myapps"),
"OAuth error should cite dev console URL: {msg}"
);
}
}
9 changes: 6 additions & 3 deletions src/cli/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,14 @@ pub async fn handle() -> Result<()> {
};
config.save_global()?;

// Step 3: Authenticate
// Step 3: Authenticate. `jr init` is inherently interactive (Select
// prompts above), so pass no_input=false and let dialoguer handle each
// credential prompt. Flags aren't plumbed through init — users who want
// a non-interactive setup should run `jr auth login` directly.
if auth_choice == 0 {
crate::cli::auth::login_oauth().await?;
crate::cli::auth::login_oauth(None, None, false).await?;
} else {
crate::cli::auth::login_token().await?;
crate::cli::auth::login_token(None, None, false).await?;
let mut config = Config::load()?;
config.global.instance.auth_method = Some("api_token".into());
config.save_global()?;
Expand Down
Loading