use url::Url;
use super::sm_api::{LoginFlow, SmAccount, SmApiClient, SmUser};
use super::{AuthError, DeviceFlowClient, LoopbackFlowClient, TokenSet, default_http_client};
#[derive(Clone)]
pub struct MintedCredentials {
pub account_id: Option<u64>,
pub email: Option<String>,
pub api_key: String,
pub api_secret: String,
pub api_url: String,
pub refresh_token: Option<String>,
pub capi_key_name: String,
pub redisctl_key_count: usize,
pub account_name: Option<String>,
pub superseded_revoked: Option<bool>,
pub superseded_key_name: Option<String>,
pub capi_newly_enabled: bool,
pub accounts: Vec<LoginAccount>,
}
pub struct SupersededKey {
pub account_id: u64,
pub key_name: String,
}
pub enum AccountChoice {
Current,
Id(u64),
Prompt(AccountPrompt),
}
pub type AccountPrompt =
Box<dyn Fn(&[LoginAccount], Option<u64>) -> Result<u64, AuthError> + Send + Sync>;
impl std::fmt::Debug for AccountChoice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Current => f.write_str("Current"),
Self::Id(id) => write!(f, "Id({id})"),
Self::Prompt(_) => f.write_str("Prompt(..)"),
}
}
}
#[derive(Debug, Clone)]
pub struct LoginAccount {
pub id: u64,
pub name: Option<String>,
}
impl LoginAccount {
pub fn label(&self) -> String {
match &self.name {
Some(n) => format!("{} (#{})", n, self.id),
None => format!("#{}", self.id),
}
}
}
impl MintedCredentials {
pub fn account_count(&self) -> usize {
self.accounts.len()
}
pub fn account_label(&self) -> String {
self.account_id
.and_then(|id| self.accounts.iter().find(|a| a.id == id))
.map(LoginAccount::label)
.unwrap_or_else(|| "your current account".to_string())
}
}
impl std::fmt::Debug for MintedCredentials {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MintedCredentials")
.field("account_id", &self.account_id)
.field("email", &self.email)
.field("api_key", &"<redacted>")
.field("api_secret", &"<redacted>")
.field("api_url", &self.api_url)
.field(
"refresh_token",
&self.refresh_token.as_ref().map(|_| "<redacted>"),
)
.field("capi_key_name", &self.capi_key_name)
.field("redisctl_key_count", &self.redisctl_key_count)
.field("account_name", &self.account_name)
.field("capi_newly_enabled", &self.capi_newly_enabled)
.field("superseded_revoked", &self.superseded_revoked)
.field("superseded_key_name", &self.superseded_key_name)
.field("accounts", &self.accounts)
.finish()
}
}
#[derive(Clone)]
pub struct CloudAuthenticator {
issuer: Url,
client_id: String,
sm_api_url: Url,
capi_url: String,
http: reqwest::Client,
}
impl CloudAuthenticator {
pub fn new(
issuer: Url,
client_id: impl Into<String>,
sm_api_url: Url,
capi_url: impl Into<String>,
) -> Self {
Self {
issuer,
client_id: client_id.into(),
sm_api_url,
capi_url: capi_url.into(),
http: default_http_client(),
}
}
pub fn with_http_client(mut self, http: reqwest::Client) -> Self {
self.http = http;
self
}
pub fn device(&self) -> DeviceFlowClient {
DeviceFlowClient::new(self.issuer.clone(), self.client_id.clone())
}
pub fn loopback(&self) -> LoopbackFlowClient {
LoopbackFlowClient::new(self.issuer.clone(), self.client_id.clone())
}
pub async fn refresh(&self, refresh_token: &str) -> Result<TokenSet, AuthError> {
super::oidc::refresh(&self.issuer, &self.client_id, refresh_token).await
}
pub async fn revoke_refresh_token(&self, refresh_token: &str) -> Result<(), AuthError> {
super::oidc::revoke_refresh_token(&self.issuer, &self.client_id, refresh_token).await
}
pub async fn list_accounts<F>(
&self,
tokens: &TokenSet,
mut mfa_prompt: F,
) -> Result<AccountListing, AuthError>
where
F: FnMut(&[String], u32) -> Result<Option<String>, AuthError>,
{
let mut sm = SmApiClient::with_http_client(
self.sm_api_url.clone(),
self.http.clone(),
LoginFlow::Switch,
);
match sm.login(&tokens.access_token, None).await {
Ok(()) => {}
Err(AuthError::MfaRequired { factors }) => {
self.satisfy_mfa(&mut sm, tokens, &factors, &mut mfa_prompt)
.await?
}
Err(e) => return Err(e),
}
let user = sm.fetch_current_user().await?;
let current = user
.current_account_id
.as_deref()
.and_then(|s| s.parse::<u64>().ok());
Ok(AccountListing {
email: user.email,
accounts: login_accounts(&sm.fetch_accounts().await?),
session_account: current,
})
}
pub async fn revoke_capi_key(
&self,
tokens: &TokenSet,
account_id: Option<u64>,
key_name: &str,
) -> Result<bool, AuthError> {
let mut sm = SmApiClient::with_http_client(
self.sm_api_url.clone(),
self.http.clone(),
LoginFlow::Switch,
);
sm.login(&tokens.access_token, None).await?;
if let Some(account_id) = account_id {
set_current_account_verified(&sm, account_id).await?;
}
let entries = sm.fetch_capi_key_entries().await?;
let Some((id, _)) = entries.iter().find(|(_, name)| name == key_name) else {
tracing::warn!(
"key {key_name} is not on {}; it holds: {}",
match account_id {
Some(account) => format!("account {account}"),
None => "the account this sign-in defaults to".to_string(),
},
entries
.iter()
.map(|(_, name)| name.as_str())
.collect::<Vec<_>>()
.join(", ")
);
return Ok(false);
};
sm.delete_capi_key(*id).await?;
Ok(true)
}
pub async fn complete_login(
&self,
tokens: &TokenSet,
key_name: &str,
flow: LoginFlow,
account: AccountChoice,
) -> Result<MintedCredentials, AuthError> {
self.complete_login_with_mfa(tokens, key_name, flow, account, None, |_, _| Ok(None))
.await
.map(|(creds, _)| creds)
}
pub async fn complete_login_with_mfa<F>(
&self,
tokens: &TokenSet,
key_name: &str,
flow: LoginFlow,
account: AccountChoice,
superseded: Option<SupersededKey>,
mut mfa_prompt: F,
) -> Result<(MintedCredentials, Option<SupersededRevoker>), AuthError>
where
F: FnMut(&[String], u32) -> Result<Option<String>, AuthError>,
{
let mut sm =
SmApiClient::with_http_client(self.sm_api_url.clone(), self.http.clone(), flow);
match sm.login(&tokens.access_token, None).await {
Ok(()) => {}
Err(AuthError::MfaRequired { factors }) => {
self.satisfy_mfa(&mut sm, tokens, &factors, &mut mfa_prompt)
.await?
}
Err(e) => return Err(e),
}
let mut user = sm.fetch_current_user().await?;
let want = match account {
AccountChoice::Current => None,
AccountChoice::Id(id) => Some(id),
AccountChoice::Prompt(choose) => {
let accounts = login_accounts(&sm.fetch_accounts().await?);
let current = user
.current_account_id
.as_deref()
.and_then(|s| s.parse::<u64>().ok());
Some(choose(&accounts, current)?)
}
};
if let Some(want) = want {
user = self.switch_account(&sm, user, want).await?;
}
let chosen = resolve_account(
sm.fetch_accounts().await?,
user.current_account_id.as_deref(),
)?
.id;
let capi_newly_enabled = sm.ensure_capi_enabled().await?;
let accounts = sm.fetch_accounts().await?;
let all_accounts = login_accounts(&accounts);
let account = accounts
.into_iter()
.find(|a| a.id == chosen)
.ok_or_else(|| {
AuthError::Protocol(format!(
"account {chosen} was no longer listed after enabling programmatic access"
))
})?;
let account_name = account.name.clone();
let account_id = Some(account.id);
let api_key = account.api_access_key.ok_or_else(|| {
AuthError::Protocol("account has no CAPI access key after enabling CAPI".into())
})?;
let minted = sm.mint_capi_key(key_name, user.user_account()?).await?;
let redisctl_key_count = sm
.fetch_capi_keys()
.await
.map(|keys| keys.iter().filter(|n| n.starts_with("redisctl-")).count())
.unwrap_or(0);
Ok((
MintedCredentials {
account_id,
email: user.email,
api_key,
api_secret: minted.secret_key,
api_url: self.capi_url.clone(),
refresh_token: tokens.refresh_token.clone(),
capi_key_name: minted.name,
redisctl_key_count,
account_name,
capi_newly_enabled,
superseded_revoked: None,
superseded_key_name: None,
accounts: all_accounts,
},
superseded.map(|previous| SupersededRevoker {
sm,
previous,
on: account_id,
}),
))
}
async fn switch_account(
&self,
sm: &SmApiClient,
user: SmUser,
want: u64,
) -> Result<SmUser, AuthError> {
if user.current_account_id.as_deref() == Some(want.to_string().as_str()) {
return Ok(user);
}
let accounts = sm.fetch_accounts().await?;
if !accounts.iter().any(|a| a.id == want) {
if accounts.is_empty() {
return Err(AuthError::Protocol(
"this login is not associated with any Redis Cloud account, so there is \
nothing to switch to"
.into(),
));
}
return Err(AuthError::UnknownAccount {
requested: want,
available: account_labels(&accounts),
});
}
set_current_account_verified(sm, want).await
}
async fn satisfy_mfa<F>(
&self,
sm: &mut SmApiClient,
tokens: &TokenSet,
factors: &[String],
mfa_prompt: &mut F,
) -> Result<(), AuthError>
where
F: FnMut(&[String], u32) -> Result<Option<String>, AuthError>,
{
for attempt in 1..=MFA_MAX_ATTEMPTS {
let Some(code) = mfa_prompt(factors, attempt)? else {
return Err(AuthError::MfaRequired {
factors: factors.to_vec(),
});
};
match sm.complete_mfa(&tokens.access_token, None, &code).await {
Ok(()) => return Ok(()),
Err(AuthError::MfaInvalidCode) if attempt < MFA_MAX_ATTEMPTS => continue,
Err(e) => return Err(e),
}
}
Err(AuthError::MfaInvalidCode)
}
}
pub const MFA_MAX_ATTEMPTS: u32 = 3;
fn login_accounts(accounts: &[SmAccount]) -> Vec<LoginAccount> {
let mut out: Vec<LoginAccount> = accounts
.iter()
.map(|a| LoginAccount {
id: a.id,
name: a.name.clone(),
})
.collect();
out.sort_by_key(|a| a.id);
out
}
#[derive(Debug)]
pub struct AccountListing {
pub email: Option<String>,
pub accounts: Vec<LoginAccount>,
pub session_account: Option<u64>,
}
async fn set_current_account_verified(sm: &SmApiClient, want: u64) -> Result<SmUser, AuthError> {
sm.set_current_account(want).await?;
let user = sm.fetch_current_user().await?;
if user.current_account_id.as_deref() != Some(want.to_string().as_str()) {
return Err(AuthError::Protocol(format!(
"asked Redis Cloud to switch to account {want} but the session still reports {}",
user.current_account_id.as_deref().unwrap_or("none")
)));
}
Ok(user)
}
pub struct SupersededRevoker {
sm: SmApiClient,
previous: SupersededKey,
on: Option<u64>,
}
impl SupersededRevoker {
pub fn key_name(&self) -> &str {
&self.previous.key_name
}
pub fn account_id(&self) -> u64 {
self.previous.account_id
}
pub async fn revoke(self) -> bool {
revoke_superseded(&self.sm, &self.previous, self.on).await
}
}
async fn revoke_superseded(sm: &SmApiClient, previous: &SupersededKey, on: Option<u64>) -> bool {
let account = previous.account_id;
let moved = on != Some(account);
if moved && let Err(e) = set_current_account_verified(sm, account).await {
tracing::warn!(
"cannot reach account {account} to revoke key {}: {e}",
previous.key_name
);
return false;
}
delete_named_key(sm, account, &previous.key_name).await
}
async fn delete_named_key(sm: &SmApiClient, account: u64, key: &str) -> bool {
let entries = match sm.fetch_capi_key_entries().await {
Ok(entries) => entries,
Err(e) => {
tracing::warn!("cannot list keys on account {account} to revoke {key}: {e}");
return false;
}
};
let Some((id, _)) = entries.iter().find(|(_, name)| name == key) else {
tracing::warn!(
"key {key} is not on account {account}; it holds: {}",
entries
.iter()
.map(|(_, name)| name.as_str())
.collect::<Vec<_>>()
.join(", ")
);
return false;
};
match sm.delete_capi_key(*id).await {
Ok(()) => true,
Err(e) => {
tracing::warn!("could not delete key {key} ({id}) on account {account}: {e}");
false
}
}
}
fn resolve_account(
mut accounts: Vec<SmAccount>,
current_account_id: Option<&str>,
) -> Result<SmAccount, AuthError> {
let target = current_account_id.and_then(|s| s.parse::<u64>().ok());
if let Some(at) = target.and_then(|id| accounts.iter().position(|a| a.id == id)) {
return Ok(accounts.swap_remove(at));
}
if accounts.is_empty() {
return Err(AuthError::Protocol(
"no accounts associated with this login".into(),
));
}
if accounts.len() == 1 && current_account_id.is_none() {
return Ok(accounts.swap_remove(0));
}
Err(AuthError::AccountRequired(format!(
"this sign-in does not report which Redis Cloud account is current{}, and a key minted \
on a guess could belong to the wrong one. Re-run with `--account <id>`; you belong to: \
{}",
match current_account_id {
Some(id) => format!(" (it names {id}, which is not one of yours)"),
None => String::new(),
},
account_labels(&accounts)
)))
}
fn account_labels(accounts: &[SmAccount]) -> String {
login_accounts(accounts)
.iter()
.map(LoginAccount::label)
.collect::<Vec<_>>()
.join(", ")
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn account(id: u64) -> SmAccount {
serde_json::from_value(serde_json::json!({
"id": id, "api_access_key": format!("KEY-{id}")
}))
.unwrap()
}
#[test]
fn login_account_labels_named_and_unnamed_accounts() {
assert_eq!(
LoginAccount {
id: 316941,
name: Some("Acme".to_string()),
}
.label(),
"Acme (#316941)"
);
assert_eq!(
LoginAccount {
id: 316941,
name: None,
}
.label(),
"#316941"
);
}
#[test]
fn resolve_account_prefers_current_account_id() {
let accts = vec![account(111), account(222), account(333)];
let chosen = resolve_account(accts, Some("222")).unwrap();
assert_eq!(chosen.id, 222);
}
#[test]
fn resolve_account_refuses_to_guess_between_several() {
for current in [None, Some("999")] {
let err = resolve_account(vec![account(111), account(222)], current).unwrap_err();
let AuthError::AccountRequired(message) = err else {
panic!("{current:?} gave {err:?}");
};
assert!(message.contains("#111"), "{message}");
assert!(message.contains("#222"), "{message}");
assert!(message.contains("--account"), "{message}");
}
let err = resolve_account(vec![account(111), account(222)], Some("999")).unwrap_err();
assert!(err.to_string().contains("999"), "{err}");
}
#[test]
fn resolve_account_takes_the_only_account() {
assert_eq!(resolve_account(vec![account(111)], None).unwrap().id, 111);
assert_eq!(
resolve_account(vec![account(111)], Some("111")).unwrap().id,
111
);
}
#[test]
fn resolve_account_refuses_a_lone_account_the_session_disowns() {
for current in [Some("999"), Some("not-a-number")] {
let err = resolve_account(vec![account(111)], current).unwrap_err();
let AuthError::AccountRequired(message) = err else {
panic!("{current:?} gave {err:?}");
};
assert!(message.contains("#111"), "{message}");
assert!(message.contains("--account"), "{message}");
}
}
#[test]
fn resolve_account_reports_an_empty_list_as_protocol() {
assert!(matches!(
resolve_account(vec![], Some("1")),
Err(AuthError::Protocol(_))
));
}
#[test]
fn debug_redacts_secrets() {
let creds = MintedCredentials {
account_id: Some(42),
email: Some("u@example.com".to_string()),
api_key: "AKEY-visible-should-not-appear".to_string(),
api_secret: "SECRET-should-not-appear".to_string(),
api_url: "https://api.example.com/v1".to_string(),
refresh_token: Some("RT-should-not-appear".to_string()),
capi_key_name: "redisctl-cli-1".to_string(),
redisctl_key_count: 3,
account_name: Some("Acme".to_string()),
capi_newly_enabled: false,
superseded_revoked: None,
superseded_key_name: None,
accounts: vec![
LoginAccount {
id: 316941,
name: Some("Acme".to_string()),
},
LoginAccount {
id: 481022,
name: Some("Contoso".to_string()),
},
],
};
let dbg = format!("{creds:?}");
assert!(dbg.contains("<redacted>"));
assert!(!dbg.contains("AKEY-visible-should-not-appear"));
assert!(!dbg.contains("SECRET-should-not-appear"));
assert!(!dbg.contains("RT-should-not-appear"));
assert!(dbg.contains("u@example.com"));
assert!(dbg.contains("redisctl-cli-1"));
}
#[tokio::test]
async fn complete_login_runs_the_full_exchange() {
let server = MockServer::start().await;
let mount = |m: &str, p: &'static str, body: serde_json::Value, cookie: bool| {
let mut tmpl = ResponseTemplate::new(200).set_body_json(body);
if cookie {
tmpl = tmpl.append_header("Set-Cookie", "JSESSIONID=SID; Path=/");
}
Mock::given(method(m)).and(path(p)).respond_with(tmpl)
};
mount("POST", "/login", serde_json::json!({}), true)
.mount(&server)
.await;
mount(
"GET",
"/csrf",
serde_json::json!({"csrfToken": {"csrf_token": "C"}}),
false,
)
.mount(&server)
.await;
mount(
"GET",
"/users/me",
serde_json::json!({"id": "114429", "current_account_id": "112117", "email": "u@e.com"}),
false,
)
.mount(&server)
.await;
mount(
"POST",
"/accounts/cloud-api/cloudApiAccessKey",
serde_json::json!({"cloudApiAccessKey": {"accessKey": "ACCT"}}),
false,
)
.mount(&server)
.await;
mount(
"GET",
"/accounts",
serde_json::json!({"accounts": [{"id": 112117, "api_access_key": "ACCT-KEY"}]}),
false,
)
.mount(&server)
.await;
mount(
"POST",
"/accounts/cloud-api/cloudApiKeys",
serde_json::json!({"name": "redisctl-test", "secret_key": "SECRET"}),
false,
)
.mount(&server)
.await;
let auth = CloudAuthenticator::new(
Url::parse("https://issuer.example/oauth2/default").unwrap(),
"cid",
Url::parse(&server.uri()).unwrap(),
"https://capi.example/v1",
);
let tokens = TokenSet {
access_token: "AT".into(),
refresh_token: Some("RT".into()),
expires_in: 3600,
};
let creds = auth
.complete_login(
&tokens,
"redisctl-test",
LoginFlow::Loopback,
AccountChoice::Current,
)
.await
.unwrap();
assert_eq!(creds.api_key, "ACCT-KEY");
assert_eq!(creds.api_secret, "SECRET");
assert_eq!(creds.api_url, "https://capi.example/v1");
assert_eq!(creds.account_id, Some(112117));
assert_eq!(creds.email.as_deref(), Some("u@e.com"));
assert_eq!(creds.refresh_token.as_deref(), Some("RT"));
assert_eq!(creds.capi_key_name, "redisctl-test");
let dbg = format!("{creds:?}");
assert!(!dbg.contains("SECRET") && !dbg.contains("ACCT-KEY") && !dbg.contains("RT"));
}
fn tokens() -> TokenSet {
TokenSet {
access_token: "AT".to_string(),
refresh_token: None,
expires_in: 3600,
}
}
fn authenticator(server: &MockServer) -> CloudAuthenticator {
CloudAuthenticator::new(
Url::parse("https://issuer.example/oauth2/default").unwrap(),
"client",
Url::parse(&server.uri()).unwrap(),
"https://capi.example/v1",
)
}
async fn common_login_mocks(server: &MockServer) {
Mock::given(method("POST"))
.and(path("/login"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({}))
.append_header("Set-Cookie", "JSESSIONID=SID; Path=/"),
)
.mount(server)
.await;
Mock::given(method("GET"))
.and(path("/csrf"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"csrfToken": {"csrf_token": "C"}})),
)
.mount(server)
.await;
Mock::given(method("POST"))
.and(path("/accounts/cloud-api/cloudApiAccessKey"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"cloudApiAccessKey": {"accessKey": "ACCT"}})),
)
.mount(server)
.await;
Mock::given(method("POST"))
.and(path("/accounts/cloud-api/cloudApiKeys"))
.respond_with(ResponseTemplate::new(200).set_body_json(
serde_json::json!({"name": "redisctl-test", "secret_key": "SECRET"}),
))
.mount(server)
.await;
}
#[tokio::test]
async fn complete_login_switches_before_minting() {
let server = MockServer::start().await;
let switched = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
common_login_mocks(&server).await;
let flag = switched.clone();
Mock::given(method("GET"))
.and(path("/users/me"))
.respond_with(move |_: &wiremock::Request| {
let id = if flag.load(std::sync::atomic::Ordering::SeqCst) {
"222"
} else {
"111"
};
ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "1", "current_account_id": id, "email": "u@e.com"
}))
})
.mount(&server)
.await;
let flag = switched.clone();
Mock::given(method("POST"))
.and(path("/accounts/setcurrent/222"))
.respond_with(move |_: &wiremock::Request| {
flag.store(true, std::sync::atomic::Ordering::SeqCst);
ResponseTemplate::new(200).set_body_json(serde_json::json!({}))
})
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/accounts"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"accounts": [
{"id": 111, "name": "One", "api_access_key": "KEY-111"},
{"id": 222, "name": "Two", "api_access_key": "KEY-222"}
]
})))
.mount(&server)
.await;
let creds = authenticator(&server)
.complete_login(
&tokens(),
"redisctl-test",
LoginFlow::Loopback,
AccountChoice::Id(222),
)
.await
.unwrap();
assert_eq!(creds.account_id, Some(222));
assert_eq!(creds.account_name.as_deref(), Some("Two"));
assert_eq!(creds.api_key, "KEY-222");
assert_eq!(creds.account_count(), 2);
}
#[tokio::test]
async fn complete_login_mints_what_the_picker_chose() {
let server = MockServer::start().await;
let switched = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
common_login_mocks(&server).await;
let flag = switched.clone();
Mock::given(method("GET"))
.and(path("/users/me"))
.respond_with(move |_: &wiremock::Request| {
let id = if flag.load(std::sync::atomic::Ordering::SeqCst) {
"111"
} else {
"222"
};
ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "1", "current_account_id": id, "email": "u@e.com"
}))
})
.mount(&server)
.await;
let flag = switched.clone();
Mock::given(method("POST"))
.and(path("/accounts/setcurrent/111"))
.respond_with(move |_: &wiremock::Request| {
flag.store(true, std::sync::atomic::Ordering::SeqCst);
ResponseTemplate::new(200).set_body_json(serde_json::json!({}))
})
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/accounts"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"accounts": [
{"id": 222, "name": "Two", "api_access_key": "KEY-222"},
{"id": 111, "name": "One", "api_access_key": "KEY-111"}
]
})))
.mount(&server)
.await;
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let seen_current = std::sync::Arc::new(std::sync::Mutex::new(None));
let (s2, c2) = (seen.clone(), seen_current.clone());
let creds = authenticator(&server)
.complete_login(
&tokens(),
"k",
LoginFlow::Switch,
AccountChoice::Prompt(Box::new(move |accounts, current| {
*s2.lock().unwrap() = accounts.iter().map(|a| a.id).collect::<Vec<_>>();
*c2.lock().unwrap() = current;
Ok(111)
})),
)
.await
.unwrap();
assert_eq!(*seen.lock().unwrap(), vec![111, 222]);
assert_eq!(*seen_current.lock().unwrap(), Some(222));
assert_eq!(creds.account_id, Some(111));
assert_eq!(creds.api_key, "KEY-111");
assert_eq!(creds.account_label(), "One (#111)");
}
#[tokio::test]
async fn complete_login_propagates_a_declined_picker() {
let server = MockServer::start().await;
common_login_mocks(&server).await;
Mock::given(method("GET"))
.and(path("/users/me"))
.respond_with(ResponseTemplate::new(200).set_body_json(
serde_json::json!({"id": "1", "current_account_id": "111", "email": "u@e.com"}),
))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/accounts"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"accounts": [{"id": 111, "name": "One", "api_access_key": "KEY-111"}]
})))
.mount(&server)
.await;
let err = authenticator(&server)
.complete_login(
&tokens(),
"k",
LoginFlow::Switch,
AccountChoice::Prompt(Box::new(|_, _| {
Err(AuthError::AccountRequired("declined".into()))
})),
)
.await
.unwrap_err();
assert!(matches!(err, AuthError::AccountRequired(_)), "got {err:?}");
}
#[tokio::test]
async fn list_accounts_reads_without_minting_or_switching() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/login"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({}))
.append_header("Set-Cookie", "JSESSIONID=SID; Path=/"),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/csrf"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"csrfToken": {"csrf_token": "C"}})),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/users/me"))
.respond_with(ResponseTemplate::new(200).set_body_json(
serde_json::json!({"id": "1", "current_account_id": "222", "email": "u@e.com"}),
))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/accounts"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"accounts": [
{"id": 222, "name": "Two"},
{"id": 111, "name": "One"}
]
})))
.mount(&server)
.await;
let listing = authenticator(&server)
.list_accounts(&tokens(), |_, _| Ok(None))
.await
.unwrap();
assert_eq!(
listing.accounts.iter().map(|a| a.id).collect::<Vec<_>>(),
vec![111, 222]
);
assert_eq!(listing.session_account, Some(222));
assert_eq!(listing.email.as_deref(), Some("u@e.com"));
}
#[tokio::test]
async fn complete_login_refuses_an_account_the_user_is_not_in() {
let server = MockServer::start().await;
common_login_mocks(&server).await;
Mock::given(method("GET"))
.and(path("/users/me"))
.respond_with(ResponseTemplate::new(200).set_body_json(
serde_json::json!({"id": "1", "current_account_id": "111", "email": "u@e.com"}),
))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/accounts"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"accounts": [{"id": 111, "name": "One", "api_access_key": "KEY-111"}]
})))
.mount(&server)
.await;
match authenticator(&server)
.complete_login(&tokens(), "k", LoginFlow::Loopback, AccountChoice::Id(999))
.await
{
Err(AuthError::UnknownAccount {
requested,
available,
}) => {
assert_eq!(requested, 999);
assert_eq!(available, "One (#111)");
}
other => panic!("expected UnknownAccount, got {other:?}"),
}
}
#[tokio::test]
async fn complete_login_mints_nothing_when_the_account_is_ambiguous() {
async fn attempt(current: Option<&str>) -> (AuthError, usize) {
let server = MockServer::start().await;
common_login_mocks(&server).await;
let mut me = serde_json::json!({"id": "1", "email": "u@e.com"});
if let Some(current) = current {
me["current_account_id"] = serde_json::json!(current);
}
Mock::given(method("GET"))
.and(path("/users/me"))
.respond_with(ResponseTemplate::new(200).set_body_json(me))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/accounts"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"accounts": [
{"id": 111, "name": "One", "api_access_key": "KEY-111"},
{"id": 222, "name": "Two", "api_access_key": "KEY-222"}
]
})))
.mount(&server)
.await;
let err = authenticator(&server)
.complete_login(&tokens(), "k", LoginFlow::Loopback, AccountChoice::Current)
.await
.expect_err("an ambiguous account must not complete");
let requests = server.received_requests().await.unwrap_or_default();
let hits = |path: &str| requests.iter().filter(|r| r.url.path() == path).count();
assert_eq!(
hits("/accounts/cloud-api/cloudApiAccessKey"),
0,
"{current:?} enabled programmatic access before refusing"
);
(err, hits("/accounts/cloud-api/cloudApiKeys"))
}
for current in [None, Some("999")] {
let (err, mints) = attempt(current).await;
assert!(
matches!(err, AuthError::AccountRequired(_)),
"{current:?} gave {err:?}"
);
assert_eq!(mints, 0, "{current:?} minted a key anyway");
}
}
#[tokio::test]
async fn complete_login_fails_when_the_switch_does_not_take() {
let server = MockServer::start().await;
common_login_mocks(&server).await;
Mock::given(method("GET"))
.and(path("/users/me"))
.respond_with(ResponseTemplate::new(200).set_body_json(
serde_json::json!({"id": "1", "current_account_id": "111", "email": "u@e.com"}),
))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/accounts/setcurrent/222"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/accounts"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"accounts": [
{"id": 111, "name": "One", "api_access_key": "KEY-111"},
{"id": 222, "name": "Two", "api_access_key": "KEY-222"}
]
})))
.mount(&server)
.await;
let err = authenticator(&server)
.complete_login(&tokens(), "k", LoginFlow::Loopback, AccountChoice::Id(222))
.await
.unwrap_err();
assert!(
matches!(err, AuthError::Protocol(ref m) if m.contains("still reports")),
"expected a switch-verification failure, got {err:?}"
);
}
#[tokio::test]
async fn complete_login_skips_the_switch_when_already_current() {
let server = MockServer::start().await;
common_login_mocks(&server).await;
Mock::given(method("GET"))
.and(path("/users/me"))
.respond_with(ResponseTemplate::new(200).set_body_json(
serde_json::json!({"id": "1", "current_account_id": "111", "email": "u@e.com"}),
))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/accounts"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"accounts": [{"id": 111, "name": "One", "api_access_key": "KEY-111"}]
})))
.mount(&server)
.await;
let creds = authenticator(&server)
.complete_login(&tokens(), "k", LoginFlow::Loopback, AccountChoice::Id(111))
.await
.unwrap();
assert_eq!(creds.account_id, Some(111));
}
#[tokio::test]
async fn the_mint_defers_revocation_to_the_caller() {
let server = MockServer::start().await;
common_login_mocks(&server).await;
Mock::given(method("GET"))
.and(path("/users/me"))
.respond_with(ResponseTemplate::new(200).set_body_json(
serde_json::json!({"id": "1", "current_account_id": "111", "email": "u@e.com"}),
))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/accounts"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"accounts": [{"id": 111, "name": "One", "api_access_key": "KEY-111"}]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/accounts/cloud-api/cloudApiKeys"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"cloudApiKeys": [{"id": 7, "name": "redisctl-cli-1"}]
})))
.mount(&server)
.await;
let (creds, revoker) = authenticator(&server)
.complete_login_with_mfa(
&tokens(),
"redisctl-cli-2",
LoginFlow::Loopback,
AccountChoice::Current,
Some(SupersededKey {
account_id: 111,
key_name: "redisctl-cli-1".to_string(),
}),
|_, _| Ok(None),
)
.await
.unwrap();
assert!(!creds.api_secret.is_empty());
assert_eq!(creds.superseded_revoked, None);
let revoker = revoker.expect("a superseded key was given, so a revoker comes back");
assert_eq!(revoker.key_name(), "redisctl-cli-1");
Mock::given(method("POST"))
.and(path("/accounts/setcurrent/111"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.mount(&server)
.await;
Mock::given(method("DELETE"))
.and(path("/accounts/cloud-api/cloudApiKeys/7"))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&server)
.await;
assert!(revoker.revoke().await);
}
#[tokio::test]
async fn revoking_across_accounts_reaches_the_other_account() {
let server = MockServer::start().await;
let cell = mock_session(&server, 111, vec![111, 222]).await;
mock_keys(&server, cell, vec![(111, 9, "redisctl-cli-1")]).await;
Mock::given(method("GET"))
.and(path("/accounts"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"accounts": [
{"id": 111, "name": "One", "api_access_key": "KEY-111"},
{"id": 222, "name": "Two", "api_access_key": "KEY-222"}
]
})))
.mount(&server)
.await;
Mock::given(method("DELETE"))
.and(path("/accounts/cloud-api/cloudApiKeys/9"))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.named("delete the superseded key on the account that holds it")
.mount(&server)
.await;
let (creds, revoker) = authenticator(&server)
.complete_login_with_mfa(
&tokens(),
"redisctl-cli-2",
LoginFlow::Switch,
AccountChoice::Id(222),
Some(SupersededKey {
account_id: 111,
key_name: "redisctl-cli-1".to_string(),
}),
|_, _| Ok(None),
)
.await
.unwrap();
assert_eq!(creds.account_id, Some(222));
assert!(revoker.unwrap().revoke().await);
}
type AccountCell = std::sync::Arc<std::sync::atomic::AtomicU64>;
fn account_of(cell: &AccountCell) -> u64 {
cell.load(std::sync::atomic::Ordering::SeqCst)
}
async fn mock_session(server: &MockServer, starts_on: u64, honoured: Vec<u64>) -> AccountCell {
let current: AccountCell =
std::sync::Arc::new(std::sync::atomic::AtomicU64::new(starts_on));
common_login_mocks(server).await;
let cell = current.clone();
Mock::given(method("GET"))
.and(path("/users/me"))
.respond_with(move |_: &wiremock::Request| {
ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "1", "current_account_id": account_of(&cell).to_string(),
"email": "u@e.com"
}))
})
.mount(server)
.await;
let cell = current.clone();
Mock::given(method("POST"))
.and(wiremock::matchers::path_regex(
r"^/accounts/setcurrent/\d+$",
))
.respond_with(move |req: &wiremock::Request| {
if let Some(asked) = req
.url
.path()
.rsplit('/')
.next()
.and_then(|s| s.parse::<u64>().ok())
&& honoured.contains(&asked)
{
cell.store(asked, std::sync::atomic::Ordering::SeqCst);
}
ResponseTemplate::new(200).set_body_json(serde_json::json!({}))
})
.mount(server)
.await;
current
}
async fn mock_keys(server: &MockServer, cell: AccountCell, per_account: Vec<(u64, u64, &str)>) {
let owned: Vec<(u64, u64, String)> = per_account
.into_iter()
.map(|(account, id, name)| (account, id, name.to_string()))
.collect();
Mock::given(method("GET"))
.and(path("/accounts/cloud-api/cloudApiKeys"))
.respond_with(move |_: &wiremock::Request| {
let here = account_of(&cell);
let keys: Vec<_> = owned
.iter()
.filter(|(account, _, _)| *account == here)
.map(|(_, id, name)| serde_json::json!({"id": id, "name": name}))
.collect();
ResponseTemplate::new(200).set_body_json(serde_json::json!({"cloudApiKeys": keys}))
})
.mount(server)
.await;
}
#[tokio::test]
async fn revoking_by_name_points_the_session_at_the_key_account() {
let server = MockServer::start().await;
let cell = mock_session(&server, 111, vec![222]).await;
mock_keys(
&server,
cell,
vec![
(111, 9, "a-key-on-the-default-account"),
(222, 5, "redisctl-cli-on-222"),
],
)
.await;
Mock::given(method("DELETE"))
.and(path("/accounts/cloud-api/cloudApiKeys/5"))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&server)
.await;
assert!(
authenticator(&server)
.revoke_capi_key(&tokens(), Some(222), "redisctl-cli-on-222")
.await
.unwrap(),
"the key on the recorded account should be found and deleted"
);
}
#[tokio::test]
async fn revoking_by_name_refuses_when_the_switch_does_not_take() {
let server = MockServer::start().await;
let cell = mock_session(&server, 111, vec![]).await;
mock_keys(&server, cell, vec![(111, 9, "redisctl-cli-shared-name")]).await;
Mock::given(method("DELETE"))
.and(wiremock::matchers::path_regex(
r"^/accounts/cloud-api/cloudApiKeys/\d+$",
))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.named("nothing may be deleted from an account we did not reach")
.mount(&server)
.await;
let err = authenticator(&server)
.revoke_capi_key(&tokens(), Some(222), "redisctl-cli-shared-name")
.await
.expect_err("an unverified switch must not be treated as success");
assert!(
format!("{err}").contains("still reports"),
"the error should say the session did not move, got: {err}"
);
}
#[tokio::test]
async fn revoking_across_accounts_refuses_when_the_switch_back_does_not_take() {
let server = MockServer::start().await;
let cell = mock_session(&server, 111, vec![222]).await;
mock_keys(
&server,
cell,
vec![(111, 9, "redisctl-cli-1"), (222, 7, "redisctl-cli-1")],
)
.await;
Mock::given(method("GET"))
.and(path("/accounts"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"accounts": [
{"id": 111, "name": "One", "api_access_key": "KEY-111"},
{"id": 222, "name": "Two", "api_access_key": "KEY-222"}
]
})))
.mount(&server)
.await;
Mock::given(method("DELETE"))
.and(wiremock::matchers::path_regex(
r"^/accounts/cloud-api/cloudApiKeys/\d+$",
))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.named("nothing may be deleted from an account we did not reach")
.mount(&server)
.await;
let (_, revoker) = authenticator(&server)
.complete_login_with_mfa(
&tokens(),
"redisctl-cli-2",
LoginFlow::Switch,
AccountChoice::Id(222),
Some(SupersededKey {
account_id: 111,
key_name: "redisctl-cli-1".to_string(),
}),
|_, _| Ok(None),
)
.await
.unwrap();
assert!(
!revoker.unwrap().revoke().await,
"a switch that did not land must not be reported as a revocation"
);
}
#[tokio::test]
async fn revocation_targets_the_recorded_key_alone() {
let server = MockServer::start().await;
let cell = mock_session(&server, 111, vec![]).await;
mock_keys(
&server,
cell,
vec![
(111, 1, "redisctl-cli-0"),
(111, 2, "redisctl-cli-11"),
(111, 3, "redisctl-cli-1"),
(111, 4, "someone-elses-key"),
],
)
.await;
Mock::given(method("DELETE"))
.and(path("/accounts/cloud-api/cloudApiKeys/3"))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.named("delete the recorded key, by exact name")
.mount(&server)
.await;
assert!(
authenticator(&server)
.revoke_capi_key(&tokens(), None, "redisctl-cli-1")
.await
.unwrap()
);
}
#[tokio::test]
async fn revocation_deletes_nothing_when_the_recorded_key_is_gone() {
let server = MockServer::start().await;
let cell = mock_session(&server, 111, vec![]).await;
mock_keys(&server, cell, vec![(111, 9, "a-key-someone-else-minted")]).await;
Mock::given(method("DELETE"))
.and(wiremock::matchers::path_regex(
r"^/accounts/cloud-api/cloudApiKeys/\d+$",
))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.named("nothing may be deleted when the recorded key is absent")
.mount(&server)
.await;
assert!(
!authenticator(&server)
.revoke_capi_key(&tokens(), None, "redisctl-cli-1")
.await
.unwrap(),
"a key that is not there cannot be reported as revoked"
);
}
#[tokio::test]
async fn revoking_by_name_without_an_account_does_not_switch() {
let server = MockServer::start().await;
common_login_mocks(&server).await;
Mock::given(method("GET"))
.and(path("/accounts/cloud-api/cloudApiKeys"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"cloudApiKeys": [{"id": 3, "name": "redisctl-cli-somewhere"}]
})))
.mount(&server)
.await;
Mock::given(method("DELETE"))
.and(path("/accounts/cloud-api/cloudApiKeys/3"))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&server)
.await;
assert!(
authenticator(&server)
.revoke_capi_key(&tokens(), None, "redisctl-cli-somewhere")
.await
.unwrap()
);
assert!(
!server
.received_requests()
.await
.unwrap()
.iter()
.any(|r| r.url.path().starts_with("/accounts/setcurrent/")),
"no account was recorded, so there is nothing to switch to"
);
}
#[tokio::test]
async fn complete_login_errors_when_login_rejected() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/login"))
.respond_with(ResponseTemplate::new(401).append_header("Set-Cookie", "JSESSIONID=S"))
.mount(&server)
.await;
let auth = CloudAuthenticator::new(
Url::parse("https://issuer.example/oauth2/default").unwrap(),
"cid",
Url::parse(&server.uri()).unwrap(),
"https://capi.example/v1",
);
let tokens = TokenSet {
access_token: "AT".into(),
refresh_token: None,
expires_in: 3600,
};
assert!(matches!(
auth.complete_login(&tokens, "k", LoginFlow::Loopback, AccountChoice::Current)
.await,
Err(AuthError::Protocol(_))
));
}
}