Skip to content

feat!: lazy credential resolution and populate PKCE identity#17

Merged
jpage-godaddy merged 7 commits into
mainfrom
lazy-auth
Jun 9, 2026
Merged

feat!: lazy credential resolution and populate PKCE identity#17
jpage-godaddy merged 7 commits into
mainfrom
lazy-auth

Conversation

@jpage-godaddy

Copy link
Copy Markdown
Collaborator

Context

Two auth defects surfaced via a consumer CLI (gddy), and investigating them exposed a deeper design issue:

  1. Local commands provoked auth when they shouldn't. A command like env list only reads local state, yet it triggered the OAuth/PKCE browser flow. Root cause: middleware resolved the credential eagerly for every command not explicitly marked no_auth(true), before the handler ran — so authors had to remember to annotate every read-only/local command, and --schema/--dry-run still triggered a login.
  2. auth status showed a blank identity. PkceAuthProvider built its Credential with ..Credential::default() and never inspected the token for identity claims.

What changed

Lazy credential resolution

  • New CredentialResolver (memoizing, Clone) on CommandContext. 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-command no_auth annotation needed.
  • Middleware::run builds the resolver and threads it through authz + handler; identity for audit/activity is read post-hoc via peek(); the auth-error audit classification is preserved.
  • Convenience: ctx.credential().await? and ctx.try_credential().await?.

Populate identity in PkceAuthProvider

  • Decode the access-token JWT payload (display-only, no signature verification) and fill identity/sub from a configurable, prioritized claim list (with_identity_claims, default email, 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_typed closures receive a CredentialResolver first arg (handlers that ignore it compile unchanged).
  • Authorizer::authorize receives &CredentialResolver instead of Option<&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.
  • New tests: JWT decode / identity-claim priority / with_identity_claims override; lazy-behavior (zero resolutions when the credential is ignored, one shared resolution across authz+handler, zero for --dry-run).

🤖 Generated with Claude Code

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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 PkceAuthProvider to derive identity/sub from 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.

Comment thread src/middleware.rs
Comment thread src/middleware.rs
Comment thread src/middleware.rs
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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Comment thread src/middleware.rs
Comment thread src/middleware.rs Outdated
…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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.

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.
@jgowdy-godaddy

Copy link
Copy Markdown
Collaborator

Follow-up: make the auth requirement fail-closed by default

I pushed two commits (ab18c07, 8d9d2a6) that build on this work. The lazy CredentialResolver, single-resolution memoization, peek()-based identity, and the PKCE JWT identity decode are unchanged — they're the right foundation. The change is to the default direction of the auth policy and one classification detail.

Why

With resolution driven entirely by the handler, a command that doesn't mark no_auth but whose handler never calls resolve() runs unauthenticated, and its audit/activity identity is blank. That makes the default fail-open: forgetting to annotate a gated command lets it execute without a credential. I think the safer default is the inverse — if an annotation is missing, the command should over-prompt, not under-authenticate.

What changed

  • CommandSpec.no_auth: boolCommandSpec.auth: AuthRequirement (Required | Optional | None), default Required. no_auth(true) still works (maps to None); added auth_optional() and auth(..).
  • For Required commands the engine resolves the credential before the handler runs and renders auth-error on failure — so a gated command can't run unauthenticated even if its handler ignores the credential, and its identity is always recorded.
  • --schema/--dry-run short-circuit before that forced resolve, so they still never trigger an auth flow. Optional defers to the handler (the env list / enrich-if-logged-in case); None never authenticates.
  • Auth-error classification now keys off the error a handler/authorizer actually returns (CliCoreError::is_auth(), resolution failures marked at the resolver boundary) instead of a sticky resolver flag. This fixes a case where an Optional handler that best-effort resolves, swallows the failure, and then fails for another reason would have the later error mistagged as auth-error.

Net effect on the breaking surface

Still one breaking release. Surviving breaks: the three from the original commit (CommandContext.credential, handler closures, Authorizer::authorize), plus CommandSpec.no_authauth and MiddlewareRequest.no_authauth. Builder call sites using .no_auth(true) are unaffected. release-please (bump-minor-pre-major) will cut 0.2.0.

Local: fmt, clippy --all-targets --all-features -D warnings, test --all-targets --all-features, test --doc, doc -D warnings all green; missing-docs: 0. Docs (AGENTS.md, docs/concepts.md, docs/design.md) updated to describe the fail-closed default. Happy to adjust naming (AuthRequirement/auth_optional) or split the classification fix into its own PR if you'd prefer.

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.
@jpage-godaddy jpage-godaddy merged commit 34313bf into main Jun 9, 2026
2 checks passed
@jpage-godaddy jpage-godaddy deleted the lazy-auth branch June 9, 2026 16:04
jpage-godaddy pushed a commit that referenced this pull request Jun 9, 2026
🤖 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants