diff --git a/src/auth/commands.rs b/src/auth/commands.rs index 9a780bb..730dc99 100644 --- a/src/auth/commands.rs +++ b/src/auth/commands.rs @@ -1,4 +1,4 @@ -use clap::Arg; +use clap::{Arg, ArgAction}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -48,12 +48,25 @@ pub fn auth_command_group(default_provider: &str, registered_names: &[String]) - .mutates(true) .no_auth(true) .with_arg(provider_arg(&effective_default, registered_names)) - .with_arg(Arg::new("env").long("env").value_name("ENV").required(true)), + .with_arg(Arg::new("env").long("env").value_name("ENV").required(true)) + .with_arg( + Arg::new("scope") + .long("scope") + .short('s') + .value_name("SCOPE") + // One scope per occurrence, repeatable: `--scope a --scope b`. + // `ArgAction::Append` requires a value, so a bare `--scope` + // is rejected rather than silently doing nothing. + .action(ArgAction::Append) + .help("Additional OAuth scope to request (repeatable, one per flag)"), + ), async |context| { let provider = string_arg(&context.args, "provider"); let env = string_arg(&context.args, "env"); + let scopes = string_vec_arg(&context.args, "scope"); serde_json::to_value( - login_and_build(&context.middleware.auth, &provider, &env).await?, + login_and_build_with_scopes(&context.middleware.auth, &provider, &env, &scopes) + .await?, ) .map(CommandResult::new) .map_err(Into::into) @@ -106,6 +119,23 @@ fn string_arg(args: &serde_json::Map, name: &str) -> String { .to_owned() } +/// Reads a repeatable string argument as a `Vec`, accepting either a +/// JSON array (multiple values) or a single string. +fn string_vec_arg(args: &serde_json::Map, name: &str) -> Vec { + match args.get(name) { + // Drop empty strings: an empty scope token is never valid and only + // produces confusing auth-server errors. + Some(Value::Array(items)) => items + .iter() + .filter_map(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .collect(), + Some(Value::String(value)) if !value.is_empty() => vec![value.clone()], + _ => Vec::new(), + } +} + fn provider_arg(default_provider: &str, registered_names: &[String]) -> Arg { let names = registered_names.join(", "); let help = format!("Auth provider name (one of: [{names}])"); @@ -125,7 +155,20 @@ pub async fn login_and_build( provider: &str, env: &str, ) -> Result { - let credential = dispatcher.login(provider, env).await?; + login_and_build_with_scopes(dispatcher, provider, env, &[]).await +} + +/// Like [`login_and_build`], but requests `additional_scopes` on top of the +/// provider's defaults (used by `auth login --scope`). +pub async fn login_and_build_with_scopes( + dispatcher: &Dispatcher, + provider: &str, + env: &str, + additional_scopes: &[String], +) -> Result { + let credential = dispatcher + .login_with_scopes(provider, env, additional_scopes) + .await?; Ok(AuthLoginResult { provider: provider.to_owned(), env: env.to_owned(), diff --git a/src/auth/dispatcher.rs b/src/auth/dispatcher.rs index 31c4511..12e6ba0 100644 --- a/src/auth/dispatcher.rs +++ b/src/auth/dispatcher.rs @@ -2,7 +2,8 @@ use std::sync::{Arc, RwLock}; use async_trait::async_trait; -use super::{AuthProvider, Credential}; +use super::{AuthProvider, Credential, CredentialRequest}; +use crate::middleware::CommandMeta; use crate::{CliCoreError, Result}; /// Routes auth operations to registered providers by name. @@ -77,13 +78,41 @@ impl Dispatcher { self.get(name)?.get_credential(env, command, tier).await } + /// Gets a credential from a named provider, passing the command's full + /// [`CredentialRequest`] so metadata-aware providers (e.g. OAuth scope + /// step-up) can act on it. + pub async fn get_credential_for( + &self, + name: &str, + req: &CredentialRequest<'_>, + ) -> Result { + self.get(name)?.get_credential_for(req).await + } + /// Clears any cached credential, ignoring logout failures, then authenticates. pub async fn login(&self, name: &str, env: &str) -> Result { + self.login_with_scopes(name, env, &[]).await + } + + /// Like [`login`](Dispatcher::login), but requests `additional_scopes` on top + /// of the provider's defaults. + /// + /// The scopes are carried as [`CommandMeta::scopes`] on a synthesized + /// request; providers without scope support ignore them. + pub async fn login_with_scopes( + &self, + name: &str, + env: &str, + additional_scopes: &[String], + ) -> Result { let provider = self.get(name)?; if let Err(err) = provider.logout(env).await { tracing::debug!(provider = name, error = %err, "ignoring logout error before login"); } - provider.get_credential(env, "", "").await + let mut meta = CommandMeta::default(); + meta.set_scopes(additional_scopes.to_vec()); + let req = CredentialRequest::new(env, "", "", &meta); + provider.get_credential_for(&req).await } /// Gets cached credential status from a named provider. @@ -176,6 +205,10 @@ impl AuthProvider for SingleProvider { .await } + async fn get_credential_for(&self, req: &CredentialRequest<'_>) -> Result { + self.dispatcher.get_credential_for(&self.name, req).await + } + async fn status(&self, env: &str) -> Result { self.dispatcher.status(&self.name, env).await } diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 15dc0d9..7bcc347 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -23,8 +23,8 @@ pub mod pkce; use async_trait::async_trait; pub use commands::{ - AuthLoginResult, AuthStatusEntry, auth_command_group, login_and_build, logout_result, - status_result, to_status_entry, + AuthLoginResult, AuthStatusEntry, auth_command_group, login_and_build, + login_and_build_with_scopes, logout_result, status_result, to_status_entry, }; pub use credential::{CACHE_TTL, Credential}; pub use dispatcher::{Dispatcher, SingleProvider, StatusEntry}; @@ -34,6 +34,51 @@ pub use exec::{ }; use crate::Result; +use crate::middleware::CommandMeta; + +/// Everything an [`AuthProvider`] may inspect about the command requesting a +/// credential. +/// +/// This bundles the routing fields passed to [`AuthProvider::get_credential`] +/// (`env`, colon command path, and tier) together with the command's +/// [`CommandMeta`], so a provider can read richer metadata — for example an +/// OAuth provider reading [`CommandMeta::scopes`] to decide whether the cached +/// token is sufficient. Providers that do not need metadata can ignore it. +/// +/// Marked `#[non_exhaustive]` because the framework constructs it (providers only +/// read it) and more request fields may be added over time; build one with +/// [`CredentialRequest::new`] rather than a struct literal so adding a field is +/// not a breaking change for downstream crates. +#[derive(Clone, Copy, Debug)] +#[non_exhaustive] +pub struct CredentialRequest<'req> { + /// Target environment name. + pub env: &'req str, + /// Colon-separated command path, for example `project:list`. + pub command: &'req str, + /// Risk tier as a string, for example `read` or `mutate`. + pub tier: &'req str, + /// Metadata for the command requesting the credential. + pub meta: &'req CommandMeta, +} + +impl<'req> CredentialRequest<'req> { + /// Creates a request from the routing fields and command metadata. + #[must_use] + pub fn new( + env: &'req str, + command: &'req str, + tier: &'req str, + meta: &'req CommandMeta, + ) -> Self { + Self { + env, + command, + tier, + meta, + } + } +} #[async_trait] /// Named auth provider used by middleware and transport injectors. @@ -47,6 +92,18 @@ pub trait AuthProvider: Send + Sync + std::fmt::Debug { /// Returns a credential for `env`, `command`, and `tier`. async fn get_credential(&self, env: &str, command: &str, tier: &str) -> Result; + /// Returns a credential for a command, given its full [`CredentialRequest`]. + /// + /// The default implementation ignores the metadata and delegates to + /// [`get_credential`](AuthProvider::get_credential). Providers that act on + /// command metadata — such as an OAuth provider performing scope step-up + /// from [`CommandMeta::scopes`] — override this. The framework calls this + /// method (not `get_credential`) when resolving credentials, so an override + /// receives the command's metadata. + async fn get_credential_for(&self, req: &CredentialRequest<'_>) -> Result { + self.get_credential(req.env, req.command, req.tier).await + } + /// Returns cached credential status for one environment. async fn status(&self, env: &str) -> Result; @@ -56,3 +113,20 @@ pub trait AuthProvider: Send + Sync + std::fmt::Debug { /// Lists environments with cached credentials. async fn list_environments(&self) -> Result>; } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn credential_request_new_sets_all_fields() { + let meta = CommandMeta::default(); + let req = CredentialRequest::new("dev", "app:list", "read", &meta); + assert_eq!(req.env, "dev"); + assert_eq!(req.command, "app:list"); + assert_eq!(req.tier, "read"); + // `Copy` is preserved (using `req` after copying it must compile). + let copy = req; + assert_eq!(copy.env, req.env); + } +} diff --git a/src/auth/pkce.rs b/src/auth/pkce.rs index 83c2e32..e16e9cf 100644 --- a/src/auth/pkce.rs +++ b/src/auth/pkce.rs @@ -39,6 +39,7 @@ use std::{ collections::HashMap, + io::IsTerminal, net::{SocketAddr, TcpListener}, sync::Arc, time::Duration, @@ -54,7 +55,7 @@ use sha2::{Digest, Sha256}; use tokio::sync::RwLock; use zeroize::{Zeroize, ZeroizeOnDrop}; -use crate::{Credential, Result, auth::AuthProvider, error::CliCoreError}; +use crate::{Credential, Result, auth::AuthProvider, auth::CredentialRequest, error::CliCoreError}; const REDIRECT_PORT_DEFAULT: u16 = 7443; const TOKEN_EXPIRY_BUFFER_SECS: i64 = 30; @@ -67,6 +68,17 @@ struct StoredToken { access_token: String, expires_at: i64, refresh_token: Option, + /// Scopes the token was obtained with (granted by the authorization server, + /// or the requested set when the server does not echo `scope`). Lets scope + /// coverage work for opaque access tokens and IdPs that do not expose scopes + /// in the access token itself. Not secret, so excluded from zeroization. + /// + /// `#[serde(default)]` keeps tokens written before this field was added + /// loadable from the keychain (they decode with an empty set, falling back to + /// the JWT `scope`/`scp` claim as before). + #[serde(default)] + #[zeroize(skip)] + scopes: Vec, } impl std::fmt::Debug for StoredToken { @@ -82,6 +94,7 @@ impl std::fmt::Debug for StoredToken { &"None" }, ) + .field("scopes", &self.scopes) .finish() } } @@ -509,38 +522,63 @@ impl PkceAuthProvider { } async fn resolve_token(&self, env: &str) -> Result { - if let Some(token) = self.cached_token(env).await { + if let Some(token) = self.existing_token(env).await? { return Ok(token); } + self.reauthenticate(env, &self.scopes).await + } + + /// Returns a usable token from the in-memory cache, keychain, or a refresh — + /// **without** launching an interactive PKCE flow. `None` means the caller + /// must authenticate. Keeping this flow-free lets `get_credential_for` decide + /// the scope set for a single login instead of authenticating twice. + async fn existing_token(&self, env: &str) -> Result> { + if let Some(token) = self.cached_token(env).await { + return Ok(Some(token)); + } if let Some(token) = self.load_token_from_keychain(env).await { if token.is_valid() { self.store_cached_token(env, token.clone()).await; - return Ok(token); + return Ok(Some(token)); } if let Some(refresh_token) = token.refresh_token.as_deref() - && let Ok(mut refreshed) = self.refresh_access_token(refresh_token).await + && let Ok(mut refreshed) = self + .refresh_access_token(refresh_token, &token.scopes) + .await { if refreshed.refresh_token.is_none() { refreshed.refresh_token = Some(refresh_token.to_owned()); } self.save_token_to_keychain(env, &refreshed).await?; self.store_cached_token(env, refreshed.clone()).await; - return Ok(refreshed); + return Ok(Some(refreshed)); } } - let token = self.run_pkce_flow(env).await?; + Ok(None) + } + + /// Runs a fresh interactive PKCE flow requesting exactly `scopes`, replacing + /// any stored token for `env`. + async fn reauthenticate(&self, env: &str, scopes: &[String]) -> Result { + let token = self.run_pkce_flow_with(scopes).await?; + // Persist first — the keychain write overwrites the existing entry for + // this env — and only update the in-memory cache after a successful + // save. This avoids destroying a still-valid token if the save fails + // (e.g. keychain unavailable and file fallback disabled). self.save_token_to_keychain(env, &token).await?; self.store_cached_token(env, token.clone()).await; Ok(token) } - async fn run_pkce_flow(&self, env: &str) -> Result { + /// Runs the browser PKCE flow requesting exactly `scopes` (used both for the + /// default login and for scope step-up, which requests a wider union). + async fn run_pkce_flow_with(&self, scopes: &[String]) -> Result { let (code_verifier, code_challenge) = pkce_challenge(); let state = random_state(); let client_id = self.effective_client_id(); let auth_url = self.effective_auth_url(); let redirect_uri = self.effective_redirect_uri(); - let scope = self.scopes.join(" "); + let scope = scopes.join(" "); let auth_params = [ ("response_type", "code"), @@ -571,7 +609,7 @@ impl PkceAuthProvider { let code = wait_for_callback(listener, &state, &callback_path, Duration::from_secs(120)).await?; - self.exchange_code_for_token(&code, &code_verifier, env) + self.exchange_code_for_token(&code, &code_verifier, scopes) .await } @@ -579,7 +617,7 @@ impl PkceAuthProvider { &self, code: &str, code_verifier: &str, - env: &str, + requested_scopes: &[String], ) -> Result { let redirect_uri = self.effective_redirect_uri(); let client_id = self.effective_client_id(); @@ -607,10 +645,14 @@ impl PkceAuthProvider { ))); } - parse_token_response(response, env).await + parse_token_response(response, requested_scopes).await } - async fn refresh_access_token(&self, refresh_token: &str) -> Result { + async fn refresh_access_token( + &self, + refresh_token: &str, + prior_scopes: &[String], + ) -> Result { let client_id = self.effective_client_id(); let token_url = self.effective_token_url(); let params = [ @@ -633,7 +675,7 @@ impl PkceAuthProvider { ))); } - parse_token_response(response, "").await + parse_token_response(response, prior_scopes).await } } @@ -648,6 +690,42 @@ impl AuthProvider for PkceAuthProvider { Ok(self.build_credential(env, &token)) } + async fn get_credential_for(&self, req: &CredentialRequest<'_>) -> Result { + let env = req.env; + let required = &req.meta.scopes; + + // Look for a usable token WITHOUT launching a flow, so we can pick the + // scope set for a single login rather than authenticating twice (e.g. + // `auth login --scope X` logs out first; resolving defaults and then + // stepping up would open the browser twice). + if let Some(token) = self.existing_token(env).await? { + // Decide based on what the token grants (JWT claim plus the scopes it + // was obtained with). Step-up means re-consent: the authorization + // server has no silent scope-expansion grant, so in non-interactive + // contexts we fail fast rather than hang on the callback timeout. + let granted = granted_scopes(&token); + match plan_step_up(&self.scopes, &granted, required, session_is_interactive()) { + StepUp::Covered => return Ok(self.build_credential(env, &token)), + StepUp::MissingNonInteractive(missing) => { + return Err(missing_scope_error(env, &missing)); + } + // Union (defaults ∪ already-granted ∪ required) so step-up never + // drops scopes acquired by an earlier login or step-up. + StepUp::Reauthenticate(union) => { + let token = self.reauthenticate(env, &union).await?; + ensure_granted(env, &token, required)?; + return Ok(self.build_credential(env, &token)); + } + } + } + + // No usable token: authenticate once, requesting defaults ∪ required. + let union = union_scopes(&self.scopes, &[], required); + let token = self.reauthenticate(env, &union).await?; + ensure_granted(env, &token, required)?; + Ok(self.build_credential(env, &token)) + } + async fn status(&self, env: &str) -> Result { let Some(token) = self.load_token_from_keychain(env).await else { return Err(CliCoreError::message(format!( @@ -1013,15 +1091,21 @@ struct TokenResponse { access_token: String, expires_in: Option, refresh_token: Option, + /// Space-delimited scopes the server actually granted, when it echoes them. + scope: Option, } /// Decodes the claims (payload) segment of a JWT **without verifying the /// signature**. /// -/// The returned claims are used only to display a human-readable identity in -/// `auth status` and audit logs — never for trust or authorization decisions, so -/// signature verification is intentionally skipped. Opaque (non-JWT) tokens and -/// any decode/parse failure yield `None`, leaving the identity blank. +/// The returned claims are used to display a human-readable identity in +/// `auth status` and audit logs, and (via [`scopes_from_jwt`]) to decide whether +/// scope step-up needs a fresh login. These are convenience/optimization reads, +/// **not** trust or authorization decisions — the authorization server remains +/// the source of truth for granted scopes — so signature verification is +/// intentionally skipped. Opaque (non-JWT) tokens and any decode/parse failure +/// yield `None`, leaving the identity blank (and treating scopes as absent, which +/// just forces a re-auth). fn decode_jwt_claims(token: &str) -> Option> { // A JWT is `header.payload.signature`; the payload is the middle segment, // base64url-encoded without padding. @@ -1030,6 +1114,152 @@ fn decode_jwt_claims(token: &str) -> Option> { serde_json::from_slice(&bytes).ok() } +/// Returns `defaults ∪ granted ∪ required`, order-preserving and de-duplicated. +fn union_scopes(defaults: &[String], granted: &[String], required: &[String]) -> Vec { + let mut union = defaults.to_vec(); + for scope in granted.iter().chain(required.iter()) { + if !union.contains(scope) { + union.push(scope.clone()); + } + } + union +} + +/// Reads the granted scopes from a JWT access token. +/// +/// OAuth uses a space-delimited `scope` string (RFC), but some IdPs (e.g. Azure +/// AD) use `scp`, and either may be encoded as a JSON array — so all of those +/// forms are accepted. Returns an empty list for opaque (non-JWT) tokens or +/// tokens without a recognized scope claim; coverage then falls back to the +/// scopes recorded on the [`StoredToken`] (see [`granted_scopes`]). +fn scopes_from_jwt(token: &str) -> Vec { + let Some(claims) = decode_jwt_claims(token) else { + return Vec::new(); + }; + for key in ["scope", "scp"] { + if let Some(value) = claims.get(key) { + let scopes = scopes_from_claim(value); + if !scopes.is_empty() { + return scopes; + } + } + } + Vec::new() +} + +/// Parses a scope claim that may be a space-delimited string or a JSON array of +/// (possibly space-delimited) strings. +fn scopes_from_claim(value: &Value) -> Vec { + match value { + Value::String(scope) => scope.split_whitespace().map(str::to_owned).collect(), + Value::Array(items) => items + .iter() + .filter_map(Value::as_str) + .flat_map(str::split_whitespace) + .map(str::to_owned) + .collect(), + _ => Vec::new(), + } +} + +/// All scopes an access token is known to carry: the JWT `scope`/`scp` claim +/// plus the scopes recorded when the token was obtained. The recorded scopes +/// make coverage work for opaque tokens and IdPs that omit scopes from the +/// access token. +fn granted_scopes(token: &StoredToken) -> Vec { + let mut scopes = scopes_from_jwt(&token.access_token); + for scope in &token.scopes { + if !scopes.contains(scope) { + scopes.push(scope.clone()); + } + } + scopes +} + +/// The action scope step-up should take for a token, given what it already +/// grants and what the command requires. Pure so the decision is unit-testable +/// without real TTY detection or a browser flow. +#[derive(Debug, PartialEq, Eq)] +enum StepUp { + /// The token already covers every required scope. + Covered, + /// Re-authenticate requesting this scope set (defaults ∪ granted ∪ required). + Reauthenticate(Vec), + /// Scopes are missing but the session is non-interactive, so step-up cannot + /// prompt; carries the missing scopes for the error message. + MissingNonInteractive(Vec), +} + +fn plan_step_up( + defaults: &[String], + granted: &[String], + required: &[String], + interactive: bool, +) -> StepUp { + let missing: Vec = required + .iter() + .filter(|scope| !granted.iter().any(|have| have == *scope)) + .cloned() + .collect(); + if missing.is_empty() { + StepUp::Covered + } else if interactive { + StepUp::Reauthenticate(union_scopes(defaults, granted, required)) + } else { + StepUp::MissingNonInteractive(missing) + } +} + +/// Treats the session as interactive if any stdio stream is a TTY, so +/// redirecting one (e.g. capturing stderr) does not block a user who can still +/// complete the browser flow. +fn session_is_interactive() -> bool { + std::io::stdin().is_terminal() + || std::io::stdout().is_terminal() + || std::io::stderr().is_terminal() +} + +/// Confirms a freshly (re)authenticated token actually grants `required`. +/// +/// Re-consent does not guarantee the authorization server grants every requested +/// scope (it may decline by policy). When the difference is detectable — the +/// token is a JWT exposing its scopes, or the token response echoed a narrower +/// `scope` — return a clear error instead of handing back an under-scoped token +/// that the API would later reject with a 403, and instead of re-prompting in a +/// loop the server will keep refusing. (For opaque tokens whose grant the server +/// does not echo, the recorded scopes equal what was requested, so an undetected +/// decline still surfaces downstream as a 403.) +fn ensure_granted(env: &str, token: &StoredToken, required: &[String]) -> Result<()> { + let granted = granted_scopes(token); + let missing: Vec = required + .iter() + .filter(|scope| !granted.iter().any(|have| have == *scope)) + .cloned() + .collect(); + if missing.is_empty() { + Ok(()) + } else { + Err(CliCoreError::message(format!( + "authorization server did not grant required scope(s) for {env:?}: {}", + missing.join(", ") + ))) + } +} + +/// Error returned when required scopes are missing and step-up cannot prompt. +fn missing_scope_error(env: &str, missing: &[String]) -> CliCoreError { + let display = missing.join(", "); + let hint = missing + .iter() + .map(|scope| format!("--scope {scope}")) + .collect::>() + .join(" "); + CliCoreError::message(format!( + "access token for {env:?} is missing required scope(s): {display}; \ + run `auth login --env {env} {hint}` in an interactive terminal" + )) +} + /// Returns the first claim value that is a non-empty string, in priority order. fn extract_identity(claims: &Map, priority: &[String]) -> String { priority @@ -1040,17 +1270,35 @@ fn extract_identity(claims: &Map, priority: &[String]) -> String .to_owned() } -async fn parse_token_response(response: reqwest::Response, _env: &str) -> Result { +async fn parse_token_response( + response: reqwest::Response, + requested_scopes: &[String], +) -> Result { let body: TokenResponse = response .json() .await .map_err(|err| CliCoreError::message(format!("failed to parse token response: {err}")))?; let expires_in = body.expires_in.unwrap_or(3600); let expires_at = Utc::now().timestamp() + expires_in; + // Record what the token grants: the server's echoed `scope` when present, + // otherwise the scopes we asked for. This is the coverage signal for opaque + // tokens, which carry no readable scope claim. + let scopes = body + .scope + .as_deref() + .map(|scope| { + scope + .split_whitespace() + .map(str::to_owned) + .collect::>() + }) + .filter(|scopes| !scopes.is_empty()) + .unwrap_or_else(|| requested_scopes.to_vec()); Ok(StoredToken { access_token: body.access_token, expires_at, refresh_token: body.refresh_token, + scopes, }) } @@ -1113,6 +1361,18 @@ mod tests { access_token: access_token.to_owned(), expires_at: Utc::now().timestamp() + 3600, refresh_token: None, + scopes: Vec::new(), + } + } + + fn token_with_scopes(access_token: &str, scopes: &[&str]) -> StoredToken { + // No struct-update from `valid_token`: StoredToken is `Drop` + // (ZeroizeOnDrop), so fields cannot be moved out of another instance. + StoredToken { + access_token: access_token.to_owned(), + expires_at: Utc::now().timestamp() + 3600, + refresh_token: None, + scopes: scopes.iter().map(|s| (*s).to_owned()).collect(), } } @@ -1122,6 +1382,7 @@ mod tests { // Older than the expiry buffer so is_valid() returns false. expires_at: Utc::now().timestamp() - TOKEN_EXPIRY_BUFFER_SECS - 1, refresh_token: None, + scopes: Vec::new(), } } @@ -1155,6 +1416,169 @@ mod tests { ); } + #[test] + fn scopes_from_jwt_parses_scope_claim() { + let token = make_jwt(&json!({ "scope": "a b c" })); + assert_eq!(scopes_from_jwt(&token), vec!["a", "b", "c"]); + } + + #[test] + fn scopes_from_jwt_parses_scp_and_array_claims() { + // Azure-style `scp` array. + let scp = make_jwt(&json!({ "scp": ["a", "b"] })); + assert_eq!(scopes_from_jwt(&scp), vec!["a", "b"]); + // `scope` encoded as an array. + let array = make_jwt(&json!({ "scope": ["a", "b c"] })); + assert_eq!(scopes_from_jwt(&array), vec!["a", "b", "c"]); + // Empty `scope` falls through to `scp`. + let mixed = make_jwt(&json!({ "scope": "", "scp": ["x"] })); + assert_eq!(scopes_from_jwt(&mixed), vec!["x"]); + } + + #[test] + fn granted_scopes_uses_recorded_scopes_for_opaque_token() { + // An opaque (non-JWT) token carries no readable claim, so coverage comes + // from the scopes recorded when it was obtained. + let token = token_with_scopes("opaque-token", &["a", "b"]); + assert_eq!(granted_scopes(&token), vec!["a", "b"]); + } + + #[test] + fn ensure_granted_rejects_a_token_missing_required_scopes() { + let required = vec!["a".to_owned(), "b".to_owned()]; + // JWT that exposes only `a` → `b` is detectably not granted. + let jwt = valid_token(&make_jwt(&json!({ "scope": "a" }))); + let err = ensure_granted("dev", &jwt, &required).expect_err("b is not granted"); + assert!( + err.to_string().contains("did not grant required scope(s)"), + "{err}" + ); + assert!(err.to_string().contains('b'), "{err}"); + + // A token granting both passes. + let ok = valid_token(&make_jwt(&json!({ "scope": "a b" }))); + ensure_granted("dev", &ok, &required).expect("both granted"); + // Recorded scopes (opaque token) also satisfy the check. + let opaque = token_with_scopes("opaque", &["a", "b"]); + ensure_granted("dev", &opaque, &required).expect("recorded scopes granted"); + } + + #[test] + fn plan_step_up_covers_reauths_and_fails_non_interactive() { + let defaults = vec!["base".to_owned()]; + let granted = vec!["base".to_owned(), "read".to_owned()]; + let read = vec!["read".to_owned()]; + let write = vec!["write".to_owned()]; + + // Already covered. + assert_eq!( + plan_step_up(&defaults, &granted, &read, true), + StepUp::Covered + ); + // Missing + interactive → reauth requesting the union. + assert_eq!( + plan_step_up(&defaults, &granted, &write, true), + StepUp::Reauthenticate(vec![ + "base".to_owned(), + "read".to_owned(), + "write".to_owned() + ]) + ); + // Missing + non-interactive → fail fast, carrying the missing scopes. + assert_eq!( + plan_step_up(&defaults, &granted, &write, false), + StepUp::MissingNonInteractive(vec!["write".to_owned()]) + ); + } + + /// An opaque cached token whose recorded scopes cover the requirement is + /// returned without starting a flow — proving coverage no longer depends on + /// a readable JWT scope claim. + #[tokio::test] + async fn get_credential_for_uses_recorded_scopes_for_opaque_token() { + let provider = test_provider(); + provider + .store_cached_token("dev", token_with_scopes("opaque-token", &["read", "write"])) + .await; + + let meta = crate::middleware::CommandMeta { + scopes: vec!["read".to_owned()], + ..crate::middleware::CommandMeta::default() + }; + let req = CredentialRequest::new("dev", "app:list", "read", &meta); + let credential = provider + .get_credential_for(&req) + .await + .expect("recorded scopes cover the requirement"); + assert_eq!(credential.token, "opaque-token"); + } + + #[test] + fn union_scopes_dedupes_and_preserves_order() { + let defaults = vec!["a".to_owned(), "b".to_owned()]; + let granted = vec!["b".to_owned(), "c".to_owned()]; + let required = vec!["c".to_owned(), "d".to_owned()]; + assert_eq!( + union_scopes(&defaults, &granted, &required), + vec!["a", "b", "c", "d"] + ); + } + + #[test] + fn scopes_from_jwt_empty_for_opaque_or_missing() { + assert!(scopes_from_jwt("opaque-token").is_empty()); + let no_scope = make_jwt(&json!({ "sub": "user" })); + assert!(scopes_from_jwt(&no_scope).is_empty()); + } + + /// When the cached token's JWT already covers the required scopes, + /// `get_credential_for` must return it without starting a PKCE flow. + #[tokio::test] + async fn get_credential_for_uses_cached_token_when_scopes_covered() { + let provider = test_provider(); + let token = valid_token(&make_jwt(&json!({ + "scope": "apps.app-registry:read apps.app-registry:write", + "sub": "user-1", + }))); + provider.store_cached_token("dev", token).await; + + let mut meta = crate::middleware::CommandMeta::default(); + meta.set_scopes(vec!["apps.app-registry:read".to_owned()]); + let req = CredentialRequest { + env: "dev", + command: "app:list", + tier: "read", + meta: &meta, + }; + let credential = provider + .get_credential_for(&req) + .await + .expect("cached token covers required scopes"); + assert_eq!(credential.sub, "user-1"); + } + + /// With no required scopes, `get_credential_for` behaves like + /// `get_credential` and returns the cached token unchanged. + #[tokio::test] + async fn get_credential_for_no_scopes_returns_cached() { + let provider = test_provider(); + provider + .store_cached_token("dev", valid_token("opaque")) + .await; + let meta = crate::middleware::CommandMeta::default(); + let req = CredentialRequest { + env: "dev", + command: "app:list", + tier: "read", + meta: &meta, + }; + let credential = provider + .get_credential_for(&req) + .await + .expect("no scopes required"); + assert_eq!(credential.token, "opaque"); + } + #[test] fn redirect_uri_default_uses_127_0_0_1_and_redirect_port() { let provider = test_provider().with_redirect_port(9000); diff --git a/src/command.rs b/src/command.rs index d5a7ec3..145e658 100644 --- a/src/command.rs +++ b/src/command.rs @@ -144,6 +144,31 @@ impl CommandContext { pub async fn try_credential(&self) -> Result> { self.credential.try_resolve().await } + + /// Resolves a credential that additionally covers `extra` scopes, on top of + /// the command's declared scopes. + /// + /// Use this when the required scopes are only known at runtime (for example + /// a generic API caller that derives scopes from the target endpoint). A + /// scope-aware auth provider re-authenticates when the cached token does not + /// already cover the requested set. + /// + /// Convenience wrapper over + /// [`self.credential.resolve_with_scopes()`](CredentialResolver::resolve_with_scopes). + /// + /// If the handler also issues HTTP requests through the transport bearer + /// injector, call this **before** the first request: the injector resolves + /// and caches a scope-unaware token, so stepping up afterwards would not + /// affect requests it already authorized. See + /// [`CredentialResolver::resolve_with_scopes`] for the full ordering note. + /// + /// # Errors + /// + /// Returns an error when the command is marked `no_auth`, or when the auth + /// provider fails to produce a credential. + pub async fn credential_with_scopes(&self, extra: &[String]) -> Result { + self.credential.resolve_with_scopes(extra).await + } } /// Declarative leaf command metadata and parser arguments. @@ -310,6 +335,30 @@ impl CommandSpec { self } + /// Declares the OAuth scopes this command requires. + /// + /// Sugar over [`with_auth_metadata`](CommandSpec::with_auth_metadata) with the + /// `"scopes"` key (whitespace-joined). The scopes surface on + /// [`CommandMeta::scopes`](crate::CommandMeta) and reach the auth provider via + /// [`CredentialRequest`](crate::CredentialRequest); a provider that supports + /// scope step-up re-authenticates when the cached token lacks them. + #[must_use] + pub fn with_scopes(mut self, scopes: &[impl AsRef]) -> Self { + let joined = scopes + .iter() + .map(AsRef::as_ref) + .collect::>() + .join(" "); + // Mirror `CommandMeta::set_scopes`: an empty list clears the key rather + // than leaving an empty-but-present `auth_metadata["scopes"]`. + if joined.is_empty() { + self.auth_metadata.remove("scopes"); + } else { + self.auth_metadata.insert("scopes".to_owned(), joined); + } + self + } + /// Adds a `clap` argument or option to this command. #[must_use] pub fn with_arg(mut self, arg: Arg) -> Self { diff --git a/src/lib.rs b/src/lib.rs index 27137e8..b20330f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -89,9 +89,9 @@ pub mod transport; pub mod tree; pub use auth::{ - AuthLoginResult, AuthProvider, AuthStatusEntry, CACHE_TTL, Credential, Dispatcher, - SingleProvider, StatusEntry, auth_command_group, login_and_build, logout_result, status_result, - to_status_entry, + AuthLoginResult, AuthProvider, AuthStatusEntry, CACHE_TTL, Credential, CredentialRequest, + Dispatcher, SingleProvider, StatusEntry, auth_command_group, login_and_build, + login_and_build_with_scopes, logout_result, status_result, to_status_entry, }; pub use cli::{ ApplyFlags, Argv0LinkMethod, Argv0Route, BuildInfo, Cli, CliConfig, CliRunOutput, diff --git a/src/middleware.rs b/src/middleware.rs index 9175a73..59888a1 100644 --- a/src/middleware.rs +++ b/src/middleware.rs @@ -8,10 +8,10 @@ use std::{ use async_trait::async_trait; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; -use tokio::sync::OnceCell; +use tokio::sync::{Mutex, OnceCell}; use crate::{ - CommandResult, Credential, Dispatcher, Result, SchemaRegistry, Tier, + CommandResult, Credential, CredentialRequest, Dispatcher, Result, SchemaRegistry, Tier, error::{CliCoreError, exit_code_for_error}, output::{ Envelope, HumanViewRegistry, OutputFormat, PipelineOpts, apply_pipeline, @@ -57,6 +57,23 @@ impl CommandMeta { pub fn fixed_env(&self) -> Option<&str> { self.auth_metadata.get("fixed_env").map(String::as_str) } + + /// Sets the OAuth scopes, keeping [`scopes`](CommandMeta::scopes) and + /// `auth_metadata["scopes"]` consistent. + /// + /// `scopes` is documented as derived from `auth_metadata["scopes"]`, so any + /// code that synthesizes or widens scopes (e.g. runtime step-up) should use + /// this rather than assigning the field directly, so metadata-aware providers + /// reading `auth_metadata` see the same set. An empty list removes the key. + pub fn set_scopes(&mut self, scopes: Vec) { + if scopes.is_empty() { + self.auth_metadata.remove("scopes"); + } else { + self.auth_metadata + .insert("scopes".to_owned(), scopes.join(" ")); + } + self.scopes = scopes; + } } /// Declares whether a command requires an authenticated credential. @@ -118,17 +135,23 @@ impl AuthRequirement { /// Resolves the credential for a single command invocation, memoizing the result. /// -/// Resolution — including any interactive browser/OAuth flow — runs at most once: -/// a handler and an authorizer that both ask share a single resolution, and the -/// engine resolves it up front for [`AuthRequirement::Required`] commands. For -/// [`Optional`](AuthRequirement::Optional) commands resolution is deferred until a -/// handler or authorizer calls [`resolve`](Self::resolve) or -/// [`try_resolve`](Self::try_resolve), and `--schema`/`--dry-run` short-circuit -/// before any resolution happens. +/// Resolution — including any interactive browser/OAuth flow — runs once for a +/// given scope set: a handler and an authorizer that both ask share a single +/// resolution, and the engine resolves it up front for +/// [`AuthRequirement::Required`] commands. For [`Optional`](AuthRequirement::Optional) +/// commands resolution is deferred until a handler or authorizer calls +/// [`resolve`](Self::resolve) or [`try_resolve`](Self::try_resolve), and +/// `--schema`/`--dry-run` short-circuit before any resolution happens. /// -/// The resolved credential is memoized: a handler and an authorizer that both -/// ask share a single resolution. Clones share the same underlying state, so the -/// engine can observe (via [`peek`](Self::peek)) whatever a handler resolved. +/// [`resolve_with_scopes`](Self::resolve_with_scopes) may trigger an *additional* +/// resolution when it needs scopes the memoized credential does not yet cover +/// (OAuth scope step-up); a scope-aware provider then re-authenticates for the +/// wider set. Resolutions are serialized, so concurrent callers never launch +/// overlapping interactive flows. +/// +/// The resolved credential is memoized: callers that need no new scopes share a +/// single resolution. Clones share the same underlying state, so the engine can +/// observe (via [`peek`](Self::peek)) whatever a handler resolved. #[derive(Clone)] pub struct CredentialResolver { inner: Arc, @@ -142,9 +165,28 @@ struct ResolverInner { command_path: String, tier: String, no_auth: bool, + /// Static command metadata; `meta.scopes` are always requested. + meta: CommandMeta, + /// Authoritative resolved credential plus the scopes it was requested with. + /// Serializes concurrent resolution and lets scope step-up replace a + /// previously-resolved (narrower) credential. + state: Mutex, + /// Write-once mirror of the first resolved credential so [`CredentialResolver::peek`] + /// can lend a reference without holding a lock. `peek` (used for audit/activity + /// identity) therefore reflects the *first* resolved credential and is not + /// replaced by a later step-up. That is sound because step-up is required to + /// re-authenticate the *same* identity: [`resolve_scopes`](CredentialResolver::resolve_scopes) + /// aborts if a step-up returns a different account, so the mirrored identity + /// always matches the identity that performed every action in the command. cell: OnceCell, } +#[derive(Debug, Default)] +struct ResolveState { + credential: Option, + requested: Vec, +} + impl std::fmt::Debug for CredentialResolver { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter @@ -165,6 +207,7 @@ impl CredentialResolver { command_path: String, tier: String, no_auth: bool, + meta: CommandMeta, ) -> Self { Self { inner: Arc::new(ResolverInner { @@ -174,6 +217,8 @@ impl CredentialResolver { command_path, tier, no_auth, + meta, + state: Mutex::new(ResolveState::default()), cell: OnceCell::new(), }), } @@ -192,27 +237,96 @@ impl CredentialResolver { "command is marked no_auth and has no credential", )); } + self.resolve_scopes(&[]).await + } + + /// Resolves a credential that additionally covers `extra` scopes (on top of + /// the command's declared [`CommandMeta::scopes`]). + /// + /// Used by handlers whose required scopes are only known at runtime (for + /// example a generic `api call` that derives scopes from the target + /// endpoint). A scope-aware auth provider re-authenticates when the cached + /// token does not already cover the requested set. + /// + /// # Ordering with the transport injector + /// + /// The HTTP transport's bearer injector resolves its token through the + /// provider's scope-*unaware* path and caches the first token it sees for the + /// injector's lifetime. So when a handler both steps up scopes and makes HTTP + /// calls through that injector, call `resolve_with_scopes` (or + /// [`CommandContext::credential_with_scopes`](crate::CommandContext::credential_with_scopes)) + /// **before** the first request: that populates the provider cache with the + /// wider-scoped token, which the injector then picks up. Resolving after the + /// injector's first `inject` would send the narrower token. + /// + /// # Errors + /// + /// Returns an error when the command is marked + /// [`no_auth`](crate::CommandSpec::no_auth), or when the auth provider fails + /// to produce a credential. + pub async fn resolve_with_scopes(&self, extra: &[String]) -> Result { + if self.inner.no_auth { + return Err(CliCoreError::message( + "command is marked no_auth and has no credential", + )); + } + self.resolve_scopes(extra).await + } + + /// Shared resolution: returns the memoized credential when it already covers + /// the wanted scopes, otherwise (re)authenticates requesting the union and + /// updates the memoized credential. + async fn resolve_scopes(&self, extra: &[String]) -> Result { let inner = &self.inner; + let mut want = inner.meta.scopes.clone(); + for scope in extra { + if !want.contains(scope) { + want.push(scope.clone()); + } + } + + let mut state = inner.state.lock().await; + if let Some(credential) = &state.credential + && want.iter().all(|scope| state.requested.contains(scope)) + { + return Ok(credential.clone()); + } + + let mut requested = state.requested.clone(); + for scope in &want { + if !requested.contains(scope) { + requested.push(scope.clone()); + } + } + let mut meta = inner.meta.clone(); + meta.set_scopes(requested.clone()); + let req = CredentialRequest::new(&inner.env, &inner.command_path, &inner.tier, &meta); let credential = inner - .cell - .get_or_try_init(async || { - inner - .auth - .get_credential( - &inner.provider, - &inner.env, - &inner.command_path, - &inner.tier, - ) - .await - // Mark resolution failures so the engine can classify them as - // `auth-error` based on the error a handler actually returns, - // rather than tracking a separate side-channel flag that could - // go stale if the handler swallows the failure. - .map_err(|source| auth_resolution_error(&inner.provider, source)) - }) - .await?; - Ok(credential.clone()) + .auth + .get_credential_for(&inner.provider, &req) + .await + // Mark resolution failures so the engine can classify them as + // `auth-error` based on the error a handler actually returns. + .map_err(|source| auth_resolution_error(&inner.provider, source))?; + // Guard against a step-up that re-authenticates as a *different* identity. + // `peek` (audit/activity identity) reflects the first resolution, so a + // silent account switch would misattribute the elevated action. Abort + // rather than proceed under a mismatched identity. + if let Some(previous) = &state.credential { + let previous_key = identity_key(previous); + let new_key = identity_key(&credential); + if !previous_key.is_empty() && !new_key.is_empty() && previous_key != new_key { + return Err(CliCoreError::message(format!( + "scope step-up authenticated as a different identity \ + (was {previous_key:?}, now {new_key:?}); aborting" + ))); + } + } + state.credential = Some(credential.clone()); + state.requested = requested; + // Mirror the first resolution for `peek`; ignored once already set. + drop(inner.cell.set(credential.clone())); + Ok(credential) } /// Resolves the credential when one is available. @@ -256,6 +370,17 @@ fn auth_resolution_error(provider: &str, source: CliCoreError) -> CliCoreError { } } +/// Stable identity discriminator for a credential: the subject (`sub`) when set, +/// otherwise the human identity. Empty when the provider exposes neither, in +/// which case the step-up identity guard cannot (and does not) compare. +fn identity_key(credential: &Credential) -> &str { + if credential.sub.is_empty() { + credential.identity.as_str() + } else { + credential.sub.as_str() + } +} + #[async_trait] /// Authorization hook called before business logic. /// @@ -468,6 +593,7 @@ impl Middleware { command_path.to_owned(), tier_text, no_auth, + meta.clone(), ); if no_auth diff --git a/src/transport/injector.rs b/src/transport/injector.rs index 6251b72..1ade0ea 100644 --- a/src/transport/injector.rs +++ b/src/transport/injector.rs @@ -172,6 +172,13 @@ impl AuthInjector for ProviderBearerInjector { async fn inject(&self, request: &mut reqwest::Request) -> Result<()> { let mut cached = self.token.lock().await; if cached.as_deref().is_none_or(str::is_empty) { + // Scope-unaware on purpose: this fetches whatever token the provider + // has for `env` (no command scopes) and caches it for the injector's + // lifetime. A handler needing OAuth scope step-up over HTTP must + // resolve the wider scopes first (CredentialResolver::resolve_with_scopes), + // which populates the provider cache so the token fetched here already + // covers them; resolving after the first inject would send the + // narrower token. let credential = self .provider .get_credential(&self.env, "", "") diff --git a/tests/foundation.rs b/tests/foundation.rs index 19335d4..c9700b2 100644 --- a/tests/foundation.rs +++ b/tests/foundation.rs @@ -3124,6 +3124,16 @@ fn command_spec_metadata_matches_legacy_annotation_resolver_behavior() { assert_eq!(meta.scopes, vec!["read:apps", "write:apps"]); } +#[test] +fn command_spec_with_scopes_round_trips_through_metadata() { + let spec = CommandSpec::new("get", "Get").with_scopes(&["commerce.business:read", "x:y"]); + + let meta = spec.metadata(); + + assert_eq!(meta.auth_metadata["scopes"], "commerce.business:read x:y"); + assert_eq!(meta.scopes, vec!["commerce.business:read", "x:y"]); +} + #[test] fn command_spec_metadata_leaves_provider_unset_by_default() { let spec = CommandSpec::new("list", "List"); @@ -6097,6 +6107,84 @@ async fn middleware_run_does_not_override_explicit_env_arg() { assert_eq!(authz.args().await[0].get("env"), Some(&json!("staging"))); } +#[tokio::test] +async fn middleware_passes_command_scopes_to_provider_and_supports_step_up() { + let recorded = Arc::new(Mutex::new(Vec::>::new())); + let mut middleware = Middleware::new(); + middleware.auth.register(Arc::new(RecordingScopeProvider { + scopes: Arc::clone(&recorded), + })); + middleware.default_auth_provider = "primary".to_owned(); + middleware.app_id = "test-app".to_owned(); + middleware.output_format = "json".to_owned(); + middleware.env = "prod".to_owned(); + + let mut meta = CommandMeta::default(); + meta.set_scopes(vec!["base:read".to_owned()]); + middleware + .run( + middleware_request(meta, "things:list", value_map([]), value_map([]), "", false), + async |credential: CredentialResolver| { + // Static command scopes reach the provider. + credential.resolve().await.expect("resolve"); + // A runtime-required scope triggers a re-resolution requesting + // the union of declared + extra scopes. + credential + .resolve_with_scopes(&["extra:write".to_owned()]) + .await + .expect("resolve with scopes"); + // Already-covered scopes do not re-call the provider. + credential + .resolve_with_scopes(&["extra:write".to_owned()]) + .await + .expect("resolve with covered scopes"); + Ok(CommandResult::new(json!({}))) + }, + ) + .await + .expect("middleware success should render"); + + let calls = recorded.lock().await.clone(); + assert_eq!(calls.len(), 2, "third request was already covered"); + assert_eq!(calls[0], vec!["base:read"]); + assert_eq!(calls[1], vec!["base:read", "extra:write"]); +} + +#[tokio::test] +async fn middleware_aborts_step_up_that_switches_identity() { + let mut middleware = Middleware::new(); + middleware + .auth + .register(Arc::new(SwitchingIdentityProvider { + calls: Arc::new(Mutex::new(0)), + })); + middleware.default_auth_provider = "primary".to_owned(); + middleware.app_id = "test-app".to_owned(); + middleware.output_format = "json".to_owned(); + middleware.env = "prod".to_owned(); + + let mut meta = CommandMeta::default(); + meta.set_scopes(vec!["base:read".to_owned()]); + middleware + .run( + middleware_request(meta, "things:list", value_map([]), value_map([]), "", false), + async |credential: CredentialResolver| { + // First resolution authenticates as user-a (also the `peek` identity). + credential.resolve().await.expect("first resolve"); + // Step-up forces a fresh resolution; the provider returns user-b, + // so the engine must refuse rather than misattribute the action. + let err = credential + .resolve_with_scopes(&["extra:write".to_owned()]) + .await + .expect_err("identity switch during step-up must abort"); + assert!(err.to_string().contains("different identity"), "{err}"); + Ok(CommandResult::new(json!({}))) + }, + ) + .await + .expect("middleware renders"); +} + #[tokio::test] async fn middleware_fixed_env_overrides_only_auth_env_preserves_legacy() { let captured_env = Arc::new(Mutex::new(Vec::new())); @@ -9545,6 +9633,106 @@ impl AuthProvider for RecordingEnvProvider { } } +/// Records the `meta.scopes` of every `get_credential_for` call, so tests can +/// assert that command scopes (and runtime step-up scopes) reach the provider. +#[derive(Debug)] +struct RecordingScopeProvider { + scopes: Arc>>>, +} + +#[async_trait] +impl AuthProvider for RecordingScopeProvider { + fn name(&self) -> &str { + "primary" + } + + async fn get_credential(&self, env: &str, _command: &str, _tier: &str) -> Result { + // Reached only if the framework bypasses get_credential_for; record an + // empty scope set so such a regression is visible. + self.scopes.lock().await.push(Vec::new()); + Ok(Credential { + token: "token".to_owned(), + env: env.to_owned(), + ..Credential::default() + }) + } + + async fn get_credential_for( + &self, + req: &cli_engine::CredentialRequest<'_>, + ) -> Result { + self.scopes.lock().await.push(req.meta.scopes.clone()); + Ok(Credential { + token: "token".to_owned(), + env: req.env.to_owned(), + ..Credential::default() + }) + } + + async fn status(&self, env: &str) -> Result { + self.get_credential(env, "", "").await + } + + async fn logout(&self, _env: &str) -> Result<()> { + Ok(()) + } + + async fn list_environments(&self) -> Result> { + Ok(Vec::new()) + } +} + +/// Returns identity `user-a` on its first credential call and `user-b` after, +/// so a step-up re-resolution observes a different account. +#[derive(Debug)] +struct SwitchingIdentityProvider { + calls: Arc>, +} + +impl SwitchingIdentityProvider { + async fn next_credential(&self, env: &str) -> Credential { + let mut calls = self.calls.lock().await; + *calls += 1; + let sub = if *calls == 1 { "user-a" } else { "user-b" }; + Credential { + token: "token".to_owned(), + env: env.to_owned(), + sub: sub.to_owned(), + ..Credential::default() + } + } +} + +#[async_trait] +impl AuthProvider for SwitchingIdentityProvider { + fn name(&self) -> &str { + "primary" + } + + async fn get_credential(&self, env: &str, _command: &str, _tier: &str) -> Result { + Ok(self.next_credential(env).await) + } + + async fn get_credential_for( + &self, + req: &cli_engine::CredentialRequest<'_>, + ) -> Result { + Ok(self.next_credential(req.env).await) + } + + async fn status(&self, env: &str) -> Result { + self.get_credential(env, "", "").await + } + + async fn logout(&self, _env: &str) -> Result<()> { + Ok(()) + } + + async fn list_environments(&self) -> Result> { + Ok(Vec::new()) + } +} + #[derive(Debug)] struct FailingProvider;