feat!: lazy credential resolution and populate PKCE identity#17
Conversation
Resolve auth credentials lazily instead of eagerly before every command. Middleware now builds a `CredentialResolver` and threads it through; the auth flow runs only when a handler or authorizer calls `resolve()` / `try_resolve()`. Commands that never request a credential — and `--schema`/`--dry-run` — no longer trigger an authentication flow. Also populate `Credential.identity` (and `sub`) in `PkceAuthProvider` by decoding the access-token JWT payload (display-only, no signature verification), with a configurable claim priority via `with_identity_claims`. BREAKING CHANGE: `CommandContext.credential` is now a `CredentialResolver` instead of `Option<Credential>`; the `RuntimeCommandSpec::new` and `new_typed` handler closures receive a `CredentialResolver` as their first argument; and `Authorizer::authorize` receives `&CredentialResolver` instead of `Option<&Credential>`. Handlers that need the credential should call `ctx.credential().await?` (or `resolver.resolve().await?`). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The new PKCE identity tests use serde_json::json! but the test module only imported Map/Value via the module glob. CI builds with --features pkce-auth, which compiles these tests; the default-feature build skips them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR changes the CLI engine’s auth model to lazy credential resolution (so auth only occurs when a handler/authorizer explicitly requests it) and fixes PKCE credentials to populate Credential.identity/sub by decoding access-token JWT claims for display/audit purposes.
Changes:
- Introduces
CredentialResolver(memoized + cloneable) and threads it through middleware, handlers, and authorizers to avoid eager auth. - Updates command/context APIs to use the resolver and adds convenience
ctx.credential().await?/ctx.try_credential().await?. - Enhances
PkceAuthProviderto deriveidentity/subfrom decoded JWT claims (configurable claim priority) and adds tests.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/streaming.rs | Updates streaming test handler to explicitly resolve credentials under lazy auth. |
| tests/foundation.rs | Updates many middleware/runtime tests for resolver-based auth; adds new lazy-resolution tests. |
| tests/derive_bridge.rs | Updates derive bridge tests to accept CredentialResolver in typed handlers. |
| src/middleware.rs | Adds CredentialResolver and refactors middleware flow to resolve credentials lazily + memoize. |
| src/lib.rs | Re-exports CredentialResolver as part of the public API surface. |
| src/command.rs | Updates CommandContext + runtime handler signatures to use CredentialResolver and adds convenience methods. |
| src/auth/pkce.rs | Populates Credential.identity/sub by decoding JWT payload claims; adds configuration + tests. |
| examples/typed.rs | Updates example typed handler signature to accept CredentialResolver. |
| docs/design.md | Updates design docs to reflect resolver-based handler/authorizer behavior. |
| docs/concepts.md | Updates conceptual docs and code snippets to use CredentialResolver + lazy resolution guidance. |
| AGENTS.md | Updates contributor guidance to explain lazy credential resolution and reduced need for no_auth(true). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Address Copilot review on PR #17: when lazy credential resolution fails (in either the authorizer or the handler path), the activity event's backend is now the auth provider name rather than the command path, matching the prior eager-auth behavior so telemetry can distinguish auth-provider failures from command backends. Adds a regression test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…output Address Copilot re-review on PR #17: - `CredentialResolver::resolve` now clears the `auth_error` flag on a successful attempt, so a retry that succeeds after an earlier failure cannot leave stale state that misclassifies a later non-auth error. - The post-authz `--schema` short-circuit now passes the resolved identity (via `peek()`, which never triggers resolution) so schema output metadata includes identity when an authorizer already resolved the credential. Adds a regression test covering the schema-identity path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Keep credential resolution memoized and deferred for --schema/--dry-run, but make the per-command authentication policy explicit and fail-closed. CommandSpec now carries an AuthRequirement (default Required) instead of a no_auth bool: - Required (default): the engine resolves the credential before the handler runs and renders an auth-error if it cannot, so a command that must be authenticated cannot execute unauthenticated even when its handler never reads the credential, and its audit/activity identity is always populated. - Optional (auth_optional()): resolution is deferred to the handler, which calls resolve()/try_resolve() only when a decision depends on the credential. - None (no_auth(true)): never authenticates; default-env injection is suppressed. --schema and --dry-run short-circuit before the engine resolves a Required credential, so they never trigger an auth flow. A forgotten annotation now over-prompts rather than allowing a gated command to run unauthenticated. BREAKING CHANGE: CommandSpec.no_auth (bool) is replaced by CommandSpec.auth (AuthRequirement), and MiddlewareRequest.no_auth (bool) by MiddlewareRequest.auth (AuthRequirement). The no_auth(true) builder still works and maps to AuthRequirement::None; new auth_optional() and auth(AuthRequirement) builders select the other policies.
The middleware previously decided whether a command outcome was `auth-error` by reading a sticky `auth_error` flag on the resolver, set on any failed `resolve()` attempt. For an `Optional` command, a handler that best-effort resolves (e.g. `try_resolve().await.ok()`), swallows the failure, and then fails for an unrelated reason would have its later command error misclassified as `auth-error` with the backend attributed to the auth provider. Mark resolution failures at the resolver boundary by wrapping non-auth-typed provider errors in `CliCoreError::AuthProvider`, and classify outcomes via the new `CliCoreError::is_auth()` on the error a handler or authorizer actually returns. A swallowed-then-different error is no longer auth-typed, so it is classified `error`. Removes the `auth_error` AtomicBool, the `had_auth_error` accessor, and the per-attempt bookkeeping. Adds a regression test for the swallow case.
Follow-up: make the auth requirement fail-closed by defaultI pushed two commits ( WhyWith resolution driven entirely by the handler, a command that doesn't mark What changed
Net effect on the breaking surfaceStill one breaking release. Surviving breaks: the three from the original commit ( Local: |
After authentication became fail-closed, Required commands short-circuit at the engine's forced resolve, so the existing auth-error tests no longer exercise the `err.is_auth()` branches on the handler and authorizer paths. Add two regression tests using Optional/authorizer-driven resolution against a missing provider: a handler that propagates the failure, and an authorizer that resolves and propagates. Both must be classified `auth-error` with the backend attributed to the auth provider.
🤖 I have created a release *beep* *boop* --- ## [0.2.0](cli-engine-v0.1.3...cli-engine-v0.2.0) (2026-06-09) ### ⚠ BREAKING CHANGES * `CommandSpec.no_auth` (bool) is replaced by `CommandSpec.auth` (`AuthRequirement`), and `MiddlewareRequest.no_auth` by `MiddlewareRequest.auth`. `CommandContext.credential` is now a `CredentialResolver` instead of `Option<Credential>`; `RuntimeCommandSpec::new` and `new_typed` handler closures receive a `CredentialResolver`; and `Authorizer::authorize` receives `&CredentialResolver` instead of `Option<&Credential>`. The `no_auth(true)` builder still works and maps to `AuthRequirement::None`; `auth_optional()` and `auth(AuthRequirement)` select the other policies. ### Features * fail-closed authentication via AuthRequirement; populate PKCE identity ([#17](#17)) ([34313bf](34313bf)) ### Bug Fixes * render help for `<group> help` subcommand form ([#15](#15)) ([c21db13](c21db13)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Context
Two auth defects surfaced via a consumer CLI (
gddy), and investigating them exposed a deeper design issue:env listonly reads local state, yet it triggered the OAuth/PKCE browser flow. Root cause: middleware resolved the credential eagerly for every command not explicitly markedno_auth(true), before the handler ran — so authors had to remember to annotate every read-only/local command, and--schema/--dry-runstill triggered a login.auth statusshowed a blankidentity.PkceAuthProviderbuilt itsCredentialwith..Credential::default()and never inspected the token for identity claims.What changed
Lazy credential resolution
CredentialResolver(memoizing,Clone) onCommandContext. The auth flow runs only when a handler or authorizer calls.resolve()/.try_resolve(). Commands that never ask — and--schema/--dry-run— skip authentication entirely. No per-commandno_authannotation needed.Middleware::runbuilds the resolver and threads it through authz + handler; identity for audit/activity is read post-hoc viapeek(); theauth-erroraudit classification is preserved.ctx.credential().await?andctx.try_credential().await?.Populate
identityinPkceAuthProvideridentity/subfrom a configurable, prioritized claim list (with_identity_claims, defaultemail, preferred_username, username, name, sub). Opaque/non-JWT tokens degrade to blank. Works for already-stored tokens with no re-login.Breaking changes (pre-1.0)
CommandContext.credential:Option<Credential>→CredentialResolver.RuntimeCommandSpec::new/new_typedclosures receive aCredentialResolverfirst arg (handlers that ignore it compile unchanged).Authorizer::authorizereceives&CredentialResolverinstead ofOption<&Credential>.Verification
cargo fmt --all --check,cargo clippy --all-targets -- -D warnings,RUSTDOCFLAGS='-D warnings' cargo doc --no-deps,cargo test --all-targets,cargo test --doc— all green;missing-docs: 0.with_identity_claimsoverride; lazy-behavior (zero resolutions when the credential is ignored, one shared resolution across authz+handler, zero for--dry-run).🤖 Generated with Claude Code