Skip to content
16 changes: 13 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ When a command has many flags, complex validation, or an existing `#[derive(clap
use the typed path instead:

```rust
use cli_engine::{CommandResult, CommandSpec, Credential, RuntimeCommandSpec};
use cli_engine::{CommandResult, CommandSpec, CredentialResolver, RuntimeCommandSpec};
use serde_json::json;

#[derive(Debug, Clone, clap::Args)]
Expand All @@ -146,7 +146,7 @@ let command = RuntimeCommandSpec::new_typed::<ListArgs, _, _, _>(
CommandSpec::from_args::<ListArgs>("list", "List projects")
.with_system("projects-api")
.with_default_fields("id,name,status"),
async |_credential: Option<Credential>, args: ListArgs| {
async |_credential: CredentialResolver, args: ListArgs| {
Ok(CommandResult::new(json!([
{"id": "p1", "name": "Portal", "team": args.team, "limit": args.limit}
])))
Expand Down Expand Up @@ -219,7 +219,17 @@ Command checklist:
- Set `.with_default_fields(...)` for list-style output.
- Set `.with_json_schema::<T>()` when the response shape is known.
- Add `clap::Arg` values with the exact user-facing flag names the CLI should expose.
- Use `.no_auth(true)` only for commands that genuinely do not need credentials.
- Authentication is fail-closed by default (`AuthRequirement::Required`): the engine resolves the
credential before the handler runs, so a command that should be gated cannot execute
unauthenticated even if its handler never reads the credential. Handlers receive a
`CredentialResolver`; for `Required` commands the credential is already resolved, so
`resolver.resolve().await?` (or `ctx.credential().await?`) is a memoized lookup. `--schema` and
`--dry-run` short-circuit before resolution, so they never trigger an auth flow.
- Use `.auth_optional()` for commands that must run while logged out and only enrich output when a
credential happens to be present; the engine does not resolve on their behalf, so the handler
decides via `resolver.try_resolve().await?`. Use `.no_auth(true)` for commands that never
authenticate (this also suppresses default-env injection). Forgetting these annotations only
over-prompts; it never lets a gated command run unauthenticated.
- Use `.with_tier(...)` or `.mutates(true)` for mutating commands so `--dry-run` can short-circuit them.
- Prefer returning structured JSON values from handlers; let cli-engine render JSON, human, and TOON formats.
- Prefer `CommandSpec::from_args::<T>()` + `RuntimeCommandSpec::new_typed` when the command has many flags, needs clap validation attributes, or when porting existing derive-based commands. Use the builder path for simple commands with one or two flags.
Expand Down
9 changes: 6 additions & 3 deletions docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,9 +172,12 @@ the bounded channel can fill and the handler will wait on `send` until the write

```rust
async fn handler(
credential: Option<cli_engine::Credential>,
credential: cli_engine::CredentialResolver,
args: cli_engine::middleware::ValueMap,
) -> cli_engine::Result<cli_engine::CommandResult> {
// Auth is fail-closed by default: the engine resolves the credential before
// this handler runs, so `credential.resolve().await?` here is a memoized
// lookup. Mark the command `.auth_optional()` or `.no_auth(true)` to opt out.
Ok(cli_engine::CommandResult::new(serde_json::json!({ "ok": true })))
}
```
Expand All @@ -188,7 +191,7 @@ Commands can also define arguments with `#[derive(clap::Args)]` structs instead
builders. This gives compile-time type safety from argument definition through handler consumption:

```rust
use cli_engine::{CommandResult, CommandSpec, Credential, RuntimeCommandSpec};
use cli_engine::{CommandResult, CommandSpec, CredentialResolver, RuntimeCommandSpec};
use serde_json::json;

#[derive(Debug, Clone, clap::Args)]
Expand All @@ -204,7 +207,7 @@ let command = RuntimeCommandSpec::new_typed::<ListArgs, _, _, _>(
CommandSpec::from_args::<ListArgs>("list", "List projects")
.with_system("projects-api")
.with_default_fields("id,name,status"),
async |_credential: Option<Credential>, args: ListArgs| {
async |_credential: CredentialResolver, args: ListArgs| {
Ok(CommandResult::new(json!([
{"id": "p1", "name": "Portal", "team": args.team}
])))
Expand Down
10 changes: 8 additions & 2 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ fn list_projects() -> RuntimeCommandSpec {
CommandSpec::from_args::<ListArgs>("list", "List projects")
.with_system("projects-api")
.with_default_fields("id,name,status"),
async |_credential: Option<Credential>, args: ListArgs| {
async |_credential: CredentialResolver, args: ListArgs| {
Ok(CommandResult::new(json!([
{"id": "p1", "name": "Portal", "team": args.team, "limit": args.limit}
])))
Expand Down Expand Up @@ -286,7 +286,13 @@ The provider process contract and transport injectors are described in
[Authentication and Transport](auth.md).

Authorization is optional and supplied by an `Authorizer` attached to middleware. The authorizer
receives command path, effective args, optional credential, reason, and tier.
receives command path, effective args, a `CredentialResolver`, reason, and tier. Authentication
policy is declared per command via `AuthRequirement` and defaults to `Required` (fail-closed): the
engine resolves the credential before the handler runs and renders an `auth-error` if it cannot, so
a command that should be gated cannot execute unauthenticated. The `CredentialResolver` memoizes a
single resolution shared by the engine, the authorizer, and the handler. `Optional` commands defer
resolution to the handler, `None` commands never authenticate, and `--schema`/`--dry-run`
short-circuit before resolution so they never trigger an auth flow.

Auditors and activity emitters are also pluggable traits. They receive enough context to record
success, auth failures, authorization denials, dry-runs, command errors, and command duration.
Expand Down
4 changes: 2 additions & 2 deletions examples/typed.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::process::ExitCode;

use cli_engine::{
BuildInfo, Cli, CliConfig, CommandResult, CommandSpec, Credential, GroupSpec, Module,
BuildInfo, Cli, CliConfig, CommandResult, CommandSpec, CredentialResolver, GroupSpec, Module,
RuntimeCommandSpec, RuntimeGroupSpec,
};
use serde_json::json;
Expand All @@ -24,7 +24,7 @@ async fn main() -> ExitCode {
.with_system("projects-api")
.with_default_fields("id,name,status")
.no_auth(true),
async |_credential: Option<Credential>, args: ListArgs| {
async |_credential: CredentialResolver, args: ListArgs| {
Ok(CommandResult::new(json!([
{
"id": "project-1",
Expand Down
181 changes: 163 additions & 18 deletions src/auth/pkce.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{SecondsFormat, Utc};
use rand::Rng;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use sha2::{Digest, Sha256};
use tokio::sync::RwLock;
use zeroize::{Zeroize, ZeroizeOnDrop};
Expand Down Expand Up @@ -108,10 +109,17 @@ pub struct PkceAuthProvider {
app_id: String,
env_prefix: String,
allow_file_fallback: bool,
/// Prioritized JWT claim names used to derive `Credential.identity` from the
/// decoded access-token payload. First non-empty string claim wins.
identity_claims: Vec<String>,
/// In-process token cache keyed by env.
cache: Arc<RwLock<HashMap<String, StoredToken>>>,
}

/// Default prioritized claim names for deriving a human-readable identity.
const DEFAULT_IDENTITY_CLAIMS: &[&str] =
&["email", "preferred_username", "username", "name", "sub"];

impl PkceAuthProvider {
/// Creates a new PKCE provider.
///
Expand Down Expand Up @@ -141,6 +149,10 @@ impl PkceAuthProvider {
app_id: String::new(),
env_prefix,
allow_file_fallback: false,
identity_claims: DEFAULT_IDENTITY_CLAIMS
.iter()
.map(|claim| (*claim).to_owned())
.collect(),
cache: Arc::new(RwLock::new(HashMap::new())),
}
}
Expand Down Expand Up @@ -191,6 +203,47 @@ impl PkceAuthProvider {
self
}

/// Overrides the prioritized JWT claim names used to derive
/// [`Credential::identity`](crate::Credential) from the decoded access-token
/// payload.
///
/// The first claim whose value is a non-empty string wins. The default order
/// is `email`, `preferred_username`, `username`, `name`, `sub`. Use this when
/// the identity provider exposes the human identity under a non-standard
/// claim name.
#[must_use]
pub fn with_identity_claims(mut self, claims: &[impl AsRef<str>]) -> Self {
self.identity_claims = claims.iter().map(|c| c.as_ref().to_owned()).collect();
self
}

/// Builds a [`Credential`] from a stored token, deriving `identity` and `sub`
/// from the access-token JWT claims when present.
fn build_credential(&self, env: &str, token: &StoredToken) -> Credential {
let claims = decode_jwt_claims(&token.access_token);
let identity = claims
.as_ref()
.map(|claims| extract_identity(claims, &self.identity_claims))
.unwrap_or_default();
let sub = claims
.as_ref()
.and_then(|claims| claims.get("sub"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned();
Credential {
token: token.access_token.clone(),
env: env.to_owned(),
provider: self.name.clone(),
expires_at: chrono::DateTime::from_timestamp(token.expires_at, 0)
.map(|dt| dt.to_rfc3339_opts(SecondsFormat::Secs, true))
.unwrap_or_default(),
identity,
sub,
..Credential::default()
}
}

fn effective_client_id(&self) -> String {
let key = format!("{}_OAUTH_CLIENT_ID", self.env_prefix);
std::env::var(&key).unwrap_or_else(|_| self.client_id.clone())
Expand Down Expand Up @@ -592,15 +645,7 @@ impl AuthProvider for PkceAuthProvider {

async fn get_credential(&self, env: &str, _command: &str, _tier: &str) -> Result<Credential> {
let token = self.resolve_token(env).await?;
Ok(Credential {
token: token.access_token.clone(),
env: env.to_owned(),
provider: self.name.clone(),
expires_at: chrono::DateTime::from_timestamp(token.expires_at, 0)
.map(|dt| dt.to_rfc3339_opts(SecondsFormat::Secs, true))
.unwrap_or_default(),
..Credential::default()
})
Ok(self.build_credential(env, &token))
}

async fn status(&self, env: &str) -> Result<Credential> {
Expand All @@ -609,15 +654,7 @@ impl AuthProvider for PkceAuthProvider {
"not logged in for environment {env:?}"
)));
};
Ok(Credential {
token: token.access_token.clone(),
env: env.to_owned(),
provider: self.name.clone(),
expires_at: chrono::DateTime::from_timestamp(token.expires_at, 0)
.map(|dt| dt.to_rfc3339_opts(SecondsFormat::Secs, true))
.unwrap_or_default(),
..Credential::default()
})
Ok(self.build_credential(env, &token))
}

async fn logout(&self, env: &str) -> Result<()> {
Expand Down Expand Up @@ -978,6 +1015,31 @@ struct TokenResponse {
refresh_token: Option<String>,
}

/// 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.
fn decode_jwt_claims(token: &str) -> Option<Map<String, Value>> {
// A JWT is `header.payload.signature`; the payload is the middle segment,
// base64url-encoded without padding.
let payload = token.split('.').nth(1)?;
let bytes = URL_SAFE_NO_PAD.decode(payload).ok()?;
serde_json::from_slice(&bytes).ok()
}

/// Returns the first claim value that is a non-empty string, in priority order.
fn extract_identity(claims: &Map<String, Value>, priority: &[String]) -> String {
priority
.iter()
.filter_map(|name| claims.get(name).and_then(Value::as_str))
.find(|value| !value.is_empty())
.unwrap_or_default()
.to_owned()
}

async fn parse_token_response(response: reqwest::Response, _env: &str) -> Result<StoredToken> {
let body: TokenResponse = response
.json()
Expand All @@ -997,6 +1059,8 @@ async fn parse_token_response(response: reqwest::Response, _env: &str) -> Result
// module serialises all access so usage here is data-race-free.
#[allow(unsafe_code)]
mod tests {
use serde_json::json;

use super::*;

/// Serialises access to XDG_CONFIG_HOME (and restores it) so env-var tests
Expand Down Expand Up @@ -1336,4 +1400,85 @@ mod tests {
let envs = provider.list_environments().await.expect("list");
assert!(envs.is_empty(), "expected empty list for a fresh provider");
}

/// Builds an unsigned-looking JWT (`header.payload.signature`) whose payload
/// is the given claims object, base64url-encoded without padding.
fn make_jwt(claims: &Value) -> String {
let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"none","typ":"JWT"}"#);
let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).expect("serialize claims"));
format!("{header}.{payload}.signature")
}

#[test]
fn decode_jwt_claims_extracts_payload() {
let token = make_jwt(&json!({"email": "user@example.com", "sub": "abc123"}));
let claims = decode_jwt_claims(&token).expect("claims decode");
assert_eq!(
claims.get("email").and_then(Value::as_str),
Some("user@example.com")
);
assert_eq!(claims.get("sub").and_then(Value::as_str), Some("abc123"));
}

#[test]
fn decode_jwt_claims_returns_none_for_non_jwt() {
assert!(decode_jwt_claims("opaque-access-token").is_none());
assert!(decode_jwt_claims("only.two").is_none());
// Valid structure but the payload is not valid base64/JSON.
assert!(decode_jwt_claims("aaa.!!!.bbb").is_none());
}

#[test]
fn extract_identity_honors_priority_and_skips_empty() {
let priority: Vec<String> = DEFAULT_IDENTITY_CLAIMS
.iter()
.map(|c| (*c).to_owned())
.collect();
// `email` is empty, so the next non-empty claim (`preferred_username`) wins.
let claims = serde_json::from_value(json!({
"email": "",
"preferred_username": "jdoe",
"name": "Jane Doe",
}))
.expect("claims map");
assert_eq!(extract_identity(&claims, &priority), "jdoe");

// No matching claim yields an empty identity.
let empty = serde_json::from_value(json!({"unrelated": "x"})).expect("claims map");
assert_eq!(extract_identity(&empty, &priority), "");
}

#[test]
fn build_credential_populates_identity_and_sub() {
let provider = test_provider();
let token = valid_token(&make_jwt(&json!({
"email": "user@example.com",
"sub": "subject-1",
})));
let credential = provider.build_credential("prod", &token);
assert_eq!(credential.identity, "user@example.com");
assert_eq!(credential.sub, "subject-1");
assert_eq!(credential.env, "prod");
assert_eq!(credential.provider, "test");
}

#[test]
fn build_credential_leaves_identity_blank_for_opaque_token() {
let provider = test_provider();
let token = valid_token("opaque-token");
let credential = provider.build_credential("prod", &token);
assert_eq!(credential.identity, "");
assert_eq!(credential.sub, "");
}

#[test]
fn with_identity_claims_overrides_selection() {
let provider = test_provider().with_identity_claims(&["custom_user"]);
let token = valid_token(&make_jwt(&json!({
"email": "ignored@example.com",
"custom_user": "picked",
})));
let credential = provider.build_credential("prod", &token);
assert_eq!(credential.identity, "picked");
}
}
4 changes: 2 additions & 2 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -987,7 +987,7 @@ impl Cli {
user_args,
args,
default_fields: &default_fields,
no_auth: command.spec.no_auth,
auth: command.spec.auth,
},
Arc::new(leaf.clone()),
streaming_handler,
Expand Down Expand Up @@ -1017,7 +1017,7 @@ impl Cli {
user_args,
args,
default_fields: &default_fields,
no_auth: command.spec.no_auth,
auth: command.spec.auth,
},
async move |credential| {
handler(CommandContext {
Expand Down
Loading