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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 47 additions & 4 deletions src/auth/commands.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use clap::Arg;
use clap::{Arg, ArgAction};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -106,6 +119,23 @@ fn string_arg(args: &serde_json::Map<String, Value>, name: &str) -> String {
.to_owned()
}

/// Reads a repeatable string argument as a `Vec<String>`, accepting either a
/// JSON array (multiple values) or a single string.
fn string_vec_arg(args: &serde_json::Map<String, Value>, name: &str) -> Vec<String> {
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}])");
Expand All @@ -125,7 +155,20 @@ pub async fn login_and_build(
provider: &str,
env: &str,
) -> Result<AuthLoginResult> {
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<AuthLoginResult> {
let credential = dispatcher
.login_with_scopes(provider, env, additional_scopes)
.await?;
Ok(AuthLoginResult {
provider: provider.to_owned(),
env: env.to_owned(),
Expand Down
37 changes: 35 additions & 2 deletions src/auth/dispatcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<Credential> {
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<Credential> {
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<Credential> {
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.
Expand Down Expand Up @@ -176,6 +205,10 @@ impl AuthProvider for SingleProvider {
.await
}

async fn get_credential_for(&self, req: &CredentialRequest<'_>) -> Result<Credential> {
self.dispatcher.get_credential_for(&self.name, req).await
}

async fn status(&self, env: &str) -> Result<Credential> {
self.dispatcher.status(&self.name, env).await
}
Expand Down
78 changes: 76 additions & 2 deletions src/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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.
Expand All @@ -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<Credential>;

/// 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<Credential> {
self.get_credential(req.env, req.command, req.tier).await
}

/// Returns cached credential status for one environment.
async fn status(&self, env: &str) -> Result<Credential>;

Expand All @@ -56,3 +113,20 @@ pub trait AuthProvider: Send + Sync + std::fmt::Debug {
/// Lists environments with cached credentials.
async fn list_environments(&self) -> Result<Vec<String>>;
}

#[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);
}
}
Loading