Skip to content

Commit f996e50

Browse files
jpage-godaddyjgowdy-godaddyclaude
authored
feat: OAuth scope step-up via command metadata (#18)
A command declares the OAuth scopes it needs; when the cached token doesn't cover them, the engine re-authenticates (interactive PKCE) for the union of scopes, then proceeds. Non-OAuth providers are unaffected. - AuthProvider gains a defaulted get_credential_for(&CredentialRequest) so a provider can read command metadata (e.g. scopes). CredentialRequest is #[non_exhaustive] with a constructor for SemVer-safe field additions. - PkceAuthProvider performs step-up: scope coverage is JWT scope/scp/array claims plus the scopes a token was issued with (recorded on StoredToken), so opaque tokens work without needless re-consent; the decision is a pure, unit-tested plan_step_up. After (re)auth it verifies the server actually granted the required scopes (clear error otherwise) and fails fast in non-interactive contexts. - CommandSpec::with_scopes declares required scopes; the resolver threads them through and adds resolve_with_scopes / CommandContext::credential_with_scopes for runtime-derived scopes. A step-up that authenticates a different identity is aborted so audit/activity identity stays correct. - auth login --scope (repeatable) requests additional scopes. - HTTP bearer-injector step-up ordering is documented. Co-authored-by: Jay Gowdy <jgowdy@godaddy.com> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9e39f2f commit f996e50

9 files changed

Lines changed: 1004 additions & 60 deletions

File tree

src/auth/commands.rs

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use clap::Arg;
1+
use clap::{Arg, ArgAction};
22
use serde::{Deserialize, Serialize};
33
use serde_json::{Value, json};
44

@@ -48,12 +48,25 @@ pub fn auth_command_group(default_provider: &str, registered_names: &[String]) -
4848
.mutates(true)
4949
.no_auth(true)
5050
.with_arg(provider_arg(&effective_default, registered_names))
51-
.with_arg(Arg::new("env").long("env").value_name("ENV").required(true)),
51+
.with_arg(Arg::new("env").long("env").value_name("ENV").required(true))
52+
.with_arg(
53+
Arg::new("scope")
54+
.long("scope")
55+
.short('s')
56+
.value_name("SCOPE")
57+
// One scope per occurrence, repeatable: `--scope a --scope b`.
58+
// `ArgAction::Append` requires a value, so a bare `--scope`
59+
// is rejected rather than silently doing nothing.
60+
.action(ArgAction::Append)
61+
.help("Additional OAuth scope to request (repeatable, one per flag)"),
62+
),
5263
async |context| {
5364
let provider = string_arg(&context.args, "provider");
5465
let env = string_arg(&context.args, "env");
66+
let scopes = string_vec_arg(&context.args, "scope");
5567
serde_json::to_value(
56-
login_and_build(&context.middleware.auth, &provider, &env).await?,
68+
login_and_build_with_scopes(&context.middleware.auth, &provider, &env, &scopes)
69+
.await?,
5770
)
5871
.map(CommandResult::new)
5972
.map_err(Into::into)
@@ -106,6 +119,23 @@ fn string_arg(args: &serde_json::Map<String, Value>, name: &str) -> String {
106119
.to_owned()
107120
}
108121

122+
/// Reads a repeatable string argument as a `Vec<String>`, accepting either a
123+
/// JSON array (multiple values) or a single string.
124+
fn string_vec_arg(args: &serde_json::Map<String, Value>, name: &str) -> Vec<String> {
125+
match args.get(name) {
126+
// Drop empty strings: an empty scope token is never valid and only
127+
// produces confusing auth-server errors.
128+
Some(Value::Array(items)) => items
129+
.iter()
130+
.filter_map(Value::as_str)
131+
.filter(|value| !value.is_empty())
132+
.map(str::to_owned)
133+
.collect(),
134+
Some(Value::String(value)) if !value.is_empty() => vec![value.clone()],
135+
_ => Vec::new(),
136+
}
137+
}
138+
109139
fn provider_arg(default_provider: &str, registered_names: &[String]) -> Arg {
110140
let names = registered_names.join(", ");
111141
let help = format!("Auth provider name (one of: [{names}])");
@@ -125,7 +155,20 @@ pub async fn login_and_build(
125155
provider: &str,
126156
env: &str,
127157
) -> Result<AuthLoginResult> {
128-
let credential = dispatcher.login(provider, env).await?;
158+
login_and_build_with_scopes(dispatcher, provider, env, &[]).await
159+
}
160+
161+
/// Like [`login_and_build`], but requests `additional_scopes` on top of the
162+
/// provider's defaults (used by `auth login --scope`).
163+
pub async fn login_and_build_with_scopes(
164+
dispatcher: &Dispatcher,
165+
provider: &str,
166+
env: &str,
167+
additional_scopes: &[String],
168+
) -> Result<AuthLoginResult> {
169+
let credential = dispatcher
170+
.login_with_scopes(provider, env, additional_scopes)
171+
.await?;
129172
Ok(AuthLoginResult {
130173
provider: provider.to_owned(),
131174
env: env.to_owned(),

src/auth/dispatcher.rs

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ use std::sync::{Arc, RwLock};
22

33
use async_trait::async_trait;
44

5-
use super::{AuthProvider, Credential};
5+
use super::{AuthProvider, Credential, CredentialRequest};
6+
use crate::middleware::CommandMeta;
67
use crate::{CliCoreError, Result};
78

89
/// Routes auth operations to registered providers by name.
@@ -77,13 +78,41 @@ impl Dispatcher {
7778
self.get(name)?.get_credential(env, command, tier).await
7879
}
7980

81+
/// Gets a credential from a named provider, passing the command's full
82+
/// [`CredentialRequest`] so metadata-aware providers (e.g. OAuth scope
83+
/// step-up) can act on it.
84+
pub async fn get_credential_for(
85+
&self,
86+
name: &str,
87+
req: &CredentialRequest<'_>,
88+
) -> Result<Credential> {
89+
self.get(name)?.get_credential_for(req).await
90+
}
91+
8092
/// Clears any cached credential, ignoring logout failures, then authenticates.
8193
pub async fn login(&self, name: &str, env: &str) -> Result<Credential> {
94+
self.login_with_scopes(name, env, &[]).await
95+
}
96+
97+
/// Like [`login`](Dispatcher::login), but requests `additional_scopes` on top
98+
/// of the provider's defaults.
99+
///
100+
/// The scopes are carried as [`CommandMeta::scopes`] on a synthesized
101+
/// request; providers without scope support ignore them.
102+
pub async fn login_with_scopes(
103+
&self,
104+
name: &str,
105+
env: &str,
106+
additional_scopes: &[String],
107+
) -> Result<Credential> {
82108
let provider = self.get(name)?;
83109
if let Err(err) = provider.logout(env).await {
84110
tracing::debug!(provider = name, error = %err, "ignoring logout error before login");
85111
}
86-
provider.get_credential(env, "", "").await
112+
let mut meta = CommandMeta::default();
113+
meta.set_scopes(additional_scopes.to_vec());
114+
let req = CredentialRequest::new(env, "", "", &meta);
115+
provider.get_credential_for(&req).await
87116
}
88117

89118
/// Gets cached credential status from a named provider.
@@ -176,6 +205,10 @@ impl AuthProvider for SingleProvider {
176205
.await
177206
}
178207

208+
async fn get_credential_for(&self, req: &CredentialRequest<'_>) -> Result<Credential> {
209+
self.dispatcher.get_credential_for(&self.name, req).await
210+
}
211+
179212
async fn status(&self, env: &str) -> Result<Credential> {
180213
self.dispatcher.status(&self.name, env).await
181214
}

src/auth/mod.rs

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ pub mod pkce;
2323
use async_trait::async_trait;
2424

2525
pub use commands::{
26-
AuthLoginResult, AuthStatusEntry, auth_command_group, login_and_build, logout_result,
27-
status_result, to_status_entry,
26+
AuthLoginResult, AuthStatusEntry, auth_command_group, login_and_build,
27+
login_and_build_with_scopes, logout_result, status_result, to_status_entry,
2828
};
2929
pub use credential::{CACHE_TTL, Credential};
3030
pub use dispatcher::{Dispatcher, SingleProvider, StatusEntry};
@@ -34,6 +34,51 @@ pub use exec::{
3434
};
3535

3636
use crate::Result;
37+
use crate::middleware::CommandMeta;
38+
39+
/// Everything an [`AuthProvider`] may inspect about the command requesting a
40+
/// credential.
41+
///
42+
/// This bundles the routing fields passed to [`AuthProvider::get_credential`]
43+
/// (`env`, colon command path, and tier) together with the command's
44+
/// [`CommandMeta`], so a provider can read richer metadata — for example an
45+
/// OAuth provider reading [`CommandMeta::scopes`] to decide whether the cached
46+
/// token is sufficient. Providers that do not need metadata can ignore it.
47+
///
48+
/// Marked `#[non_exhaustive]` because the framework constructs it (providers only
49+
/// read it) and more request fields may be added over time; build one with
50+
/// [`CredentialRequest::new`] rather than a struct literal so adding a field is
51+
/// not a breaking change for downstream crates.
52+
#[derive(Clone, Copy, Debug)]
53+
#[non_exhaustive]
54+
pub struct CredentialRequest<'req> {
55+
/// Target environment name.
56+
pub env: &'req str,
57+
/// Colon-separated command path, for example `project:list`.
58+
pub command: &'req str,
59+
/// Risk tier as a string, for example `read` or `mutate`.
60+
pub tier: &'req str,
61+
/// Metadata for the command requesting the credential.
62+
pub meta: &'req CommandMeta,
63+
}
64+
65+
impl<'req> CredentialRequest<'req> {
66+
/// Creates a request from the routing fields and command metadata.
67+
#[must_use]
68+
pub fn new(
69+
env: &'req str,
70+
command: &'req str,
71+
tier: &'req str,
72+
meta: &'req CommandMeta,
73+
) -> Self {
74+
Self {
75+
env,
76+
command,
77+
tier,
78+
meta,
79+
}
80+
}
81+
}
3782

3883
#[async_trait]
3984
/// Named auth provider used by middleware and transport injectors.
@@ -47,6 +92,18 @@ pub trait AuthProvider: Send + Sync + std::fmt::Debug {
4792
/// Returns a credential for `env`, `command`, and `tier`.
4893
async fn get_credential(&self, env: &str, command: &str, tier: &str) -> Result<Credential>;
4994

95+
/// Returns a credential for a command, given its full [`CredentialRequest`].
96+
///
97+
/// The default implementation ignores the metadata and delegates to
98+
/// [`get_credential`](AuthProvider::get_credential). Providers that act on
99+
/// command metadata — such as an OAuth provider performing scope step-up
100+
/// from [`CommandMeta::scopes`] — override this. The framework calls this
101+
/// method (not `get_credential`) when resolving credentials, so an override
102+
/// receives the command's metadata.
103+
async fn get_credential_for(&self, req: &CredentialRequest<'_>) -> Result<Credential> {
104+
self.get_credential(req.env, req.command, req.tier).await
105+
}
106+
50107
/// Returns cached credential status for one environment.
51108
async fn status(&self, env: &str) -> Result<Credential>;
52109

@@ -56,3 +113,20 @@ pub trait AuthProvider: Send + Sync + std::fmt::Debug {
56113
/// Lists environments with cached credentials.
57114
async fn list_environments(&self) -> Result<Vec<String>>;
58115
}
116+
117+
#[cfg(test)]
118+
mod tests {
119+
use super::*;
120+
121+
#[test]
122+
fn credential_request_new_sets_all_fields() {
123+
let meta = CommandMeta::default();
124+
let req = CredentialRequest::new("dev", "app:list", "read", &meta);
125+
assert_eq!(req.env, "dev");
126+
assert_eq!(req.command, "app:list");
127+
assert_eq!(req.tier, "read");
128+
// `Copy` is preserved (using `req` after copying it must compile).
129+
let copy = req;
130+
assert_eq!(copy.env, req.env);
131+
}
132+
}

0 commit comments

Comments
 (0)