Skip to main content

redisctl_core/auth/
authenticator.rs

1//! Orchestrates a full `cloud auth login`: build the OIDC flow clients, and after a flow
2//! yields tokens, run the SM exchange and mint a CAPI key.
3//!
4//! Returns [`MintedCredentials`] for the caller to persist (see
5//! `config::Config::apply_cloud_login`). Persistence lives in the config layer so this stays
6//! free of file/keyring I/O and easy to test. The flow itself (device polling with progress,
7//! or loopback with a browser) is driven by the CLI using [`CloudAuthenticator::device`]
8//! / [`CloudAuthenticator::loopback`].
9
10use url::Url;
11
12use super::sm_api::{LoginFlow, SmAccount, SmApiClient, SmUser};
13use super::{AuthError, DeviceFlowClient, LoopbackFlowClient, TokenSet, default_http_client};
14
15/// Result of a completed login: a Redis Cloud CAPI key pair plus context, ready to persist.
16///
17/// Secret fields are redacted from `Debug`.
18#[derive(Clone)]
19pub struct MintedCredentials {
20    /// Numeric account id, matching the `id` of the matching entry in `accounts`, so the two can
21    /// be compared directly by a caller reading the JSON output.
22    pub account_id: Option<u64>,
23    pub email: Option<String>,
24    /// Account-level CAPI key (`x-api-key`).
25    pub api_key: String,
26    /// Minted user secret (`x-api-secret-key`).
27    pub api_secret: String,
28    /// CAPI base URL to record in the resulting cloud profile.
29    pub api_url: String,
30    /// Okta refresh token (rotating) to persist for silent re-auth, if the IdP issued one.
31    pub refresh_token: Option<String>,
32    /// Name of the minted `redisctl-*` CAPI key (visible/revocable in the console).
33    pub capi_key_name: String,
34    /// How many `redisctl-*` CAPI keys the account has after this mint (best-effort; 0 if the
35    /// listing failed). The CLI warns when this grows, since each login mints a new key (D5).
36    pub redisctl_key_count: usize,
37    /// Name of the account the key was minted for, when the API reports one.
38    pub account_name: Option<String>,
39    /// Whether the key this switch replaced was revoked. `None` when there was none to revoke.
40    pub superseded_revoked: Option<bool>,
41    /// The name of that key, so a failed revocation can say which one is left behind.
42    pub superseded_key_name: Option<String>,
43    /// Whether this login is what switched account-wide programmatic access on. Reported so an
44    /// account-level change is not made silently.
45    pub capi_newly_enabled: bool,
46    /// Every account the signed-in user belongs to. The key is scoped to exactly one of them —
47    /// the session's *current* account — so the CLI can both name the one it used and list the
48    /// alternatives, which are otherwise only discoverable in the console.
49    pub accounts: Vec<LoginAccount>,
50}
51
52/// How the account to mint for is decided.
53///
54/// [`AccountChoice::Prompt`] exists because a picker cannot run before the exchange: listing the
55/// accounts needs a session, and re-logging-in to act on the answer would mean a second sign-in
56/// (and a second MFA challenge). The callback is invoked mid-exchange instead, on the one session.
57/// A key a switch is about to replace, revoked on the same session once its successor exists.
58pub struct SupersededKey {
59    pub account_id: u64,
60    pub key_name: String,
61}
62
63pub enum AccountChoice {
64    /// Whatever account the session is already on.
65    Current,
66    /// A specific account id.
67    Id(u64),
68    /// Decide once the accounts are known. Called with every account the user belongs to and the
69    /// id of the current one, when the API reports it.
70    Prompt(AccountPrompt),
71}
72
73/// Callback for [`AccountChoice::Prompt`]: pick an account id, or fail with the reason.
74pub type AccountPrompt =
75    Box<dyn Fn(&[LoginAccount], Option<u64>) -> Result<u64, AuthError> + Send + Sync>;
76
77impl std::fmt::Debug for AccountChoice {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        match self {
80            Self::Current => f.write_str("Current"),
81            Self::Id(id) => write!(f, "Id({id})"),
82            Self::Prompt(_) => f.write_str("Prompt(..)"),
83        }
84    }
85}
86
87/// One account the signed-in user belongs to, as reported during login.
88#[derive(Debug, Clone)]
89pub struct LoginAccount {
90    pub id: u64,
91    pub name: Option<String>,
92}
93
94impl LoginAccount {
95    /// `Acme (#316941)`, or `#316941` when the API reports no name. Used both in the CLI listing
96    /// and in the `UnknownAccount` message, so the two always read the same.
97    pub fn label(&self) -> String {
98        match &self.name {
99            Some(n) => format!("{} (#{})", n, self.id),
100            None => format!("#{}", self.id),
101        }
102    }
103}
104
105impl MintedCredentials {
106    /// How many accounts the signed-in user belongs to.
107    pub fn account_count(&self) -> usize {
108        self.accounts.len()
109    }
110
111    /// The account the key is for, rendered like the listing (`Acme (#316941)`).
112    ///
113    /// Shared so every place that names the account spells it the same way. `account_id` is always
114    /// one of `accounts` — both come from the same `/accounts` response — so the lookup only
115    /// misses for a hand-built value.
116    pub fn account_label(&self) -> String {
117        self.account_id
118            .and_then(|id| self.accounts.iter().find(|a| a.id == id))
119            .map(LoginAccount::label)
120            .unwrap_or_else(|| "your current account".to_string())
121    }
122}
123
124impl std::fmt::Debug for MintedCredentials {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        f.debug_struct("MintedCredentials")
127            .field("account_id", &self.account_id)
128            .field("email", &self.email)
129            .field("api_key", &"<redacted>")
130            .field("api_secret", &"<redacted>")
131            .field("api_url", &self.api_url)
132            .field(
133                "refresh_token",
134                &self.refresh_token.as_ref().map(|_| "<redacted>"),
135            )
136            .field("capi_key_name", &self.capi_key_name)
137            .field("redisctl_key_count", &self.redisctl_key_count)
138            .field("account_name", &self.account_name)
139            .field("capi_newly_enabled", &self.capi_newly_enabled)
140            .field("superseded_revoked", &self.superseded_revoked)
141            .field("superseded_key_name", &self.superseded_key_name)
142            .field("accounts", &self.accounts)
143            .finish()
144    }
145}
146
147/// Ties the OIDC endpoints (Okta) and the SM API together for one environment.
148#[derive(Clone)]
149pub struct CloudAuthenticator {
150    issuer: Url,
151    client_id: String,
152    sm_api_url: Url,
153    capi_url: String,
154    http: reqwest::Client,
155}
156
157impl CloudAuthenticator {
158    /// Build for one environment. `issuer`/`client_id` drive the Okta flows, `sm_api_url` the
159    /// key-minting exchange, and `capi_url` is recorded in the resulting profile.
160    pub fn new(
161        issuer: Url,
162        client_id: impl Into<String>,
163        sm_api_url: Url,
164        capi_url: impl Into<String>,
165    ) -> Self {
166        Self {
167            issuer,
168            client_id: client_id.into(),
169            sm_api_url,
170            capi_url: capi_url.into(),
171            http: default_http_client(),
172        }
173    }
174
175    /// Use a caller-provided reqwest client (tests / shared client).
176    pub fn with_http_client(mut self, http: reqwest::Client) -> Self {
177        self.http = http;
178        self
179    }
180
181    /// Device-authorization-grant client for headless / agent logins. The flow runs on the
182    /// `oauth2` crate's own HTTP stack, so it does not share this authenticator's SM client.
183    pub fn device(&self) -> DeviceFlowClient {
184        DeviceFlowClient::new(self.issuer.clone(), self.client_id.clone())
185    }
186
187    /// Auth-code + PKCE loopback client for interactive human logins.
188    pub fn loopback(&self) -> LoopbackFlowClient {
189        LoopbackFlowClient::new(self.issuer.clone(), self.client_id.clone())
190    }
191
192    /// Refresh an Okta refresh token for a fresh token set (Okta rotates it). The grant is
193    /// flow-agnostic, so it goes straight through `oidc` rather than a specific flow client.
194    pub async fn refresh(&self, refresh_token: &str) -> Result<TokenSet, AuthError> {
195        super::oidc::refresh(&self.issuer, &self.client_id, refresh_token).await
196    }
197
198    /// Invalidate a stored refresh token at the identity provider.
199    pub async fn revoke_refresh_token(&self, refresh_token: &str) -> Result<(), AuthError> {
200        super::oidc::revoke_refresh_token(&self.issuer, &self.client_id, refresh_token).await
201    }
202
203    /// List the accounts the signed-in user belongs to, minting nothing and switching nothing.
204    pub async fn list_accounts<F>(
205        &self,
206        tokens: &TokenSet,
207        mut mfa_prompt: F,
208    ) -> Result<AccountListing, AuthError>
209    where
210        F: FnMut(&[String], u32) -> Result<Option<String>, AuthError>,
211    {
212        let mut sm = SmApiClient::with_http_client(
213            self.sm_api_url.clone(),
214            self.http.clone(),
215            LoginFlow::Switch,
216        );
217        match sm.login(&tokens.access_token, None).await {
218            Ok(()) => {}
219            Err(AuthError::MfaRequired { factors }) => {
220                self.satisfy_mfa(&mut sm, tokens, &factors, &mut mfa_prompt)
221                    .await?
222            }
223            Err(e) => return Err(e),
224        }
225        let user = sm.fetch_current_user().await?;
226        let current = user
227            .current_account_id
228            .as_deref()
229            .and_then(|s| s.parse::<u64>().ok());
230        Ok(AccountListing {
231            email: user.email,
232            accounts: login_accounts(&sm.fetch_accounts().await?),
233            session_account: current,
234        })
235    }
236
237    /// Revoke a minted CAPI key by name from `account_id`, using a session established from
238    /// `tokens`.
239    ///
240    /// Returns whether a key of that name was found. Both the listing and the delete are scoped
241    /// to the session's account, so the caller has to say which account holds the key: a sign-in
242    /// starts on the user's server-side default, which is not necessarily the one a profile's key
243    /// was minted for. Passing `None` searches wherever the session lands, which is all an older
244    /// profile that recorded no account can do.
245    pub async fn revoke_capi_key(
246        &self,
247        tokens: &TokenSet,
248        account_id: Option<u64>,
249        key_name: &str,
250    ) -> Result<bool, AuthError> {
251        let mut sm = SmApiClient::with_http_client(
252            self.sm_api_url.clone(),
253            self.http.clone(),
254            LoginFlow::Switch,
255        );
256        sm.login(&tokens.access_token, None).await?;
257        // A fresh sign-in starts on the user's server-side default account, and both the listing
258        // and the delete are scoped to the session's account. Without this, revoking a key that
259        // belongs to any other account looks like a key that is already gone.
260        if let Some(account_id) = account_id {
261            set_current_account_verified(&sm, account_id).await?;
262        }
263        let entries = sm.fetch_capi_key_entries().await?;
264        let Some((id, _)) = entries.iter().find(|(_, name)| name == key_name) else {
265            // Same diagnostic the login/switch path logs on a miss: without the names, "not
266            // found" gives the reader nothing to compare against.
267            tracing::warn!(
268                "key {key_name} is not on {}; it holds: {}",
269                match account_id {
270                    Some(account) => format!("account {account}"),
271                    None => "the account this sign-in defaults to".to_string(),
272                },
273                entries
274                    .iter()
275                    .map(|(_, name)| name.as_str())
276                    .collect::<Vec<_>>()
277                    .join(", ")
278            );
279            return Ok(false);
280        };
281        sm.delete_capi_key(*id).await?;
282        Ok(true)
283    }
284
285    /// Given tokens from a flow, run the SM exchange and mint a CAPI key named `key_name`.
286    ///
287    /// Propagates [`AuthError::MfaRequired`] if the account is MFA-protected; use
288    /// [`CloudAuthenticator::complete_login_with_mfa`] to supply codes interactively.
289    pub async fn complete_login(
290        &self,
291        tokens: &TokenSet,
292        key_name: &str,
293        flow: LoginFlow,
294        account: AccountChoice,
295    ) -> Result<MintedCredentials, AuthError> {
296        self.complete_login_with_mfa(tokens, key_name, flow, account, None, |_, _| Ok(None))
297            .await
298            .map(|(creds, _)| creds)
299    }
300
301    /// As [`CloudAuthenticator::complete_login`], but `mfa_prompt` is consulted when SM challenges
302    /// the login for multi-factor authentication.
303    ///
304    /// `mfa_prompt(factors, attempt)` is called with the factor types SM offered (possibly empty)
305    /// and a 1-based attempt number. Return `Some(code)` to submit a TOTP code, or `None` to give
306    /// up — which surfaces the original [`AuthError::MfaRequired`] to the caller, the right
307    /// behaviour when there is no terminal to prompt on.
308    pub async fn complete_login_with_mfa<F>(
309        &self,
310        tokens: &TokenSet,
311        key_name: &str,
312        flow: LoginFlow,
313        account: AccountChoice,
314        superseded: Option<SupersededKey>,
315        mut mfa_prompt: F,
316    ) -> Result<(MintedCredentials, Option<SupersededRevoker>), AuthError>
317    where
318        F: FnMut(&[String], u32) -> Result<Option<String>, AuthError>,
319    {
320        let mut sm =
321            SmApiClient::with_http_client(self.sm_api_url.clone(), self.http.clone(), flow);
322        // Google/GitHub logins must not send Sm-Id-Token (SSO-only); see sm_api docs.
323        match sm.login(&tokens.access_token, None).await {
324            Ok(()) => {}
325            Err(AuthError::MfaRequired { factors }) => {
326                self.satisfy_mfa(&mut sm, tokens, &factors, &mut mfa_prompt)
327                    .await?
328            }
329            Err(e) => return Err(e),
330        }
331        let mut user = sm.fetch_current_user().await?;
332        let want = match account {
333            AccountChoice::Current => None,
334            AccountChoice::Id(id) => Some(id),
335            // The picker needs the list, so fetch it here; `switch_account` re-reads it to
336            // validate, which also covers ids that did not come from a picker.
337            AccountChoice::Prompt(choose) => {
338                let accounts = login_accounts(&sm.fetch_accounts().await?);
339                let current = user
340                    .current_account_id
341                    .as_deref()
342                    .and_then(|s| s.parse::<u64>().ok());
343                Some(choose(&accounts, current)?)
344            }
345        };
346        if let Some(want) = want {
347            user = self.switch_account(&sm, user, want).await?;
348        }
349        // Pick the account matching the logged-in user's current_account_id; `/accounts` order
350        // is not guaranteed, so there is nothing safe to fall back to.
351        //
352        // Settled *before* `ensure_capi_enabled`, which switches programmatic access on for the
353        // account: a login that is going to refuse must not leave that behind unreported, and
354        // `capi_newly_enabled` is only delivered on the success path.
355        let chosen = resolve_account(
356            sm.fetch_accounts().await?,
357            user.current_account_id.as_deref(),
358        )?
359        .id;
360        let capi_newly_enabled = sm.ensure_capi_enabled().await?;
361        // Re-read: the account access key only exists once CAPI is on, so the entry the key
362        // comes from has to be the one fetched after enabling it.
363        let accounts = sm.fetch_accounts().await?;
364        let all_accounts = login_accounts(&accounts);
365        let account = accounts
366            .into_iter()
367            .find(|a| a.id == chosen)
368            .ok_or_else(|| {
369                AuthError::Protocol(format!(
370                    "account {chosen} was no longer listed after enabling programmatic access"
371                ))
372            })?;
373        let account_name = account.name.clone();
374        // Report the account the key belongs to, taken from the same entry the key came from.
375        let account_id = Some(account.id);
376        let api_key = account.api_access_key.ok_or_else(|| {
377            AuthError::Protocol("account has no CAPI access key after enabling CAPI".into())
378        })?;
379        let minted = sm.mint_capi_key(key_name, user.user_account()?).await?;
380        // Best-effort: count our keys so the CLI can warn about sprawl (D5). Never fail login
381        // over this — a listing error just means no warning.
382        let redisctl_key_count = sm
383            .fetch_capi_keys()
384            .await
385            .map(|keys| keys.iter().filter(|n| n.starts_with("redisctl-")).count())
386            .unwrap_or(0);
387        Ok((
388            MintedCredentials {
389                account_id,
390                email: user.email,
391                api_key,
392                api_secret: minted.secret_key,
393                api_url: self.capi_url.clone(),
394                refresh_token: tokens.refresh_token.clone(),
395                capi_key_name: minted.name,
396                redisctl_key_count,
397                account_name,
398                capi_newly_enabled,
399                // Nothing has been revoked yet; the caller records what the revoker reports.
400                superseded_revoked: None,
401                superseded_key_name: None,
402                accounts: all_accounts,
403            },
404            superseded.map(|previous| SupersededRevoker {
405                sm,
406                previous,
407                on: account_id,
408            }),
409        ))
410    }
411
412    /// Point the session at `want` before anything account-scoped happens.
413    ///
414    /// Verifies the switch actually took rather than assuming it: every later call resolves the
415    /// account from the session, so a silent no-op here would mint the key on the wrong account.
416    async fn switch_account(
417        &self,
418        sm: &SmApiClient,
419        user: SmUser,
420        want: u64,
421    ) -> Result<SmUser, AuthError> {
422        if user.current_account_id.as_deref() == Some(want.to_string().as_str()) {
423            return Ok(user);
424        }
425        // Fetched here rather than reusing the later call: membership has to be checked *before*
426        // `setcurrent`, while the later fetch has to come *after* `ensure_capi_enabled` to see the
427        // account access key it creates. Only runs when `--account` was given.
428        let accounts = sm.fetch_accounts().await?;
429        if !accounts.iter().any(|a| a.id == want) {
430            if accounts.is_empty() {
431                return Err(AuthError::Protocol(
432                    "this login is not associated with any Redis Cloud account, so there is \
433                     nothing to switch to"
434                        .into(),
435                ));
436            }
437            return Err(AuthError::UnknownAccount {
438                requested: want,
439                available: account_labels(&accounts),
440            });
441        }
442        set_current_account_verified(sm, want).await
443    }
444
445    /// Drive the MFA retry loop against an already-challenged client.
446    async fn satisfy_mfa<F>(
447        &self,
448        sm: &mut SmApiClient,
449        tokens: &TokenSet,
450        factors: &[String],
451        mfa_prompt: &mut F,
452    ) -> Result<(), AuthError>
453    where
454        F: FnMut(&[String], u32) -> Result<Option<String>, AuthError>,
455    {
456        for attempt in 1..=MFA_MAX_ATTEMPTS {
457            let Some(code) = mfa_prompt(factors, attempt)? else {
458                // Caller can't prompt (no TTY / agent): report the challenge, not a failure.
459                return Err(AuthError::MfaRequired {
460                    factors: factors.to_vec(),
461                });
462            };
463            match sm.complete_mfa(&tokens.access_token, None, &code).await {
464                Ok(()) => return Ok(()),
465                // Wrong code: loop and let the caller re-prompt, unless attempts are spent.
466                Err(AuthError::MfaInvalidCode) if attempt < MFA_MAX_ATTEMPTS => continue,
467                Err(e) => return Err(e),
468            }
469        }
470        Err(AuthError::MfaInvalidCode)
471    }
472}
473
474/// How many TOTP codes a single login will accept before giving up. SM enforces its own quota
475/// (`mfa-quota-exceeded`); this only bounds our prompting.
476pub const MFA_MAX_ATTEMPTS: u32 = 3;
477
478/// Choose the account matching `current_account_id` (the logged-in user context); fall back to
479/// the first account only when the id is absent or not present in the list.
480/// Project the API's accounts into [`LoginAccount`]s, in a stable order.
481///
482/// `/accounts` order is not guaranteed. Sorting here rather than at each use means the picker's
483/// numbering, the printed listing and the JSON `accounts` array all agree between runs — the last
484/// of which callers are told to read for ids.
485fn login_accounts(accounts: &[SmAccount]) -> Vec<LoginAccount> {
486    let mut out: Vec<LoginAccount> = accounts
487        .iter()
488        .map(|a| LoginAccount {
489            id: a.id,
490            name: a.name.clone(),
491        })
492        .collect();
493    out.sort_by_key(|a| a.id);
494    out
495}
496
497/// What a sign-in can reach, read without minting or switching anything.
498#[derive(Debug)]
499pub struct AccountListing {
500    pub email: Option<String>,
501    pub accounts: Vec<LoginAccount>,
502    /// The account the session starts on: the user's server-side default, which is not
503    /// necessarily the one a profile's key belongs to.
504    pub session_account: Option<u64>,
505}
506
507/// Point the session at `want` and confirm it landed there.
508///
509/// A successful response does not mean the session moved, and everything afterwards resolves the
510/// account from the session — so an unverified switch quietly reads and deletes on whichever
511/// account the session was already on.
512async fn set_current_account_verified(sm: &SmApiClient, want: u64) -> Result<SmUser, AuthError> {
513    sm.set_current_account(want).await?;
514    let user = sm.fetch_current_user().await?;
515    // Trust the server's answer, not the request's success.
516    if user.current_account_id.as_deref() != Some(want.to_string().as_str()) {
517        return Err(AuthError::Protocol(format!(
518            "asked Redis Cloud to switch to account {want} but the session still reports {}",
519            user.current_account_id.as_deref().unwrap_or("none")
520        )));
521    }
522    Ok(user)
523}
524
525/// Revokes the key a freshly minted one replaces, using the session that minted it.
526///
527/// Handed back rather than run during the mint so a caller can store the new credentials first:
528/// revoking before they are safely stored can leave a profile with the old key dead and the new
529/// secret lost, since Redis Cloud returns a key's secret only when it is created.
530pub struct SupersededRevoker {
531    sm: SmApiClient,
532    previous: SupersededKey,
533    /// The account the replacement was minted on, so the revoke can skip a pointless
534    /// `setcurrent` when the old key lives there too.
535    on: Option<u64>,
536}
537
538impl SupersededRevoker {
539    /// Which key this would revoke, for a caller that wants to report it.
540    pub fn key_name(&self) -> &str {
541        &self.previous.key_name
542    }
543
544    /// The account holding that key. A caller comparing this with the account it just minted on
545    /// can tell whether the revoke will remove a key from that same account.
546    pub fn account_id(&self) -> u64 {
547        self.previous.account_id
548    }
549
550    /// Best-effort: `false` means the old key may still be live, never that the new one is bad.
551    pub async fn revoke(self) -> bool {
552        revoke_superseded(&self.sm, &self.previous, self.on).await
553    }
554}
555
556/// Revoke `previous` using the current session. The delete is scoped to the session's account, so
557/// the session is pointed at `previous.account_id` unless `on` says it is already there.
558async fn revoke_superseded(sm: &SmApiClient, previous: &SupersededKey, on: Option<u64>) -> bool {
559    let account = previous.account_id;
560    let moved = on != Some(account);
561    if moved && let Err(e) = set_current_account_verified(sm, account).await {
562        tracing::warn!(
563            "cannot reach account {account} to revoke key {}: {e}",
564            previous.key_name
565        );
566        return false;
567    }
568    // Nothing uses this session afterwards — it is dropped with the revoker — so there is no
569    // need to point it back at `on`.
570    delete_named_key(sm, account, &previous.key_name).await
571}
572
573async fn delete_named_key(sm: &SmApiClient, account: u64, key: &str) -> bool {
574    let entries = match sm.fetch_capi_key_entries().await {
575        Ok(entries) => entries,
576        Err(e) => {
577            tracing::warn!("cannot list keys on account {account} to revoke {key}: {e}");
578            return false;
579        }
580    };
581    let Some((id, _)) = entries.iter().find(|(_, name)| name == key) else {
582        tracing::warn!(
583            "key {key} is not on account {account}; it holds: {}",
584            entries
585                .iter()
586                .map(|(_, name)| name.as_str())
587                .collect::<Vec<_>>()
588                .join(", ")
589        );
590        return false;
591    };
592    match sm.delete_capi_key(*id).await {
593        Ok(()) => true,
594        Err(e) => {
595            tracing::warn!("could not delete key {key} ({id}) on account {account}: {e}");
596            false
597        }
598    }
599}
600
601/// The account the minted key will belong to: the one the session reports as current.
602///
603/// The chosen entry supplies the access key, while the secret is minted in the session's own
604/// account context — so guessing here pairs two halves that need not belong together, and
605/// `/accounts` order is not guaranteed. One account and a session that claims nothing is not a
606/// guess; anything else, and the caller has to say which.
607fn resolve_account(
608    mut accounts: Vec<SmAccount>,
609    current_account_id: Option<&str>,
610) -> Result<SmAccount, AuthError> {
611    let target = current_account_id.and_then(|s| s.parse::<u64>().ok());
612    if let Some(at) = target.and_then(|id| accounts.iter().position(|a| a.id == id)) {
613        return Ok(accounts.swap_remove(at));
614    }
615    if accounts.is_empty() {
616        return Err(AuthError::Protocol(
617            "no accounts associated with this login".into(),
618        ));
619    }
620    // One account and nothing claimed: there is nothing else the key could belong to. A
621    // `current_account_id` we could not match is a different situation — the session is naming an
622    // account this list does not have, so taking the lone entry would pair its access key with a
623    // secret minted somewhere else. That is a disagreement, not silence, so it refuses below.
624    if accounts.len() == 1 && current_account_id.is_none() {
625        return Ok(accounts.swap_remove(0));
626    }
627    Err(AuthError::AccountRequired(format!(
628        "this sign-in does not report which Redis Cloud account is current{}, and a key minted \
629         on a guess could belong to the wrong one. Re-run with `--account <id>`; you belong to: \
630         {}",
631        match current_account_id {
632            Some(id) => format!(" (it names {id}, which is not one of yours)"),
633            None => String::new(),
634        },
635        account_labels(&accounts)
636    )))
637}
638
639/// `Acme (#316941), #451002` — the shared rendering for every message that lists accounts.
640fn account_labels(accounts: &[SmAccount]) -> String {
641    login_accounts(accounts)
642        .iter()
643        .map(LoginAccount::label)
644        .collect::<Vec<_>>()
645        .join(", ")
646}
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651    use wiremock::matchers::{method, path};
652    use wiremock::{Mock, MockServer, ResponseTemplate};
653
654    fn account(id: u64) -> SmAccount {
655        serde_json::from_value(serde_json::json!({
656            "id": id, "api_access_key": format!("KEY-{id}")
657        }))
658        .unwrap()
659    }
660
661    /// The label is shared by the CLI's account listing and the `UnknownAccount` message, so
662    /// both read identically — including when the API reports no name.
663    #[test]
664    fn login_account_labels_named_and_unnamed_accounts() {
665        assert_eq!(
666            LoginAccount {
667                id: 316941,
668                name: Some("Acme".to_string()),
669            }
670            .label(),
671            "Acme (#316941)"
672        );
673        assert_eq!(
674            LoginAccount {
675                id: 316941,
676                name: None,
677            }
678            .label(),
679            "#316941"
680        );
681    }
682
683    #[test]
684    fn resolve_account_prefers_current_account_id() {
685        let accts = vec![account(111), account(222), account(333)];
686        // Matches the user's current account, not the first in the list.
687        let chosen = resolve_account(accts, Some("222")).unwrap();
688        assert_eq!(chosen.id, 222);
689    }
690
691    /// The chosen entry supplies the access key while the secret is minted in the session's own
692    /// account, so a guess can pair halves from different accounts. With more than one candidate
693    /// and nothing to go on, refuse and name them.
694    #[test]
695    fn resolve_account_refuses_to_guess_between_several() {
696        for current in [None, Some("999")] {
697            let err = resolve_account(vec![account(111), account(222)], current).unwrap_err();
698            let AuthError::AccountRequired(message) = err else {
699                panic!("{current:?} gave {err:?}");
700            };
701            assert!(message.contains("#111"), "{message}");
702            assert!(message.contains("#222"), "{message}");
703            assert!(message.contains("--account"), "{message}");
704        }
705        // The unknown id is worth naming: it is the thing that did not match.
706        let err = resolve_account(vec![account(111), account(222)], Some("999")).unwrap_err();
707        assert!(err.to_string().contains("999"), "{err}");
708    }
709
710    /// One account and a session that claims nothing is not a guess — there is nothing else the
711    /// key could belong to.
712    #[test]
713    fn resolve_account_takes_the_only_account() {
714        assert_eq!(resolve_account(vec![account(111)], None).unwrap().id, 111);
715        // And when the session does name it, by the matching path.
716        assert_eq!(
717            resolve_account(vec![account(111)], Some("111")).unwrap().id,
718            111
719        );
720    }
721
722    /// A `current_account_id` the list does not have is a disagreement, not silence: the session
723    /// mints the secret in *that* account while the lone entry supplies the access key, so the
724    /// two halves need not belong together. Refused even though there is only one candidate.
725    #[test]
726    fn resolve_account_refuses_a_lone_account_the_session_disowns() {
727        for current in [Some("999"), Some("not-a-number")] {
728            let err = resolve_account(vec![account(111)], current).unwrap_err();
729            let AuthError::AccountRequired(message) = err else {
730                panic!("{current:?} gave {err:?}");
731            };
732            assert!(message.contains("#111"), "{message}");
733            assert!(message.contains("--account"), "{message}");
734        }
735    }
736
737    #[test]
738    fn resolve_account_reports_an_empty_list_as_protocol() {
739        assert!(matches!(
740            resolve_account(vec![], Some("1")),
741            Err(AuthError::Protocol(_))
742        ));
743    }
744
745    #[test]
746    fn debug_redacts_secrets() {
747        let creds = MintedCredentials {
748            account_id: Some(42),
749            email: Some("u@example.com".to_string()),
750            api_key: "AKEY-visible-should-not-appear".to_string(),
751            api_secret: "SECRET-should-not-appear".to_string(),
752            api_url: "https://api.example.com/v1".to_string(),
753            refresh_token: Some("RT-should-not-appear".to_string()),
754            capi_key_name: "redisctl-cli-1".to_string(),
755            redisctl_key_count: 3,
756            account_name: Some("Acme".to_string()),
757            capi_newly_enabled: false,
758            superseded_revoked: None,
759            superseded_key_name: None,
760            accounts: vec![
761                LoginAccount {
762                    id: 316941,
763                    name: Some("Acme".to_string()),
764                },
765                LoginAccount {
766                    id: 481022,
767                    name: Some("Contoso".to_string()),
768                },
769            ],
770        };
771        let dbg = format!("{creds:?}");
772        assert!(dbg.contains("<redacted>"));
773        assert!(!dbg.contains("AKEY-visible-should-not-appear"));
774        assert!(!dbg.contains("SECRET-should-not-appear"));
775        assert!(!dbg.contains("RT-should-not-appear"));
776        // Non-secret fields remain visible for diagnostics.
777        assert!(dbg.contains("u@example.com"));
778        assert!(dbg.contains("redisctl-cli-1"));
779    }
780
781    #[tokio::test]
782    async fn complete_login_runs_the_full_exchange() {
783        let server = MockServer::start().await;
784        let mount = |m: &str, p: &'static str, body: serde_json::Value, cookie: bool| {
785            let mut tmpl = ResponseTemplate::new(200).set_body_json(body);
786            if cookie {
787                tmpl = tmpl.append_header("Set-Cookie", "JSESSIONID=SID; Path=/");
788            }
789            Mock::given(method(m)).and(path(p)).respond_with(tmpl)
790        };
791        mount("POST", "/login", serde_json::json!({}), true)
792            .mount(&server)
793            .await;
794        mount(
795            "GET",
796            "/csrf",
797            serde_json::json!({"csrfToken": {"csrf_token": "C"}}),
798            false,
799        )
800        .mount(&server)
801        .await;
802        mount(
803            "GET",
804            "/users/me",
805            serde_json::json!({"id": "114429", "current_account_id": "112117", "email": "u@e.com"}),
806            false,
807        )
808        .mount(&server)
809        .await;
810        mount(
811            "POST",
812            "/accounts/cloud-api/cloudApiAccessKey",
813            serde_json::json!({"cloudApiAccessKey": {"accessKey": "ACCT"}}),
814            false,
815        )
816        .mount(&server)
817        .await;
818        mount(
819            "GET",
820            "/accounts",
821            serde_json::json!({"accounts": [{"id": 112117, "api_access_key": "ACCT-KEY"}]}),
822            false,
823        )
824        .mount(&server)
825        .await;
826        mount(
827            "POST",
828            "/accounts/cloud-api/cloudApiKeys",
829            serde_json::json!({"name": "redisctl-test", "secret_key": "SECRET"}),
830            false,
831        )
832        .mount(&server)
833        .await;
834
835        let auth = CloudAuthenticator::new(
836            Url::parse("https://issuer.example/oauth2/default").unwrap(),
837            "cid",
838            Url::parse(&server.uri()).unwrap(),
839            "https://capi.example/v1",
840        );
841        let tokens = TokenSet {
842            access_token: "AT".into(),
843            refresh_token: Some("RT".into()),
844            expires_in: 3600,
845        };
846
847        let creds = auth
848            .complete_login(
849                &tokens,
850                "redisctl-test",
851                LoginFlow::Loopback,
852                AccountChoice::Current,
853            )
854            .await
855            .unwrap();
856        assert_eq!(creds.api_key, "ACCT-KEY");
857        assert_eq!(creds.api_secret, "SECRET");
858        assert_eq!(creds.api_url, "https://capi.example/v1");
859        assert_eq!(creds.account_id, Some(112117));
860        assert_eq!(creds.email.as_deref(), Some("u@e.com"));
861        assert_eq!(creds.refresh_token.as_deref(), Some("RT"));
862        assert_eq!(creds.capi_key_name, "redisctl-test");
863        // secrets must not leak via Debug
864        let dbg = format!("{creds:?}");
865        assert!(!dbg.contains("SECRET") && !dbg.contains("ACCT-KEY") && !dbg.contains("RT"));
866    }
867
868    fn tokens() -> TokenSet {
869        TokenSet {
870            access_token: "AT".to_string(),
871            refresh_token: None,
872            expires_in: 3600,
873        }
874    }
875
876    fn authenticator(server: &MockServer) -> CloudAuthenticator {
877        CloudAuthenticator::new(
878            Url::parse("https://issuer.example/oauth2/default").unwrap(),
879            "client",
880            Url::parse(&server.uri()).unwrap(),
881            "https://capi.example/v1",
882        )
883    }
884
885    /// Everything the exchange needs apart from the account-shaped endpoints each test varies.
886    async fn common_login_mocks(server: &MockServer) {
887        Mock::given(method("POST"))
888            .and(path("/login"))
889            .respond_with(
890                ResponseTemplate::new(200)
891                    .set_body_json(serde_json::json!({}))
892                    .append_header("Set-Cookie", "JSESSIONID=SID; Path=/"),
893            )
894            .mount(server)
895            .await;
896        Mock::given(method("GET"))
897            .and(path("/csrf"))
898            .respond_with(
899                ResponseTemplate::new(200)
900                    .set_body_json(serde_json::json!({"csrfToken": {"csrf_token": "C"}})),
901            )
902            .mount(server)
903            .await;
904        Mock::given(method("POST"))
905            .and(path("/accounts/cloud-api/cloudApiAccessKey"))
906            .respond_with(
907                ResponseTemplate::new(200)
908                    .set_body_json(serde_json::json!({"cloudApiAccessKey": {"accessKey": "ACCT"}})),
909            )
910            .mount(server)
911            .await;
912        Mock::given(method("POST"))
913            .and(path("/accounts/cloud-api/cloudApiKeys"))
914            .respond_with(ResponseTemplate::new(200).set_body_json(
915                serde_json::json!({"name": "redisctl-test", "secret_key": "SECRET"}),
916            ))
917            .mount(server)
918            .await;
919    }
920
921    /// `--account` has to switch the session *before* anything account-scoped runs: both
922    /// `ensure_capi_enabled` and the mint resolve the account from the session, so a switch that
923    /// happened afterwards would put the key on the previous account. The mocks answer
924    /// `/users/me` differently before and after `setcurrent`, so the minted account is only right
925    /// if the ordering held.
926    #[tokio::test]
927    async fn complete_login_switches_before_minting() {
928        let server = MockServer::start().await;
929        let switched = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
930        common_login_mocks(&server).await;
931
932        // /users/me reports 111 until setcurrent runs, then 222.
933        let flag = switched.clone();
934        Mock::given(method("GET"))
935            .and(path("/users/me"))
936            .respond_with(move |_: &wiremock::Request| {
937                let id = if flag.load(std::sync::atomic::Ordering::SeqCst) {
938                    "222"
939                } else {
940                    "111"
941                };
942                ResponseTemplate::new(200).set_body_json(serde_json::json!({
943                    "id": "1", "current_account_id": id, "email": "u@e.com"
944                }))
945            })
946            .mount(&server)
947            .await;
948        let flag = switched.clone();
949        Mock::given(method("POST"))
950            .and(path("/accounts/setcurrent/222"))
951            .respond_with(move |_: &wiremock::Request| {
952                flag.store(true, std::sync::atomic::Ordering::SeqCst);
953                ResponseTemplate::new(200).set_body_json(serde_json::json!({}))
954            })
955            .mount(&server)
956            .await;
957        Mock::given(method("GET"))
958            .and(path("/accounts"))
959            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
960                "accounts": [
961                    {"id": 111, "name": "One", "api_access_key": "KEY-111"},
962                    {"id": 222, "name": "Two", "api_access_key": "KEY-222"}
963                ]
964            })))
965            .mount(&server)
966            .await;
967
968        let creds = authenticator(&server)
969            .complete_login(
970                &tokens(),
971                "redisctl-test",
972                LoginFlow::Loopback,
973                AccountChoice::Id(222),
974            )
975            .await
976            .unwrap();
977        // The key, the reported id and the reported name all describe the requested account.
978        assert_eq!(creds.account_id, Some(222));
979        assert_eq!(creds.account_name.as_deref(), Some("Two"));
980        assert_eq!(creds.api_key, "KEY-222");
981        assert_eq!(creds.account_count(), 2);
982    }
983
984    /// The picker runs mid-exchange, on the one session. It must see every account in a stable
985    /// order (the API does not promise one) along with the session's current account, and its
986    /// answer must be what gets minted.
987    #[tokio::test]
988    async fn complete_login_mints_what_the_picker_chose() {
989        let server = MockServer::start().await;
990        let switched = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
991        common_login_mocks(&server).await;
992
993        let flag = switched.clone();
994        Mock::given(method("GET"))
995            .and(path("/users/me"))
996            .respond_with(move |_: &wiremock::Request| {
997                let id = if flag.load(std::sync::atomic::Ordering::SeqCst) {
998                    "111"
999                } else {
1000                    "222"
1001                };
1002                ResponseTemplate::new(200).set_body_json(serde_json::json!({
1003                    "id": "1", "current_account_id": id, "email": "u@e.com"
1004                }))
1005            })
1006            .mount(&server)
1007            .await;
1008        let flag = switched.clone();
1009        Mock::given(method("POST"))
1010            .and(path("/accounts/setcurrent/111"))
1011            .respond_with(move |_: &wiremock::Request| {
1012                flag.store(true, std::sync::atomic::Ordering::SeqCst);
1013                ResponseTemplate::new(200).set_body_json(serde_json::json!({}))
1014            })
1015            .mount(&server)
1016            .await;
1017        // Deliberately returned highest-id-first, so a stable order cannot come from the API.
1018        Mock::given(method("GET"))
1019            .and(path("/accounts"))
1020            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1021                "accounts": [
1022                    {"id": 222, "name": "Two", "api_access_key": "KEY-222"},
1023                    {"id": 111, "name": "One", "api_access_key": "KEY-111"}
1024                ]
1025            })))
1026            .mount(&server)
1027            .await;
1028
1029        let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1030        let seen_current = std::sync::Arc::new(std::sync::Mutex::new(None));
1031        let (s2, c2) = (seen.clone(), seen_current.clone());
1032        let creds = authenticator(&server)
1033            .complete_login(
1034                &tokens(),
1035                "k",
1036                LoginFlow::Switch,
1037                AccountChoice::Prompt(Box::new(move |accounts, current| {
1038                    *s2.lock().unwrap() = accounts.iter().map(|a| a.id).collect::<Vec<_>>();
1039                    *c2.lock().unwrap() = current;
1040                    Ok(111)
1041                })),
1042            )
1043            .await
1044            .unwrap();
1045
1046        // Sorted by id despite the API's order, so a positional choice is stable between runs.
1047        assert_eq!(*seen.lock().unwrap(), vec![111, 222]);
1048        // The session's account is passed through for context.
1049        assert_eq!(*seen_current.lock().unwrap(), Some(222));
1050        // And the picker's answer is what got minted.
1051        assert_eq!(creds.account_id, Some(111));
1052        assert_eq!(creds.api_key, "KEY-111");
1053        assert_eq!(creds.account_label(), "One (#111)");
1054    }
1055
1056    /// Refusing at the picker abandons the switch instead of minting something unasked for.
1057    #[tokio::test]
1058    async fn complete_login_propagates_a_declined_picker() {
1059        let server = MockServer::start().await;
1060        common_login_mocks(&server).await;
1061        Mock::given(method("GET"))
1062            .and(path("/users/me"))
1063            .respond_with(ResponseTemplate::new(200).set_body_json(
1064                serde_json::json!({"id": "1", "current_account_id": "111", "email": "u@e.com"}),
1065            ))
1066            .mount(&server)
1067            .await;
1068        Mock::given(method("GET"))
1069            .and(path("/accounts"))
1070            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1071                "accounts": [{"id": 111, "name": "One", "api_access_key": "KEY-111"}]
1072            })))
1073            .mount(&server)
1074            .await;
1075        let err = authenticator(&server)
1076            .complete_login(
1077                &tokens(),
1078                "k",
1079                LoginFlow::Switch,
1080                AccountChoice::Prompt(Box::new(|_, _| {
1081                    Err(AuthError::AccountRequired("declined".into()))
1082                })),
1083            )
1084            .await
1085            .unwrap_err();
1086        assert!(matches!(err, AuthError::AccountRequired(_)), "got {err:?}");
1087    }
1088
1089    /// Listing must not mint a key or move the session: only the read endpoints are mounted, so
1090    /// an attempt at either would 404 and fail the test.
1091    #[tokio::test]
1092    async fn list_accounts_reads_without_minting_or_switching() {
1093        let server = MockServer::start().await;
1094        Mock::given(method("POST"))
1095            .and(path("/login"))
1096            .respond_with(
1097                ResponseTemplate::new(200)
1098                    .set_body_json(serde_json::json!({}))
1099                    .append_header("Set-Cookie", "JSESSIONID=SID; Path=/"),
1100            )
1101            .mount(&server)
1102            .await;
1103        Mock::given(method("GET"))
1104            .and(path("/csrf"))
1105            .respond_with(
1106                ResponseTemplate::new(200)
1107                    .set_body_json(serde_json::json!({"csrfToken": {"csrf_token": "C"}})),
1108            )
1109            .mount(&server)
1110            .await;
1111        Mock::given(method("GET"))
1112            .and(path("/users/me"))
1113            .respond_with(ResponseTemplate::new(200).set_body_json(
1114                serde_json::json!({"id": "1", "current_account_id": "222", "email": "u@e.com"}),
1115            ))
1116            .mount(&server)
1117            .await;
1118        Mock::given(method("GET"))
1119            .and(path("/accounts"))
1120            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1121                "accounts": [
1122                    {"id": 222, "name": "Two"},
1123                    {"id": 111, "name": "One"}
1124                ]
1125            })))
1126            .mount(&server)
1127            .await;
1128
1129        let listing = authenticator(&server)
1130            .list_accounts(&tokens(), |_, _| Ok(None))
1131            .await
1132            .unwrap();
1133
1134        // Sorted, so the numbering a caller reads is stable between runs.
1135        assert_eq!(
1136            listing.accounts.iter().map(|a| a.id).collect::<Vec<_>>(),
1137            vec![111, 222]
1138        );
1139        assert_eq!(listing.session_account, Some(222));
1140        assert_eq!(listing.email.as_deref(), Some("u@e.com"));
1141    }
1142
1143    /// An account the user does not belong to is refused before any switch is attempted, and the
1144    /// message names the accounts they do have.
1145    #[tokio::test]
1146    async fn complete_login_refuses_an_account_the_user_is_not_in() {
1147        let server = MockServer::start().await;
1148        common_login_mocks(&server).await;
1149        Mock::given(method("GET"))
1150            .and(path("/users/me"))
1151            .respond_with(ResponseTemplate::new(200).set_body_json(
1152                serde_json::json!({"id": "1", "current_account_id": "111", "email": "u@e.com"}),
1153            ))
1154            .mount(&server)
1155            .await;
1156        Mock::given(method("GET"))
1157            .and(path("/accounts"))
1158            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1159                "accounts": [{"id": 111, "name": "One", "api_access_key": "KEY-111"}]
1160            })))
1161            .mount(&server)
1162            .await;
1163        // No /accounts/setcurrent/* mock is mounted: reaching one would 404 and surface as a
1164        // different error, which is itself the assertion that no switch was attempted.
1165        match authenticator(&server)
1166            .complete_login(&tokens(), "k", LoginFlow::Loopback, AccountChoice::Id(999))
1167            .await
1168        {
1169            Err(AuthError::UnknownAccount {
1170                requested,
1171                available,
1172            }) => {
1173                assert_eq!(requested, 999);
1174                assert_eq!(available, "One (#111)");
1175            }
1176            other => panic!("expected UnknownAccount, got {other:?}"),
1177        }
1178    }
1179
1180    /// Both shapes of "the session will not say which account is current" have to stop before
1181    /// the mint: a key's secret is returned once, so one minted on a guess cannot be recovered
1182    /// or paired with the right account afterwards.
1183    ///
1184    /// And before `ensure_capi_enabled`, which switches programmatic access on for the account.
1185    /// A refusal must not leave that behind: `capi_newly_enabled` only reaches the caller on the
1186    /// success path, so enabling it here would change the account with nothing saying so.
1187    #[tokio::test]
1188    async fn complete_login_mints_nothing_when_the_account_is_ambiguous() {
1189        async fn attempt(current: Option<&str>) -> (AuthError, usize) {
1190            let server = MockServer::start().await;
1191            common_login_mocks(&server).await;
1192
1193            let mut me = serde_json::json!({"id": "1", "email": "u@e.com"});
1194            if let Some(current) = current {
1195                me["current_account_id"] = serde_json::json!(current);
1196            }
1197            Mock::given(method("GET"))
1198                .and(path("/users/me"))
1199                .respond_with(ResponseTemplate::new(200).set_body_json(me))
1200                .mount(&server)
1201                .await;
1202            Mock::given(method("GET"))
1203                .and(path("/accounts"))
1204                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1205                    "accounts": [
1206                        {"id": 111, "name": "One", "api_access_key": "KEY-111"},
1207                        {"id": 222, "name": "Two", "api_access_key": "KEY-222"}
1208                    ]
1209                })))
1210                .mount(&server)
1211                .await;
1212
1213            let err = authenticator(&server)
1214                .complete_login(&tokens(), "k", LoginFlow::Loopback, AccountChoice::Current)
1215                .await
1216                .expect_err("an ambiguous account must not complete");
1217            let requests = server.received_requests().await.unwrap_or_default();
1218            let hits = |path: &str| requests.iter().filter(|r| r.url.path() == path).count();
1219            assert_eq!(
1220                hits("/accounts/cloud-api/cloudApiAccessKey"),
1221                0,
1222                "{current:?} enabled programmatic access before refusing"
1223            );
1224            (err, hits("/accounts/cloud-api/cloudApiKeys"))
1225        }
1226
1227        for current in [None, Some("999")] {
1228            let (err, mints) = attempt(current).await;
1229            assert!(
1230                matches!(err, AuthError::AccountRequired(_)),
1231                "{current:?} gave {err:?}"
1232            );
1233            assert_eq!(mints, 0, "{current:?} minted a key anyway");
1234        }
1235    }
1236
1237    /// A `setcurrent` that reports success but leaves the session on the old account must fail
1238    /// loudly: continuing would mint the key on the wrong account and report success.
1239    #[tokio::test]
1240    async fn complete_login_fails_when_the_switch_does_not_take() {
1241        let server = MockServer::start().await;
1242        common_login_mocks(&server).await;
1243        // Never changes, however many times it is asked.
1244        Mock::given(method("GET"))
1245            .and(path("/users/me"))
1246            .respond_with(ResponseTemplate::new(200).set_body_json(
1247                serde_json::json!({"id": "1", "current_account_id": "111", "email": "u@e.com"}),
1248            ))
1249            .mount(&server)
1250            .await;
1251        Mock::given(method("POST"))
1252            .and(path("/accounts/setcurrent/222"))
1253            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1254            .mount(&server)
1255            .await;
1256        Mock::given(method("GET"))
1257            .and(path("/accounts"))
1258            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1259                "accounts": [
1260                    {"id": 111, "name": "One", "api_access_key": "KEY-111"},
1261                    {"id": 222, "name": "Two", "api_access_key": "KEY-222"}
1262                ]
1263            })))
1264            .mount(&server)
1265            .await;
1266        let err = authenticator(&server)
1267            .complete_login(&tokens(), "k", LoginFlow::Loopback, AccountChoice::Id(222))
1268            .await
1269            .unwrap_err();
1270        assert!(
1271            matches!(err, AuthError::Protocol(ref m) if m.contains("still reports")),
1272            "expected a switch-verification failure, got {err:?}"
1273        );
1274    }
1275
1276    /// Asking for the account the session is already on must not issue a switch at all.
1277    #[tokio::test]
1278    async fn complete_login_skips_the_switch_when_already_current() {
1279        let server = MockServer::start().await;
1280        common_login_mocks(&server).await;
1281        Mock::given(method("GET"))
1282            .and(path("/users/me"))
1283            .respond_with(ResponseTemplate::new(200).set_body_json(
1284                serde_json::json!({"id": "1", "current_account_id": "111", "email": "u@e.com"}),
1285            ))
1286            .mount(&server)
1287            .await;
1288        Mock::given(method("GET"))
1289            .and(path("/accounts"))
1290            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1291                "accounts": [{"id": 111, "name": "One", "api_access_key": "KEY-111"}]
1292            })))
1293            .mount(&server)
1294            .await;
1295        // Again, no setcurrent mount: if one were issued it would 404 and fail the login.
1296        let creds = authenticator(&server)
1297            .complete_login(&tokens(), "k", LoginFlow::Loopback, AccountChoice::Id(111))
1298            .await
1299            .unwrap();
1300        assert_eq!(creds.account_id, Some(111));
1301    }
1302
1303    /// The mint must not revoke anything: the caller stores the new credentials first, and only
1304    /// then runs the revoker. No DELETE is mounted here, so revoking during the mint would 404.
1305    #[tokio::test]
1306    async fn the_mint_defers_revocation_to_the_caller() {
1307        let server = MockServer::start().await;
1308        common_login_mocks(&server).await;
1309        Mock::given(method("GET"))
1310            .and(path("/users/me"))
1311            .respond_with(ResponseTemplate::new(200).set_body_json(
1312                serde_json::json!({"id": "1", "current_account_id": "111", "email": "u@e.com"}),
1313            ))
1314            .mount(&server)
1315            .await;
1316        Mock::given(method("GET"))
1317            .and(path("/accounts"))
1318            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1319                "accounts": [{"id": 111, "name": "One", "api_access_key": "KEY-111"}]
1320            })))
1321            .mount(&server)
1322            .await;
1323        Mock::given(method("GET"))
1324            .and(path("/accounts/cloud-api/cloudApiKeys"))
1325            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1326                "cloudApiKeys": [{"id": 7, "name": "redisctl-cli-1"}]
1327            })))
1328            .mount(&server)
1329            .await;
1330
1331        let (creds, revoker) = authenticator(&server)
1332            .complete_login_with_mfa(
1333                &tokens(),
1334                "redisctl-cli-2",
1335                LoginFlow::Loopback,
1336                AccountChoice::Current,
1337                Some(SupersededKey {
1338                    account_id: 111,
1339                    key_name: "redisctl-cli-1".to_string(),
1340                }),
1341                |_, _| Ok(None),
1342            )
1343            .await
1344            .unwrap();
1345        // Credentials are complete and usable, and nothing has been taken away yet.
1346        assert!(!creds.api_secret.is_empty());
1347        assert_eq!(creds.superseded_revoked, None);
1348        let revoker = revoker.expect("a superseded key was given, so a revoker comes back");
1349        assert_eq!(revoker.key_name(), "redisctl-cli-1");
1350
1351        // Firing it is what issues the delete. It points the session at the key's account
1352        // first, which is why that is mounted only now too.
1353        Mock::given(method("POST"))
1354            .and(path("/accounts/setcurrent/111"))
1355            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1356            .mount(&server)
1357            .await;
1358        Mock::given(method("DELETE"))
1359            .and(path("/accounts/cloud-api/cloudApiKeys/7"))
1360            .respond_with(ResponseTemplate::new(200))
1361            .expect(1)
1362            .mount(&server)
1363            .await;
1364        assert!(revoker.revoke().await);
1365    }
1366
1367    /// Revoking across accounts has to reach the other account, which is the reason the revoker
1368    /// keeps the session rather than the caller signing in again. The switch back is verified
1369    /// too, so a session that never moved cannot delete from the wrong account.
1370    #[tokio::test]
1371    async fn revoking_across_accounts_reaches_the_other_account() {
1372        let server = MockServer::start().await;
1373        let cell = mock_session(&server, 111, vec![111, 222]).await;
1374        mock_keys(&server, cell, vec![(111, 9, "redisctl-cli-1")]).await;
1375        Mock::given(method("GET"))
1376            .and(path("/accounts"))
1377            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1378                "accounts": [
1379                    {"id": 111, "name": "One", "api_access_key": "KEY-111"},
1380                    {"id": 222, "name": "Two", "api_access_key": "KEY-222"}
1381                ]
1382            })))
1383            .mount(&server)
1384            .await;
1385        Mock::given(method("DELETE"))
1386            .and(path("/accounts/cloud-api/cloudApiKeys/9"))
1387            .respond_with(ResponseTemplate::new(200))
1388            .expect(1)
1389            .named("delete the superseded key on the account that holds it")
1390            .mount(&server)
1391            .await;
1392
1393        let (creds, revoker) = authenticator(&server)
1394            .complete_login_with_mfa(
1395                &tokens(),
1396                "redisctl-cli-2",
1397                LoginFlow::Switch,
1398                AccountChoice::Id(222),
1399                Some(SupersededKey {
1400                    account_id: 111,
1401                    key_name: "redisctl-cli-1".to_string(),
1402                }),
1403                |_, _| Ok(None),
1404            )
1405            .await
1406            .unwrap();
1407        assert_eq!(creds.account_id, Some(222));
1408        assert!(revoker.unwrap().revoke().await);
1409    }
1410
1411    type AccountCell = std::sync::Arc<std::sync::atomic::AtomicU64>;
1412
1413    fn account_of(cell: &AccountCell) -> u64 {
1414        cell.load(std::sync::atomic::Ordering::SeqCst)
1415    }
1416
1417    /// A mock session that reports whichever account it was last switched *to*, and only moves
1418    /// for the ids in `honoured`.
1419    ///
1420    /// An empty `honoured` models what the real API can do, and what the verification exists for:
1421    /// answer 200 and leave the session where it was. Returns the cell so a test can mount
1422    /// account-dependent mocks of its own.
1423    async fn mock_session(server: &MockServer, starts_on: u64, honoured: Vec<u64>) -> AccountCell {
1424        let current: AccountCell =
1425            std::sync::Arc::new(std::sync::atomic::AtomicU64::new(starts_on));
1426        common_login_mocks(server).await;
1427
1428        let cell = current.clone();
1429        Mock::given(method("GET"))
1430            .and(path("/users/me"))
1431            .respond_with(move |_: &wiremock::Request| {
1432                ResponseTemplate::new(200).set_body_json(serde_json::json!({
1433                    "id": "1", "current_account_id": account_of(&cell).to_string(),
1434                    "email": "u@e.com"
1435                }))
1436            })
1437            .mount(server)
1438            .await;
1439
1440        let cell = current.clone();
1441        Mock::given(method("POST"))
1442            .and(wiremock::matchers::path_regex(
1443                r"^/accounts/setcurrent/\d+$",
1444            ))
1445            .respond_with(move |req: &wiremock::Request| {
1446                if let Some(asked) = req
1447                    .url
1448                    .path()
1449                    .rsplit('/')
1450                    .next()
1451                    .and_then(|s| s.parse::<u64>().ok())
1452                    && honoured.contains(&asked)
1453                {
1454                    cell.store(asked, std::sync::atomic::Ordering::SeqCst);
1455                }
1456                ResponseTemplate::new(200).set_body_json(serde_json::json!({}))
1457            })
1458            .mount(server)
1459            .await;
1460
1461        current
1462    }
1463
1464    /// Keys per account, so the listing shows what the session's account actually holds.
1465    async fn mock_keys(server: &MockServer, cell: AccountCell, per_account: Vec<(u64, u64, &str)>) {
1466        let owned: Vec<(u64, u64, String)> = per_account
1467            .into_iter()
1468            .map(|(account, id, name)| (account, id, name.to_string()))
1469            .collect();
1470        Mock::given(method("GET"))
1471            .and(path("/accounts/cloud-api/cloudApiKeys"))
1472            .respond_with(move |_: &wiremock::Request| {
1473                let here = account_of(&cell);
1474                let keys: Vec<_> = owned
1475                    .iter()
1476                    .filter(|(account, _, _)| *account == here)
1477                    .map(|(_, id, name)| serde_json::json!({"id": id, "name": name}))
1478                    .collect();
1479                ResponseTemplate::new(200).set_body_json(serde_json::json!({"cloudApiKeys": keys}))
1480            })
1481            .mount(server)
1482            .await;
1483    }
1484
1485    /// Logout revokes the key the profile recorded, which may not be on the account the sign-in
1486    /// lands on. The listing only shows it once the switch lands, so skipping the switch reads
1487    /// the wrong account rather than merely omitting a request.
1488    #[tokio::test]
1489    async fn revoking_by_name_points_the_session_at_the_key_account() {
1490        let server = MockServer::start().await;
1491        let cell = mock_session(&server, 111, vec![222]).await;
1492        mock_keys(
1493            &server,
1494            cell,
1495            vec![
1496                (111, 9, "a-key-on-the-default-account"),
1497                (222, 5, "redisctl-cli-on-222"),
1498            ],
1499        )
1500        .await;
1501        Mock::given(method("DELETE"))
1502            .and(path("/accounts/cloud-api/cloudApiKeys/5"))
1503            .respond_with(ResponseTemplate::new(200))
1504            .expect(1)
1505            .mount(&server)
1506            .await;
1507
1508        assert!(
1509            authenticator(&server)
1510                .revoke_capi_key(&tokens(), Some(222), "redisctl-cli-on-222")
1511                .await
1512                .unwrap(),
1513            "the key on the recorded account should be found and deleted"
1514        );
1515    }
1516
1517    /// `setcurrent` can answer 200 and leave the session where it was. Acting on that would read
1518    /// and delete on the wrong account — and where that account holds a key of the same name,
1519    /// delete someone else's working key. It has to fail instead.
1520    #[tokio::test]
1521    async fn revoking_by_name_refuses_when_the_switch_does_not_take() {
1522        let server = MockServer::start().await;
1523        let cell = mock_session(&server, 111, vec![]).await;
1524        mock_keys(&server, cell, vec![(111, 9, "redisctl-cli-shared-name")]).await;
1525        Mock::given(method("DELETE"))
1526            .and(wiremock::matchers::path_regex(
1527                r"^/accounts/cloud-api/cloudApiKeys/\d+$",
1528            ))
1529            .respond_with(ResponseTemplate::new(200))
1530            .expect(0)
1531            .named("nothing may be deleted from an account we did not reach")
1532            .mount(&server)
1533            .await;
1534
1535        let err = authenticator(&server)
1536            .revoke_capi_key(&tokens(), Some(222), "redisctl-cli-shared-name")
1537            .await
1538            .expect_err("an unverified switch must not be treated as success");
1539        assert!(
1540            format!("{err}").contains("still reports"),
1541            "the error should say the session did not move, got: {err}"
1542        );
1543    }
1544
1545    /// The switch *back* to the superseded key's account is verified too. Here only the switch to
1546    /// 222 lands, so the return to 111 answers 200 without moving — and a revoke that cannot
1547    /// reach the account must report failure rather than delete from wherever it ended up.
1548    #[tokio::test]
1549    async fn revoking_across_accounts_refuses_when_the_switch_back_does_not_take() {
1550        let server = MockServer::start().await;
1551        let cell = mock_session(&server, 111, vec![222]).await;
1552        mock_keys(
1553            &server,
1554            cell,
1555            vec![(111, 9, "redisctl-cli-1"), (222, 7, "redisctl-cli-1")],
1556        )
1557        .await;
1558        Mock::given(method("GET"))
1559            .and(path("/accounts"))
1560            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1561                "accounts": [
1562                    {"id": 111, "name": "One", "api_access_key": "KEY-111"},
1563                    {"id": 222, "name": "Two", "api_access_key": "KEY-222"}
1564                ]
1565            })))
1566            .mount(&server)
1567            .await;
1568        // 222 holds a key of the same name. Deleting that one would be deleting the key this
1569        // login just minted for, on the wrong account.
1570        Mock::given(method("DELETE"))
1571            .and(wiremock::matchers::path_regex(
1572                r"^/accounts/cloud-api/cloudApiKeys/\d+$",
1573            ))
1574            .respond_with(ResponseTemplate::new(200))
1575            .expect(0)
1576            .named("nothing may be deleted from an account we did not reach")
1577            .mount(&server)
1578            .await;
1579
1580        let (_, revoker) = authenticator(&server)
1581            .complete_login_with_mfa(
1582                &tokens(),
1583                "redisctl-cli-2",
1584                LoginFlow::Switch,
1585                AccountChoice::Id(222),
1586                Some(SupersededKey {
1587                    account_id: 111,
1588                    key_name: "redisctl-cli-1".to_string(),
1589                }),
1590                |_, _| Ok(None),
1591            )
1592            .await
1593            .unwrap();
1594        assert!(
1595            !revoker.unwrap().revoke().await,
1596            "a switch that did not land must not be reported as a revocation"
1597        );
1598    }
1599
1600    /// Revocation deletes the key the profile recorded and nothing else. An account can hold other
1601    /// `redisctl-*` keys — a second machine, a second profile — and the names here are chosen so a
1602    /// prefix or substring match would take a neighbour.
1603    #[tokio::test]
1604    async fn revocation_targets_the_recorded_key_alone() {
1605        let server = MockServer::start().await;
1606        let cell = mock_session(&server, 111, vec![]).await;
1607        mock_keys(
1608            &server,
1609            cell,
1610            vec![
1611                (111, 1, "redisctl-cli-0"),
1612                (111, 2, "redisctl-cli-11"),
1613                (111, 3, "redisctl-cli-1"),
1614                (111, 4, "someone-elses-key"),
1615            ],
1616        )
1617        .await;
1618        // Only id 3 may be deleted; any other id has no mock and would 404.
1619        Mock::given(method("DELETE"))
1620            .and(path("/accounts/cloud-api/cloudApiKeys/3"))
1621            .respond_with(ResponseTemplate::new(200))
1622            .expect(1)
1623            .named("delete the recorded key, by exact name")
1624            .mount(&server)
1625            .await;
1626
1627        assert!(
1628            authenticator(&server)
1629                .revoke_capi_key(&tokens(), None, "redisctl-cli-1")
1630                .await
1631                .unwrap()
1632        );
1633    }
1634
1635    /// A recorded key that is not on the account deletes nothing and is reported as not revoked.
1636    /// Guessing which key was meant is the one thing worse than saying so.
1637    #[tokio::test]
1638    async fn revocation_deletes_nothing_when_the_recorded_key_is_gone() {
1639        let server = MockServer::start().await;
1640        let cell = mock_session(&server, 111, vec![]).await;
1641        mock_keys(&server, cell, vec![(111, 9, "a-key-someone-else-minted")]).await;
1642        Mock::given(method("DELETE"))
1643            .and(wiremock::matchers::path_regex(
1644                r"^/accounts/cloud-api/cloudApiKeys/\d+$",
1645            ))
1646            .respond_with(ResponseTemplate::new(200))
1647            .expect(0)
1648            .named("nothing may be deleted when the recorded key is absent")
1649            .mount(&server)
1650            .await;
1651
1652        assert!(
1653            !authenticator(&server)
1654                .revoke_capi_key(&tokens(), None, "redisctl-cli-1")
1655                .await
1656                .unwrap(),
1657            "a key that is not there cannot be reported as revoked"
1658        );
1659    }
1660
1661    /// A profile that recorded no account — written before the id was stored — searches wherever
1662    /// the session lands, which is all it can do. It must not issue a switch to nowhere.
1663    #[tokio::test]
1664    async fn revoking_by_name_without_an_account_does_not_switch() {
1665        let server = MockServer::start().await;
1666        common_login_mocks(&server).await;
1667        Mock::given(method("GET"))
1668            .and(path("/accounts/cloud-api/cloudApiKeys"))
1669            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1670                "cloudApiKeys": [{"id": 3, "name": "redisctl-cli-somewhere"}]
1671            })))
1672            .mount(&server)
1673            .await;
1674        Mock::given(method("DELETE"))
1675            .and(path("/accounts/cloud-api/cloudApiKeys/3"))
1676            .respond_with(ResponseTemplate::new(200))
1677            .expect(1)
1678            .mount(&server)
1679            .await;
1680
1681        assert!(
1682            authenticator(&server)
1683                .revoke_capi_key(&tokens(), None, "redisctl-cli-somewhere")
1684                .await
1685                .unwrap()
1686        );
1687        assert!(
1688            !server
1689                .received_requests()
1690                .await
1691                .unwrap()
1692                .iter()
1693                .any(|r| r.url.path().starts_with("/accounts/setcurrent/")),
1694            "no account was recorded, so there is nothing to switch to"
1695        );
1696    }
1697
1698    #[tokio::test]
1699    async fn complete_login_errors_when_login_rejected() {
1700        let server = MockServer::start().await;
1701        Mock::given(method("POST"))
1702            .and(path("/login"))
1703            .respond_with(ResponseTemplate::new(401).append_header("Set-Cookie", "JSESSIONID=S"))
1704            .mount(&server)
1705            .await;
1706        let auth = CloudAuthenticator::new(
1707            Url::parse("https://issuer.example/oauth2/default").unwrap(),
1708            "cid",
1709            Url::parse(&server.uri()).unwrap(),
1710            "https://capi.example/v1",
1711        );
1712        let tokens = TokenSet {
1713            access_token: "AT".into(),
1714            refresh_token: None,
1715            expires_in: 3600,
1716        };
1717        assert!(matches!(
1718            auth.complete_login(&tokens, "k", LoginFlow::Loopback, AccountChoice::Current)
1719                .await,
1720            Err(AuthError::Protocol(_))
1721        ));
1722    }
1723}