use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use redisctl_core::AuthError;
use redisctl_core::auth::{
AccountChoice, CloudAuthenticator, LoginAccount, LoginFlow, MFA_MAX_ATTEMPTS,
MintedCredentials, SupersededKey,
};
use redisctl_core::{CloudAuthConfig, Config, CredentialStore, DeviceAuthorization, TokenSet};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::cli::CloudAuthCommands;
use crate::connection::ConnectionManager;
use crate::error::{RedisCtlError, Result as CliResult};
use crate::output::{OutputFormat, print_formatted_output};
use crate::structured_error::StructuredError;
const SCOPES: [&str; 4] = ["openid", "profile", "email", "offline_access"];
const STALE_KEY_WARN_THRESHOLD: usize = 3;
const ACCOUNT_PROMPT_ATTEMPTS: u32 = 3;
fn auth_err(e: redisctl_core::AuthError) -> RedisCtlError {
RedisCtlError::Structured(Box::new(StructuredError::from(e)))
}
pub async fn handle_auth_command(
conn_mgr: &ConnectionManager,
profile: Option<&str>,
cmd: &CloudAuthCommands,
output: OutputFormat,
) -> CliResult<()> {
match cmd {
CloudAuthCommands::Login {
device,
allow_plaintext,
wait,
account,
} => {
login(
conn_mgr,
profile,
*device,
*allow_plaintext,
*wait,
*account,
output,
)
.await
}
CloudAuthCommands::Switch { account } => switch(conn_mgr, profile, *account, output).await,
CloudAuthCommands::Accounts => accounts(conn_mgr, profile, output).await,
CloudAuthCommands::Status { wait, timeout } => {
status(conn_mgr, profile, *wait, *timeout, output).await
}
CloudAuthCommands::Logout => logout(conn_mgr, profile, output).await,
}
}
fn target_profile(conn_mgr: &ConnectionManager, profile: Option<&str>) -> String {
profile
.map(str::to_string)
.or_else(|| conn_mgr.config.default_cloud.clone())
.unwrap_or_else(|| "cloud".to_string())
}
fn prepare(
conn_mgr: &ConnectionManager,
profile: Option<&str>,
) -> CliResult<(String, CloudAuthenticator, CloudAuthConfig)> {
let profile_name = target_profile(conn_mgr, profile);
let auth_cfg = conn_mgr.config.resolve_cloud_auth(&profile_name);
if !auth_cfg.is_complete() {
return Err(RedisCtlError::Structured(Box::new(
StructuredError::not_authenticated(format!(
"cloud login endpoints are not configured for profile '{profile_name}'. Add a \
[cloud_auth.{profile_name}] section (okta_issuer, okta_client_id, sm_api_url) to \
the config, or use a profile whose environment is provisioned."
)),
)));
}
let issuer = parse_url(&auth_cfg.okta_issuer, "okta_issuer")?;
let sm_api_url = parse_url(&auth_cfg.sm_api_url, "sm_api_url")?;
parse_url(&auth_cfg.capi_url, "capi_url")?;
validate_client_id(&auth_cfg.okta_client_id)?;
let authenticator = CloudAuthenticator::new(
issuer,
&auth_cfg.okta_client_id,
sm_api_url,
&auth_cfg.capi_url,
);
Ok((profile_name, authenticator, auth_cfg))
}
async fn login(
conn_mgr: &ConnectionManager,
profile: Option<&str>,
device: bool,
allow_plaintext: bool,
wait: bool,
account: Option<u64>,
output: OutputFormat,
) -> CliResult<()> {
let (profile_name, authenticator, auth_cfg) = prepare(conn_mgr, profile)?;
let use_device = device || !std::io::stderr().is_terminal();
if use_device && !wait {
return initiate_device_login(
conn_mgr,
&profile_name,
&authenticator,
allow_plaintext,
account,
output,
)
.await;
}
let superseded = superseded_key(&auth_cfg);
let tokens = if use_device {
run_device_flow_blocking(&authenticator).await?
} else {
run_loopback_flow(&authenticator).await?
};
let creds = complete_and_persist(
conn_mgr,
&profile_name,
&authenticator,
&tokens,
auth_cfg,
LoginRun {
flow: if use_device {
LoginFlow::Device
} else {
LoginFlow::Loopback
},
account: match account {
Some(id) => AccountChoice::Id(id),
None if std::io::stdin().is_terminal() && std::io::stderr().is_terminal() => {
AccountChoice::Prompt(Box::new(account_for_login))
}
None => AccountChoice::Current,
},
superseded,
allow_plaintext,
make_default: true,
},
)
.await?;
emit_signed_in(&creds, &profile_name, output)
}
fn account_for_login(accounts: &[LoginAccount], current: Option<u64>) -> Result<u64, AuthError> {
if let Some(id) = current
&& accounts.iter().any(|a| a.id == id)
{
return Ok(id);
}
match accounts {
[] => Err(AuthError::Protocol(
"this login is not associated with any Redis Cloud account".into(),
)),
[only] => Ok(only.id),
several => prompt_account(several, current),
}
}
fn superseded_key(auth_cfg: &CloudAuthConfig) -> Option<SupersededKey> {
match (auth_cfg.account_id, auth_cfg.capi_key_name.clone()) {
(Some(account_id), Some(key_name)) => Some(SupersededKey {
account_id,
key_name,
}),
_ => None,
}
}
async fn accounts(
conn_mgr: &ConnectionManager,
profile: Option<&str>,
output: OutputFormat,
) -> CliResult<()> {
let (profile_name, authenticator, auth_cfg) = prepare(conn_mgr, profile)?;
let on_account = auth_cfg.account_id;
let store = CredentialStore::new();
let refresh_token = store
.get_credential(&format!("keyring:{profile_name}-okta-refresh"), None)
.map_err(|_| {
RedisCtlError::Structured(Box::new(StructuredError::not_authenticated(format!(
"there is no stored sign-in for profile '{profile_name}' to list accounts \
with. Run `redisctl --profile {profile_name} cloud auth login` first; its \
output lists them too."
))))
})?;
let tokens = authenticator
.refresh(&refresh_token)
.await
.map_err(|e| match e {
AuthError::Network(_) | AuthError::Transport(_) => auth_err(e),
_ => RedisCtlError::Structured(Box::new(StructuredError::not_authenticated(format!(
"the stored sign-in for profile '{profile_name}' is no longer usable. Run \
`redisctl --profile {profile_name} cloud auth login`."
)))),
})?;
let mut sign_in_stored = true;
if let Some(rotated) = tokens.refresh_token.as_deref()
&& rotated != refresh_token
&& let Err(e) = store.store_credential(&format!("{profile_name}-okta-refresh"), rotated)
{
sign_in_stored = false;
eprintln!(
"\n warning: the identity provider rotated this profile's sign-in and the \
replacement could not be stored ({e}). The one it replaced is no longer valid, so \
run `redisctl --profile {profile_name} cloud auth login` before the next command."
);
}
let listing = authenticator
.list_accounts(&tokens, prompt_mfa_code)
.await
.map_err(auth_err)?;
let accounts = listing.accounts;
let profile_account = on_account.or(listing.session_account);
match listing.email.as_deref() {
Some(email) => eprintln!("\nAccounts {email} belongs to:"),
None => eprintln!("\nAccounts this sign-in belongs to:"),
}
for a in &accounts {
let marker = if Some(a.id) == profile_account {
" (this profile)"
} else {
""
};
eprintln!(" {}{}", a.label(), marker);
}
if accounts.len() > 1 {
eprintln!("\nTo use another: redisctl --profile {profile_name} cloud auth switch <id>");
}
print_formatted_output(
serde_json::json!({
"status": "ok",
"profile": profile_name,
"email": listing.email,
"account_id": profile_account,
"sign_in_stored": sign_in_stored,
"accounts": accounts.iter().map(|a| serde_json::json!({
"id": a.id,
"name": a.name,
})).collect::<Vec<_>>(),
}),
output,
)
}
async fn switch(
conn_mgr: &ConnectionManager,
profile: Option<&str>,
account: Option<u64>,
output: OutputFormat,
) -> CliResult<()> {
let (profile_name, authenticator, auth_cfg) = prepare(conn_mgr, profile)?;
let on_account = auth_cfg.account_id;
let superseded = superseded_key(&auth_cfg);
if let Some(want) = account
&& on_account == Some(want)
{
eprintln!("\nProfile '{profile_name}' already uses account #{want}. Nothing to do.");
return print_formatted_output(
serde_json::json!({
"status": "ok",
"profile": profile_name,
"account_id": want,
"changed": false,
}),
output,
);
}
let interactive = std::io::stderr().is_terminal() && std::io::stdin().is_terminal();
if account.is_none() && !interactive {
return Err(RedisCtlError::Structured(Box::new(
StructuredError::account_required(format!(
"there is no terminal to choose an account on; pass the id instead \
(`redisctl --profile {profile_name} cloud auth switch <ID>`). A completed login \
reports the accounts you belong to in its `accounts` field."
)),
)));
}
let store = CredentialStore::new();
let refresh_token = store
.get_credential(&format!("keyring:{profile_name}-okta-refresh"), None)
.map_err(|_| {
RedisCtlError::Structured(Box::new(StructuredError::not_authenticated(format!(
"there is no stored sign-in for profile '{profile_name}' to switch with \
(credentials saved with --allow-plaintext cannot be reused this way). Run \
`redisctl --profile {profile_name} cloud auth login --account <ID>` instead."
))))
})?;
let tokens = authenticator
.refresh(&refresh_token)
.await
.map_err(|e| match e {
AuthError::Network(_) | AuthError::Transport(_) => auth_err(e),
_ => RedisCtlError::Structured(Box::new(StructuredError::not_authenticated(format!(
"the stored sign-in for profile '{profile_name}' is no longer usable — refresh \
tokens expire and are rotated. Run `redisctl --profile {profile_name} cloud auth \
login --account <ID>`."
)))),
})?;
let creds = complete_and_persist(
conn_mgr,
&profile_name,
&authenticator,
&tokens,
auth_cfg,
LoginRun {
flow: LoginFlow::Switch,
account: match account {
Some(id) => AccountChoice::Id(id),
None => AccountChoice::Prompt(Box::new(move |accounts, _session_account| {
prompt_account(accounts, on_account)
})),
},
superseded,
allow_plaintext: false,
make_default: false,
},
)
.await?;
clear_pending(conn_mgr, &profile_name);
eprintln!(
"\n\u{2713} Profile '{profile_name}' now uses {}.",
creds.account_label()
);
match creds.superseded_revoked {
Some(true) => eprintln!(" the key it replaced has been revoked."),
Some(false) => eprintln!(
" note: could not revoke {} — revoke it in the Redis Cloud console \
(Access Management > API Keys).",
superseded_label(&creds)
),
None => {}
}
warn_on_key_sprawl(&creds);
print_formatted_output(
serde_json::json!({
"status": "ok",
"profile": profile_name,
"account_id": creds.account_id,
"account_name": creds.account_name,
"accounts": creds.accounts.iter().map(|a| serde_json::json!({
"id": a.id,
"name": a.name,
})).collect::<Vec<_>>(),
"email": creds.email,
"redisctl_key_count": creds.redisctl_key_count,
"superseded_revoked": creds.superseded_revoked,
"superseded_key": creds.superseded_key_name,
"changed": true,
}),
output,
)
}
fn superseded_label(creds: &MintedCredentials) -> String {
match &creds.superseded_key_name {
Some(key) => format!("the key {key} that this replaced"),
None => "the key this replaced".to_string(),
}
}
fn warn_on_key_sprawl(creds: &MintedCredentials) {
let key_count = creds.redisctl_key_count;
if key_count > STALE_KEY_WARN_THRESHOLD {
eprintln!(
" note: this account now has {key_count} redisctl-* API keys. Revoke unused ones \
in the Redis Cloud console (Access Management > API Keys)."
);
}
}
fn prompt_account(accounts: &[LoginAccount], current: Option<u64>) -> Result<u64, AuthError> {
if accounts.len() < 2 {
return Err(AuthError::AccountRequired(
"this login belongs to a single Redis Cloud account, so there is nothing to switch to"
.into(),
));
}
eprintln!("\nAccounts you belong to:");
for (i, a) in accounts.iter().enumerate() {
let marker = if Some(a.id) == current {
" (current)"
} else {
""
};
eprintln!(" {}) {}{}", i + 1, a.label(), marker);
}
if current.is_none() {
eprintln!(" (which one this profile is on is unknown — sign in again to record it)");
}
for attempt in 1..=ACCOUNT_PROMPT_ATTEMPTS {
eprint!("Switch to which? [1-{}]: ", accounts.len());
use std::io::Write;
let _ = std::io::stderr().flush();
let mut line = String::new();
let read = std::io::stdin()
.read_line(&mut line)
.map_err(|e| AuthError::Protocol(format!("could not read a choice: {e}")))?;
if read == 0 {
return Err(AuthError::AccountRequired(
"no account was chosen, so nothing was switched".into(),
));
}
match resolve_account_choice(accounts, &line) {
Ok(id) => return Ok(id),
Err(e) if attempt < ACCOUNT_PROMPT_ATTEMPTS => eprintln!(" {e}"),
Err(e) => return Err(e),
}
}
unreachable!("loop returns on the final attempt")
}
fn resolve_account_choice(accounts: &[LoginAccount], input: &str) -> Result<u64, AuthError> {
let typed = input.trim();
if let Ok(position) = typed.parse::<usize>()
&& let Some(a) = accounts.get(position.wrapping_sub(1))
{
return Ok(a.id);
}
if let Ok(id) = typed.parse::<u64>()
&& let Some(a) = accounts.iter().find(|a| a.id == id)
{
return Ok(a.id);
}
Err(AuthError::AccountRequired(format!(
"'{typed}' is not one of 1-{} or an account id from the list",
accounts.len()
)))
}
fn device_instructions(authz: &DeviceAuthorization) -> String {
match authz.verification_uri_complete() {
Some(complete) => format!(
"\nTo sign in, open:\n {complete}\nand confirm the code: {} (already filled in)\n",
authz.user_code(),
),
None => format!(
"\nTo sign in, open:\n {}\nand enter the code: {}\n",
authz.verification_uri(),
authz.user_code(),
),
}
}
async fn initiate_device_login(
conn_mgr: &ConnectionManager,
profile_name: &str,
authenticator: &CloudAuthenticator,
allow_plaintext: bool,
account: Option<u64>,
output: OutputFormat,
) -> CliResult<()> {
let authz = authenticator
.device()
.start(&SCOPES)
.await
.map_err(auth_err)?;
save_pending(
conn_mgr,
&PendingAuth {
profile: profile_name.to_string(),
device_authorization: authz.clone(),
allow_plaintext,
account,
},
)?;
eprintln!(
"{}Then run: redisctl cloud auth status --wait (or wait for the agent to poll)",
device_instructions(&authz),
);
print_formatted_output(
serde_json::json!({
"status": "authorization_pending",
"profile": profile_name,
"verification_uri": authz.verification_uri(),
"verification_uri_complete": authz.verification_uri_complete(),
"user_code": authz.user_code(),
"expires_in": authz.expires_in(),
"interval": authz.interval(),
}),
output,
)
}
async fn run_device_flow_blocking(auth: &CloudAuthenticator) -> CliResult<TokenSet> {
let client = auth.device();
let authz = client.start(&SCOPES).await.map_err(auth_err)?;
eprintln!("{}(waiting for approval…)", device_instructions(&authz),);
client.poll(&authz, None).await.map_err(auth_err)
}
async fn run_loopback_flow(auth: &CloudAuthenticator) -> CliResult<TokenSet> {
let tokens = auth
.loopback()
.login(&SCOPES, |url| {
eprintln!("Opening your browser to sign in…\n {url}");
let _ = open_browser(url);
})
.await
.map_err(auth_err)?;
Ok(tokens)
}
fn attempts_left(attempt: u32) -> u32 {
MFA_MAX_ATTEMPTS.saturating_add(1).saturating_sub(attempt)
}
fn prompt_mfa_code(factors: &[String], attempt: u32) -> Result<Option<String>, AuthError> {
if !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
return Ok(None);
}
if attempt == 1 {
let detail = if factors.is_empty() {
String::new()
} else {
format!(" ({})", factors.join(", "))
};
eprintln!("\nThis account requires multi-factor authentication{detail}.");
} else {
eprintln!(
"That code wasn't accepted. {} attempt(s) left.",
attempts_left(attempt)
);
}
loop {
eprint!("Enter the 6-digit code from your authenticator app: ");
let _ = std::io::Write::flush(&mut std::io::stderr());
let mut line = String::new();
if std::io::stdin().read_line(&mut line).unwrap_or(0) == 0 {
return Ok(None); }
let code = line.trim();
if code.len() == 6 && code.chars().all(|c| c.is_ascii_digit()) {
return Ok(Some(code.to_string()));
}
eprintln!("Codes are exactly 6 digits.");
}
}
struct LoginRun {
flow: LoginFlow,
account: AccountChoice,
superseded: Option<SupersededKey>,
allow_plaintext: bool,
make_default: bool,
}
async fn complete_and_persist(
conn_mgr: &ConnectionManager,
profile_name: &str,
authenticator: &CloudAuthenticator,
tokens: &TokenSet,
auth_cfg: CloudAuthConfig,
run: LoginRun,
) -> CliResult<MintedCredentials> {
let store = if run.allow_plaintext {
CredentialStore::plaintext()
} else {
let store = CredentialStore::new();
if store.storage_backend() != "keyring" {
return Err(RedisCtlError::Structured(Box::new(
StructuredError::keyring_unavailable(
"no OS keyring is available to store the credentials, and storing them in \
the config file has to be asked for. Re-run with `--allow-plaintext` to \
store them there (0600) instead.",
),
)));
}
store.probe_writable().map_err(|e| {
RedisCtlError::Structured(Box::new(StructuredError::keyring_unavailable(format!(
"the OS keyring cannot store credentials ({e}). Re-run with \
`--allow-plaintext` to store them in the config file (0600) instead."
))))
})?;
store
};
let (mut creds, revoker) = authenticator
.complete_login_with_mfa(
tokens,
&default_key_name(),
run.flow,
run.account,
run.superseded,
prompt_mfa_code,
)
.await
.map_err(auth_err)?;
let mut config = conn_mgr.config.clone();
config
.apply_cloud_login(
&store,
profile_name,
&creds,
Some(auth_cfg),
run.make_default,
)
.map_err(|e| {
let orphan = format!(
" The key {} was created but not stored; revoke it in the Redis Cloud console \
(Access Management > API Keys).",
creds.capi_key_name
);
if run.allow_plaintext {
RedisCtlError::Structured(Box::new(StructuredError::keyring_unavailable(format!(
"failed to store credentials ({e}).{orphan}"
))))
} else {
RedisCtlError::Structured(Box::new(StructuredError::keyring_unavailable(format!(
"failed to store credentials in the OS keyring ({e}). Re-run \
`redisctl cloud auth login --allow-plaintext` to store them in the config \
file (0600) instead.{orphan}"
))))
}
})?;
save_config_for_store(conn_mgr, &config, &store).map_err(|e| match e {
RedisCtlError::Structured(_) => e,
other => RedisCtlError::Configuration(format!(
"{other}. The key {} was created but not stored; revoke it in the Redis Cloud \
console (Access Management > API Keys).",
creds.capi_key_name
)),
})?;
if let Some(revoker) = revoker {
let same_account = Some(revoker.account_id()) == creds.account_id;
creds.superseded_key_name = Some(revoker.key_name().to_string());
let revoked = revoker.revoke().await;
creds.superseded_revoked = Some(revoked);
if revoked && same_account {
creds.redisctl_key_count = creds.redisctl_key_count.saturating_sub(1);
}
}
Ok(creds)
}
fn emit_signed_in(
creds: &MintedCredentials,
profile_name: &str,
output: OutputFormat,
) -> CliResult<()> {
eprintln!(
"\n\u{2713} Signed in as {}. Credentials saved to profile '{}'.",
creds.email.as_deref().unwrap_or("your account"),
profile_name
);
if creds.account_count() > 1 {
let which = creds
.account_id
.and_then(|id| creds.accounts.iter().find(|a| a.id == id))
.map(LoginAccount::label)
.unwrap_or_else(|| "your current account".to_string());
eprintln!(
" note: the key is for {which} — 1 of {} accounts you belong to:",
creds.account_count()
);
eprintln!(
" {}",
creds
.accounts
.iter()
.map(LoginAccount::label)
.collect::<Vec<_>>()
.join(" · ")
);
eprintln!(" To use another: redisctl --profile {profile_name} cloud auth switch <id>");
}
if creds.capi_newly_enabled {
eprintln!(
" note: programmatic (API) access was switched on for this account — it was off \
until now, and this applies account-wide, not just to this key."
);
}
if creds.superseded_revoked == Some(false) {
eprintln!(
" note: could not revoke {} — revoke it in the Redis Cloud console \
(Access Management > API Keys).",
superseded_label(creds)
);
}
warn_on_key_sprawl(creds);
let key_count = creds.redisctl_key_count;
print_formatted_output(
serde_json::json!({
"status": "ok",
"authenticated": true,
"profile": profile_name,
"account_id": creds.account_id,
"account_name": creds.account_name,
"account_count": creds.account_count(),
"accounts": creds.accounts.iter().map(|a| serde_json::json!({
"id": a.id,
"name": a.name,
})).collect::<Vec<_>>(),
"email": creds.email,
"redisctl_key_count": key_count,
"capi_newly_enabled": creds.capi_newly_enabled,
"superseded_revoked": creds.superseded_revoked,
"superseded_key": creds.superseded_key_name,
}),
output,
)
}
async fn status(
conn_mgr: &ConnectionManager,
profile: Option<&str>,
wait: bool,
timeout: u64,
output: OutputFormat,
) -> CliResult<()> {
let profile_name = target_profile(conn_mgr, profile);
if wait && let Some(pending) = load_pending(conn_mgr, &profile_name) {
let (_, authenticator, auth_cfg) = prepare(conn_mgr, Some(&profile_name))?;
match poll_pending(&authenticator, &pending, timeout).await? {
Some(tokens) => {
let creds = complete_and_persist(
conn_mgr,
&profile_name,
&authenticator,
&tokens,
auth_cfg.clone(),
LoginRun {
flow: LoginFlow::Device,
account: match pending.account {
Some(id) => AccountChoice::Id(id),
None => AccountChoice::Current,
},
superseded: superseded_key(&auth_cfg),
allow_plaintext: pending.allow_plaintext,
make_default: true,
},
)
.await?;
clear_pending(conn_mgr, &profile_name);
return emit_signed_in(&creds, &profile_name, output);
}
None => {
return print_formatted_output(
serde_json::json!({
"status": "authorization_pending",
"authenticated": false,
"profile": profile_name,
}),
output,
);
}
}
}
let authenticated = conn_mgr
.resolve_cloud_connection(Some(&profile_name))
.is_ok();
let pending = !authenticated && load_pending(conn_mgr, &profile_name).is_some();
if pending {
eprintln!(
"A device login for profile '{profile_name}' is waiting for approval.\nComplete it \
with: redisctl cloud auth status --wait"
);
}
let mut report = serde_json::json!({ "authenticated": authenticated, "profile": profile_name });
if pending {
report["status"] = "authorization_pending".into();
}
print_formatted_output(report, output)
}
async fn poll_pending(
auth: &CloudAuthenticator,
pending: &PendingAuth,
timeout_secs: u64,
) -> CliResult<Option<TokenSet>> {
let client = auth.device();
match tokio::time::timeout(
Duration::from_secs(timeout_secs),
client.poll(&pending.device_authorization, None),
)
.await
{
Err(_elapsed) => Ok(None),
Ok(result) => result.map(Some).map_err(auth_err),
}
}
async fn logout(
conn_mgr: &ConnectionManager,
profile: Option<&str>,
output: OutputFormat,
) -> CliResult<()> {
let profile_name = target_profile(conn_mgr, profile);
let store = CredentialStore::new();
let revoked = revoke_remotely(conn_mgr, &profile_name, &store).await;
for suffix in ["cloud-api-key", "cloud-api-secret", "okta-refresh"] {
let _ = store.delete_credential(&format!("{profile_name}-{suffix}"));
}
clear_pending(conn_mgr, &profile_name);
let mut config = conn_mgr.config.clone();
let saved_auth = config.cloud_auth.get(&profile_name).cloned();
config.remove_profile(&profile_name);
if let Some(mut auth) = saved_auth {
auth.account_id = None;
auth.capi_key_name = None;
config.cloud_auth.insert(profile_name.clone(), auth);
}
save_config(conn_mgr, &config)?;
match (&revoked.key, &revoked.session) {
(Ok(key), Ok(())) => eprintln!(
"\n\u{2713} Logged out of profile '{profile_name}'. Revoked the API key {key} and \
the stored sign-in."
),
(Ok(key), Err(_)) => eprintln!(
"\n\u{2713} Logged out of profile '{profile_name}'. Revoked the API key {key}."
),
(Err(_), Ok(())) => eprintln!(
"\n\u{2713} Logged out of profile '{profile_name}'. Revoked the stored sign-in."
),
(Err(_), Err(_)) => {
eprintln!("\n\u{2713} Logged out of profile '{profile_name}' locally.")
}
}
for note in revoked.notes() {
eprintln!(" note: {note}");
}
print_formatted_output(
serde_json::json!({
"status": "ok",
"profile": profile_name,
"logged_out": true,
"revoked": revoked.key.is_ok() && revoked.session.is_ok(),
"key_revoked": revoked.key.is_ok(),
"session_revoked": revoked.session.is_ok(),
}),
output,
)
}
struct Revocation {
key: Result<String, String>,
session: Result<(), String>,
}
impl Revocation {
fn blocked(key_note: String, session_note: String) -> Self {
Self {
key: Err(key_note),
session: Err(session_note),
}
}
fn notes(&self) -> Vec<String> {
let mut notes = Vec::new();
if let Err(why) = &self.key {
notes.push(format!(
"{why} Revoke the key in the Redis Cloud console (Access Management > API Keys)."
));
}
if let Err(why) = &self.session {
notes.push(format!(
"{why} It stays valid at the identity provider until it expires."
));
}
notes
}
}
fn refresh_failure(e: &AuthError, no_key_because: impl Fn(&str) -> String) -> Revocation {
let (why, session) = match e {
AuthError::Network(_) | AuthError::Transport(_) => (
"the identity provider could not be reached",
"so the stored sign-in was not revoked.",
),
_ => (
"the stored sign-in is no longer valid",
"so there was nothing to revoke.",
),
};
Revocation::blocked(no_key_because(why), format!("{why}, {session}"))
}
async fn revoke_remotely(
conn_mgr: &ConnectionManager,
profile_name: &str,
store: &CredentialStore,
) -> Revocation {
let auth_cfg = conn_mgr.config.resolve_cloud_auth(profile_name);
let recorded_key = auth_cfg.capi_key_name.clone();
let no_key_because = |why: &str| match &recorded_key {
Some(key) => format!("{why}, so the key {key} could not be revoked."),
None => format!("{why}, and this profile does not record a key to revoke by name."),
};
let Ok(refresh_token) =
store.get_credential(&format!("keyring:{profile_name}-okta-refresh"), None)
else {
let why = format!("there is no stored sign-in for '{profile_name}'");
return Revocation::blocked(no_key_because(&why), format!("{why}, so none was revoked."));
};
let (_, authenticator, _) = match prepare(conn_mgr, Some(profile_name)) {
Ok(parts) => parts,
Err(_) => {
let why = "login endpoints are not configured";
return Revocation::blocked(
no_key_because(why),
format!("{why}, so the stored sign-in could not be revoked."),
);
}
};
let tokens = match authenticator.refresh(&refresh_token).await {
Ok(tokens) => tokens,
Err(e) => return refresh_failure(&e, no_key_because),
};
let key = match &recorded_key {
None => Err(
"this profile does not record which API key it holds, so there was nothing to \
revoke by name."
.to_string(),
),
Some(key_name) => {
let on_account = auth_cfg.account_id;
match authenticator
.revoke_capi_key(&tokens, on_account, key_name)
.await
{
Ok(true) => Ok(key_name.clone()),
Ok(false) => Err(match on_account {
Some(account) => format!(
"the key {key_name} was not found on account {account}; it may already \
be revoked."
),
None => format!(
"the key {key_name} was not found on the account this sign-in defaults \
to, and this profile does not record which account it belongs to."
),
}),
Err(e) => Err(format!("could not revoke the key {key_name}: {e}.")),
}
}
};
let newest = tokens.refresh_token.as_deref().unwrap_or(&refresh_token);
let session = authenticator
.revoke_refresh_token(newest)
.await
.map_err(|e| format!("could not revoke the stored sign-in: {e}."));
Revocation { key, session }
}
#[derive(Serialize, Deserialize)]
struct PendingAuth {
profile: String,
#[serde(default)]
account: Option<u64>,
device_authorization: DeviceAuthorization,
allow_plaintext: bool,
}
fn pending_dir(conn_mgr: &ConnectionManager) -> PathBuf {
conn_mgr
.config_path
.as_ref()
.and_then(|p| p.parent().map(Path::to_path_buf))
.or_else(|| {
Config::config_path()
.ok()
.and_then(|p| p.parent().map(Path::to_path_buf))
})
.unwrap_or_else(std::env::temp_dir)
}
fn pending_path(conn_mgr: &ConnectionManager, profile: &str) -> PathBuf {
pending_dir(conn_mgr).join(format!("redisctl-pending-{profile}.json"))
}
fn save_pending(conn_mgr: &ConnectionManager, pending: &PendingAuth) -> CliResult<()> {
let path = pending_path(conn_mgr, &pending.profile);
let json = serde_json::to_vec_pretty(pending)?;
write_private(&path, &json)
.map_err(|e| RedisCtlError::Configuration(format!("could not save pending login: {e}")))
}
fn load_pending(conn_mgr: &ConnectionManager, profile: &str) -> Option<PendingAuth> {
let bytes = std::fs::read(pending_path(conn_mgr, profile)).ok()?;
serde_json::from_slice(&bytes).ok()
}
fn clear_pending(conn_mgr: &ConnectionManager, profile: &str) {
let _ = std::fs::remove_file(pending_path(conn_mgr, profile));
}
#[cfg(unix)]
fn write_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write as _;
use std::os::unix::fs::OpenOptionsExt;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)?;
f.write_all(bytes)
}
#[cfg(not(unix))]
fn write_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, bytes)
}
fn validate_client_id(client_id: &str) -> CliResult<()> {
if !client_id.is_empty()
&& client_id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Ok(());
}
Err(RedisCtlError::Configuration(format!(
"invalid okta_client_id ({client_id:?}): expected letters, digits, '-' or '_'"
)))
}
const TRUSTED_ENDPOINT_SUFFIXES: [&str; 3] = ["redis.com", "redislabs.com", "redis.io"];
const TRUST_OVERRIDE_ENV: &str = "REDISCTL_ALLOW_UNTRUSTED_ENDPOINTS";
fn endpoint_trust_disabled() -> bool {
trust_override_requested(std::env::var(TRUST_OVERRIDE_ENV).ok().as_deref())
}
fn trust_override_requested(value: Option<&str>) -> bool {
value.is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
}
fn is_trusted_endpoint_host(host: &url::Host<&str>) -> bool {
let url::Host::Domain(name) = host else {
return false;
};
let name = name.trim_end_matches('.').to_ascii_lowercase();
TRUSTED_ENDPOINT_SUFFIXES
.iter()
.any(|suffix| name == *suffix || name.ends_with(&format!(".{suffix}")))
}
fn host_display(host: &url::Host<&str>) -> String {
match host {
url::Host::Domain(name) => (*name).to_string(),
url::Host::Ipv4(ip) => ip.to_string(),
url::Host::Ipv6(ip) => format!("[{ip}]"),
}
}
fn parse_url(value: &str, field: &str) -> CliResult<Url> {
parse_endpoint(value, field, endpoint_trust_disabled())
}
fn parse_endpoint(value: &str, field: &str, trust_disabled: bool) -> CliResult<Url> {
let reject = |why: &str| {
RedisCtlError::Structured(Box::new(StructuredError::invalid_endpoint(format!(
"invalid {field} ({value:?}): {why}"
))))
};
let url = Url::parse(value).map_err(|e| reject(&e.to_string()))?;
let Some(host) = url.host() else {
return Err(reject("no host"));
};
if matches!(host, url::Host::Domain("")) {
return Err(reject("no host"));
}
let loopback = match host {
url::Host::Domain(name) => name == "localhost",
url::Host::Ipv4(ip) => ip.is_loopback(),
url::Host::Ipv6(ip) => ip.is_loopback(),
};
if url.scheme() != "https" && !(url.scheme() == "http" && loopback) {
return Err(reject(
"must use https (http is allowed only for localhost)",
));
}
if !loopback && !is_trusted_endpoint_host(&host) {
if !trust_disabled {
return Err(reject(&format!(
"host is not a Redis endpoint ({}). Trusted: {}, or loopback. Set \
{TRUST_OVERRIDE_ENV}=1 to use another host",
host_display(&host),
TRUSTED_ENDPOINT_SUFFIXES.join(", "),
)));
}
eprintln!(
"warning: {TRUST_OVERRIDE_ENV} is set, so {field} points at {} without an \
endpoint check",
host_display(&host)
);
}
if !url.username().is_empty() || url.password().is_some() {
return Err(reject("must not embed credentials"));
}
Ok(url)
}
fn save_config(conn_mgr: &ConnectionManager, config: &Config) -> CliResult<()> {
match &conn_mgr.config_path {
Some(path) => config.save_to_path(path)?,
None => config.save()?,
}
Ok(())
}
fn save_config_for_store(
conn_mgr: &ConnectionManager,
config: &Config,
store: &CredentialStore,
) -> CliResult<()> {
if store.storage_backend() == "keyring" {
return save_config(conn_mgr, config);
}
let path = match &conn_mgr.config_path {
Some(path) => path.clone(),
None => Config::config_path()?,
};
config.save_to_path_owner_only(&path)?;
Ok(())
}
fn default_key_name() -> String {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
format!("redisctl-cli-{ts}")
}
fn open_browser(url: &str) -> std::io::Result<()> {
use std::process::{Command, Stdio};
let mut cmd = if cfg!(target_os = "macos") {
let mut c = Command::new("open");
c.arg(url);
c
} else if cfg!(target_os = "windows") {
let mut c = Command::new("cmd");
c.args(["/C", "start", "", url]);
c
} else {
let mut c = Command::new("xdg-open");
c.arg(url);
c
};
cmd.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map(|_| ())
}
#[cfg(test)]
mod tests {
#[test]
fn a_refresh_that_never_arrived_is_not_a_dead_sign_in() {
let named = |why: &str| format!("the key k-1 was not revoked because {why}");
let notes = refresh_failure(&AuthError::Transport("connection refused".into()), named)
.notes()
.join(" ");
assert!(notes.contains("could not be reached"), "{notes}");
assert!(
!notes.contains("no longer valid") && !notes.contains("nothing to revoke"),
"a transport failure was reported as a dead sign-in: {notes}"
);
assert!(notes.contains("k-1"), "{notes}");
}
#[test]
fn a_rejected_grant_still_reports_nothing_to_revoke() {
let named = |why: &str| format!("the key k-1 was not revoked because {why}");
let notes = refresh_failure(&AuthError::Protocol("invalid_grant".into()), named)
.notes()
.join(" ");
assert!(notes.contains("no longer valid"), "{notes}");
assert!(notes.contains("nothing to revoke"), "{notes}");
}
use super::*;
fn minted(superseded_key_name: Option<&str>) -> MintedCredentials {
MintedCredentials {
account_id: Some(1),
email: None,
api_key: "k".into(),
api_secret: "s".into(),
api_url: "https://api.redislabs.com/v1".into(),
refresh_token: None,
capi_key_name: "redisctl-cli-2".into(),
redisctl_key_count: 1,
account_name: None,
capi_newly_enabled: false,
superseded_revoked: Some(false),
superseded_key_name: superseded_key_name.map(str::to_string),
accounts: vec![],
}
}
#[test]
fn a_failed_revocation_names_the_key_left_behind() {
assert_eq!(
superseded_label(&minted(Some("redisctl-cli-1"))),
"the key redisctl-cli-1 that this replaced"
);
assert_eq!(superseded_label(&minted(None)), "the key this replaced");
}
#[test]
fn logout_reports_each_revocation_separately() {
let both = Revocation {
key: Ok("redisctl-cli-1".to_string()),
session: Ok(()),
};
assert!(both.notes().is_empty());
let key_only = Revocation {
key: Ok("redisctl-cli-1".to_string()),
session: Err("could not revoke the stored sign-in: 503.".to_string()),
};
let notes = key_only.notes();
assert_eq!(notes.len(), 1);
assert!(notes[0].contains("stays valid at the identity provider"));
let session_only = Revocation {
key: Err("could not revoke the key redisctl-cli-1: 500.".to_string()),
session: Ok(()),
};
let notes = session_only.notes();
assert_eq!(notes.len(), 1);
assert!(notes[0].contains("Access Management > API Keys"));
let neither = Revocation {
key: Err("a".to_string()),
session: Err("b".to_string()),
};
assert_eq!(neither.notes().len(), 2);
}
#[test]
fn attempts_left_counts_the_submission_being_entered() {
assert_eq!(MFA_MAX_ATTEMPTS, 3);
assert_eq!(attempts_left(1), 3);
assert_eq!(attempts_left(2), 2);
assert_eq!(attempts_left(3), 1);
assert_eq!(attempts_left(9), 0);
}
fn accounts() -> Vec<LoginAccount> {
vec![
LoginAccount {
id: 316941,
name: Some("Acme".to_string()),
},
LoginAccount {
id: 481022,
name: None,
},
]
}
#[test]
fn login_asks_only_when_the_session_leaves_the_account_open() {
assert_eq!(
account_for_login(&accounts(), Some(481022)).unwrap(),
481022
);
let one = vec![LoginAccount {
id: 316941,
name: Some("Acme".to_string()),
}];
for current in [None, Some(999)] {
assert_eq!(account_for_login(&one, current).unwrap(), 316941);
}
let err = account_for_login(&[], None).unwrap_err();
assert!(matches!(err, AuthError::Protocol(_)), "got {err:?}");
}
#[test]
fn endpoints_must_be_https_or_loopback() {
for ok in [
"https://auth.redis.com/oauth2/default",
"https://api.redislabs.com/v1",
"https://anything.redis.io/",
"http://127.0.0.1:8899/oauth2/default",
"http://localhost:1234/api/v1",
"http://[::1]:1234/api/v1",
] {
assert!(
parse_endpoint(ok, "f", false).is_ok(),
"{ok} should be accepted"
);
}
for bad in [
"http://auth.redis.com/oauth2/default",
"http://[2606:4700::1111]/api/v1",
"http://[fe80::1]/api/v1",
"https://app.example.com/api/v1",
"https://notredis.com/v1",
"https://redis.com.attacker.example/v1",
"https://evil-redis.io/v1",
"https://198.51.100.7/v1",
"https://user:pass@auth.redis.com/",
"https://user@auth.redis.com/",
"ftp://auth.redis.com/",
"not a url",
"file:///etc/passwd",
"https://",
] {
assert!(
parse_endpoint(bad, "f", false).is_err(),
"{bad:?} should be rejected"
);
}
}
#[test]
fn the_override_admits_another_host_but_not_another_scheme() {
let host = "https://app.example.com/api/v1";
assert!(parse_endpoint(host, "sm_api_url", false).is_err());
assert!(
parse_endpoint(host, "sm_api_url", true).is_ok(),
"the override should admit a non-Redis host"
);
assert!(
parse_endpoint("http://app.example.com/api/v1", "sm_api_url", true).is_err(),
"the override must not relax transport security"
);
}
#[test]
fn the_override_is_opt_in_by_exact_value() {
for on in ["1", "true", "TRUE", "True"] {
assert!(trust_override_requested(Some(on)), "{on:?} should opt in");
}
for off in [
None,
Some(""),
Some("0"),
Some("false"),
Some("yes"),
Some("2"),
] {
assert!(!trust_override_requested(off), "{off:?} should not opt in");
}
}
#[test]
fn client_id_must_look_like_one() {
assert!(validate_client_id("0oaw90hjzrLoATW0q5d7").is_ok());
assert!(validate_client_id("client-id_1").is_ok());
for bad in ["", "a b", "a&b", "a|b", "a\"b", "a$(b)"] {
assert!(
validate_client_id(bad).is_err(),
"{bad:?} should be rejected"
);
}
}
#[test]
fn write_private_creates_missing_parents() {
let dir = tempfile::tempdir().unwrap();
let path = dir
.path()
.join("no")
.join("such")
.join("dir")
.join("p.json");
write_private(&path, b"{}").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"{}");
}
#[cfg(unix)]
#[test]
fn write_private_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested").join("p.json");
write_private(&path, b"{}").unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "got {mode:o}");
}
#[test]
fn account_choice_accepts_a_position_or_an_id() {
assert_eq!(resolve_account_choice(&accounts(), "1").unwrap(), 316941);
assert_eq!(resolve_account_choice(&accounts(), " 2\n").unwrap(), 481022);
assert_eq!(
resolve_account_choice(&accounts(), "481022").unwrap(),
481022
);
}
#[test]
fn account_choice_prefers_a_position_over_an_id() {
let low = vec![
LoginAccount {
id: 2,
name: Some("Two".to_string()),
},
LoginAccount {
id: 5,
name: Some("Five".to_string()),
},
];
assert_eq!(resolve_account_choice(&low, "2").unwrap(), 5);
assert_eq!(resolve_account_choice(&low, "1").unwrap(), 2);
assert_eq!(resolve_account_choice(&low, "5").unwrap(), 5);
}
#[test]
fn account_choice_errors_are_preconditions() {
let err = resolve_account_choice(&accounts(), "nope").unwrap_err();
assert!(matches!(err, AuthError::AccountRequired(_)), "got {err:?}");
assert_eq!(
crate::structured_error::StructuredError::from(err).code,
"account_required"
);
}
#[test]
fn account_choice_refuses_anything_else() {
for input in ["", "0", "3", "999999", "abc", "1.5", "-1"] {
assert!(
resolve_account_choice(&accounts(), input).is_err(),
"{input:?} should not resolve to an account"
);
}
}
#[test]
fn prompt_refuses_when_there_is_only_one_account() {
let one = vec![LoginAccount {
id: 316941,
name: Some("Acme".to_string()),
}];
let err = prompt_account(&one, Some(316941)).unwrap_err();
assert!(
matches!(err, AuthError::AccountRequired(ref m) if m.contains("single Redis Cloud account")),
"got {err:?}"
);
}
}