Skip to content

fix(cli): surface OAuth2 client-credentials env vars in auth status - #17653

Open
devin-ai-integration[bot] wants to merge 4 commits into
mainfrom
devin/1788534285-cli-oauth-status
Open

fix(cli): surface OAuth2 client-credentials env vars in auth status#17653
devin-ai-integration[bot] wants to merge 4 commits into
mainfrom
devin/1788534285-cli-oauth-status

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Description

Linear ticket: Refs (none — from Slack report)

A customer with an oauth / client-credentials scheme (client-id-env: OAUTH_CLIENT_ID, client-secret-env: OAUTH_CLIENT_SECRET) reported the generated CLI "is not picking them up": auth status printed (no credential sources bound) for the OAuth scheme.

Root cause: the generator does emit .client_id_env("OAUTH_CLIENT_ID").client_secret_env("OAUTH_CLIENT_SECRET"), and OAuth2Auth::into_binding() turns that into an OAuth2TokenProvider stored as SchemeBinding::Custom. The runtime's auth status treated every Custom binding as opaque (only synthesising a keyring source when a login flow was registered), so the env vars were read fine at request time but never shown — and logged_in was false in --output json.

Reproducing the customer's config surfaced a second bug in the same output: their three basic schemes reported a set TWILIO_AUTH_TOKEN as shadowed. Status flattened username+password into one precedence list, so the password looked like it was losing to the username. That reads as "your auth token is being ignored" and invites the user to go chase a problem that doesn't exist.

Their exact config, before and after (same binary, only the vendored runtime swapped):

BEFORE                                          AFTER

  Scheme: accountSid_authToken                    Scheme: accountSid_authToken
    ✓ active    TWILIO_ACCOUNT_SID env var          ✓ active    TWILIO_ACCOUNT_SID env var
      shadowed  TWILIO_AUTH_TOKEN env var           ✓ active    TWILIO_AUTH_TOKEN env var

  Scheme: oAuth2ClientCredentials                 Scheme: oAuth2ClientCredentials
    (no credential sources bound)                      missing   OAUTH_CLIENT_ID env var
                                                       missing   OAUTH_CLIENT_SECRET env var
                                                    Not logged in. Set OAUTH_CLIENT_ID,
                                                    OAUTH_CLIENT_SECRET to authenticate.

No change to request-time auth behaviour.

Changes Made

CredentialSlots — a status-introspection hook for Custom providers (auth/credential.rs)

pub struct CredentialSlots {
    pub required: Vec<Vec<AuthCredentialSource>>,  // ANDed; each inner vec is one slot,
                                                   // sources in precedence order
    pub alternatives: Vec<AuthCredentialSource>,   // ORed against the whole set
}
  • AuthProvider::credential_slots() -> CredentialSlots (default empty). Providers behind SchemeBinding::Custom are otherwise opaque to the status surface; overriding this lets it enumerate their env vars like a builtin bearer/basic binding.
  • OAuth2TokenProvider implements it: client_id_env, client_secret_env, required custom token-endpoint property env vars (contract mode), and refresh_token_env for the refresh-token grant. A valid cached access token is added as an alternative, so it satisfies the scheme on its own without hiding the env vars that mint it — the question the customer was actually asking. (inject_oauth2_caches wires a disk cache into every generated CLI before dispatch, so a cached token is the common case after the first successful call.)

login.rs: expand_sourcesexpand_slots

  • Token = 1 required slot, Basic = 2 (username, password), Custom = provider.credential_slots() + a keyring slot when a login flow exists.
  • New evaluate_slots resolves every source once and classifies it; both the human and JSON paths render from it, so they can't drift. logged_in is true when every required slot resolves, or an alternative does.
  • Side fix: basic auth's two halves are now independent slots, so a set password reports active instead of shadowed.
  • remedy_line picks the fix that matches how the scheme actually resolves credentials. auth login --with-token writes the keyring, and OAuth client credentials / basic auth never read it — suggesting it sent the user down a path that silently does nothing. Now: login flow → auth login; keyring source present → --with-token; otherwise name the env vars to set.

DocsADR-0008 § "Shadowing-aware UX" and ARCHITECTURE.md § 8.21 described the pre-slot model, and the ADR's failure-mode table pinned the literal Run \ auth login`string thatremedy_line` now varies. Both amended (the ADR gains a Remedy selection subsection), plus the decisions index row. Amended rather than superseded — this refines an accepted decision, it doesn't reverse it.

Changelog entry under generators/cli/changes/unreleased/.

Testing

  • Unit tests added/updated — cargo test --lib in generators/cli/sdk: 1987 passed (three consecutive auth:: runs clean after serializing the env-mutating test).

    • alternative_satisfies_scheme_without_hiding_required_env_vars — cached-token alternative reports active while the env slots still render as missing, and logged_in stays true.
    • remedy_names_missing_env_vars_when_the_scheme_never_reads_the_keyring, remedy_suggests_with_token_when_the_scheme_reads_the_keyring, remedy_points_at_the_login_flow_when_one_is_declared.
    • expand_slots_lists_oauth2_client_credentials_env_vars, status_reports_oauth2_client_credentials_env_vars (both env vars set → logged_in: true, states active, active; secret unset → logged_in: false), expand_slots_keeps_basic_halves_in_separate_slots.
    • credential_slots_report_cached_token_when_env_unset — asserts the alternative and the required env slots coexist.
  • Manual testing completed — generated a CLI from the customer's shape (OAuth client credentials with a namespaced oauth::POST /v2/token reference, alongside three basic schemes over shared env vars) via pnpm seed run --generator cli --local, built it, and exercised auth status in every state:

    • nothing set → all missing, remedy names all missing env vars
    • partially set → per-var active/missing, remedy names only what's left
    • all set → all active, no remedy line, logged_in: true
    • valid cached token, env unset → ✓ active cached OAuth token (<path>) followed by the missing env vars, logged_in: true
    • bearer binding unchanged → still Run '<cli> auth login --with-token'
    • request-time behaviour unchanged: token exchange fires and the endpoint receives Authorization: Bearer <token>

Note: cargo clippy -D warnings has pre-existing errors on main in this crate; none introduced here.

No regressions outside auth status

credential_slots() has exactly one non-test caller — expand_slots (login.rs:546), reached only from handle_status and status_entry_for. Nothing on the request path can observe it. oauth2.rs is 2 import lines plus a 52-line pure insertion (zero lines removed or modified), so apply, has_credentials, has_credentials_for_url, credential_hints, the token exchange/refresh path and all of TokenCache are byte-identical; provider.rs and credential.rs are pure insertions; builder.rs, schemes.rs, compose.rs, oauth_login.rs, keyring_store.rs and error.rs aren't in the diff.

Verified behaviourally by building one generated CLI (three basic schemes + OAuth client credentials) and swapping only the vendored runtime between main and this branch. Byte-identical on both: --help, <group> --help, auth --help, --schema, --dry-run, the Authorization header the API actually received on all four endpoints, response bodies, exit codes, the no-credentials error, and the 401 path for both a basic and an OAuth endpoint (including credential-source disclosure). Differing: only the three auth status captures.

One machine-readable change: logged_in flips truefalse for a basic scheme with a single half set. That corrects a status surface that contradicted the request path — BasicAuthProvider in Full mode requires both halves in has_credentials() (schemes.rs:170) and apply() returns the request unmodified when either is missing (schemes.rs:205), confirmed on the wire as Authorization=<none>:

                      main runtime                      this branch
username only    logged_in=true   sent=no          logged_in=false  sent=no
password only    logged_in=true   sent=no          logged_in=false  sent=no
both basic       logged_in=true   sent=yes         logged_in=true   sent=yes
both oauth       logged_in=false  sent=yes  <-- bug logged_in=true   sent=yes

Known gaps (not addressed here)

  • BearerAuthProvider / BasicAuthProvider / HeaderAuthProvider still use the empty default, so schemes registered via .auth_provider(...) — e.g. generated password-omitted basic — continue to print (no credential sources bound). Each is a few lines since they already own their AuthCredentialSource.
  • The compose.rs wrappers forward credential_hints but not credential_slots. Latent only: the generator doesn't emit them today. AnyAuthProvider is OR-of-AND, which flat slots can't express.
  • The keyring slot for Custom bindings is still pushed as required rather than as an alternative. Not reachable from generated CLIs (client credentials never register a login flow; PKCE/device-code replace the binding with OAuth2KeyringProvider), but it's a one-line move now that alternatives exists.
  • credential_slots doesn't model the "expired access token + persisted refresh token + contract refresh endpoint" case that has_credentials_for_url accepts, so that narrow state reports logged_in: false while requests succeed.

Link to Devin session: https://app.devin.ai/sessions/d9ff8ec39c404dc7b310aed342579599
Open in Devin Desktop: https://app.devin.ai/desktop/session/d9ff8ec39c404dc7b310aed342579599?variant=devin


Devin Review

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@nitpickybot nitpickybot Bot 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.

AI Review Summary

Solid, well-scoped fix: slots model for multi-value schemes plus a credential_slots() introspection hook. Main concerns are the interaction between provider slots and the synthesized keyring slot (an OAuth login-flow scheme with client-id/secret env vars now requires all slots, likely flipping logged_in to false for users who logged in via keyring), and test env-var mutation without serialization.

  • 🟡 2 warning(s)
  • 🔵 2 suggestion(s)

To request another review, comment /ai-review on this pull request.

Comment on lines +567 to +577
SchemeBinding::Custom(provider) => {
let mut slots: Vec<Vec<AuthCredentialSource>> = provider
.credential_slots()
.into_iter()
.map(|slot| slot.into_iter().flat_map(flatten_chain).collect())
.filter(|slot: &Vec<AuthCredentialSource>| !slot.is_empty())
.collect();
if login_flows.iter().any(|f| f.scheme_name() == scheme_name) {
vec![AuthCredentialSource::keyring(cli_name, scheme_name)]
} else {
Vec::new()
slots.push(vec![AuthCredentialSource::keyring(cli_name, scheme_name)]);
}
slots

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 warning

The keyring slot is pushed as an additional required slot alongside the provider's env slots. For an OAuth scheme that has both a registered login flow and client_id_env/client_secret_env (authorization-code + client-secret is common), a user who logged in via the browser flow now gets logged_in: false because the env slots don't resolve — a regression versus the old "any source resolves" behaviour.

Semantically the keyring token and the client-credentials env vars are alternatives (either grants a token), not conjunctive requirements. Consider folding the keyring source into an alternative slot group, or skipping the env slots when a keyring token is present.

Comment on lines +992 to +1016
fn status_reports_oauth2_client_credentials_env_vars() {
use crate::auth::root_builder::{AuthSchemeBuilder, OAuth2Auth};
std::env::set_var("STATUS_TEST_OAUTH_CLIENT_ID", "id");
std::env::set_var("STATUS_TEST_OAUTH_CLIENT_SECRET", "secret");
let (name, binding) = OAuth2Auth::new("oAuth2ClientCredentials")
.token_url("https://example.com/oauth/token")
.client_id_env("STATUS_TEST_OAUTH_CLIENT_ID")
.client_secret_env("STATUS_TEST_OAUTH_CLIENT_SECRET")
.into_binding();

let entry = status_entry_for("my-cli", &name, &binding, &[]);
assert_eq!(entry["logged_in"], true);
let states: Vec<&str> = entry["sources"]
.as_array()
.unwrap()
.iter()
.map(|s| s["state"].as_str().unwrap())
.collect();
assert_eq!(states, ["active", "active"]);

std::env::remove_var("STATUS_TEST_OAUTH_CLIENT_SECRET");
let entry = status_entry_for("my-cli", &name, &binding, &[]);
assert_eq!(entry["logged_in"], false);
std::env::remove_var("STATUS_TEST_OAUTH_CLIENT_ID");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 warning

std::env::set_var/remove_var are process-global and (as of Rust 2024) unsafe; with cargo test running tests in parallel threads this is a data race against any other test reading env. Also, if an assertion panics before remove_var, the vars leak into other tests. Wrap in the crate's env-mutation test guard/mutex if one exists (the existing keyring/env tests likely have one), and reset via a scope guard rather than trailing statements.

Comment on lines +939 to +940
let mut seen = std::collections::HashSet::new();
env_vars.retain(|var| seen.insert(*var));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 suggestion

Minor: empty env var names (e.g. an unconfigured client_secret_env) would be emitted as an Env("") slot that can never resolve, making logged_in permanently false. Worth filtering blanks alongside the dedupe:

Suggested change
let mut seen = std::collections::HashSet::new();
env_vars.retain(|var| seen.insert(*var));
let mut seen = std::collections::HashSet::new();
env_vars.retain(|var| !var.is_empty() && seen.insert(*var));

Comment thread generators/cli/sdk/src/auth/login.rs Outdated
.iter()
.map(|s| {
let slots = expand_slots(scheme_name, binding, login_flows, cli_name);
let mut all_slots_active = !slots.is_empty();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 suggestion

all_slots_active starts as !slots.is_empty() here, but the human-readable path (line 470) initialises all_slots_active = true and returns early when slots.is_empty(). Same result, but the divergent initialisation is easy to break later — consider mirroring the early-return for empty slots in the JSON path too.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

2 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

Comment thread generators/cli/sdk/src/auth/oauth2.rs Outdated
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

E2E test: auth status for OAuth client-credentials

Built the seed-generated oauth-test CLI (seed/cli/cli-oauth/client-credentials, wired with .client_id_env("ACME_CLIENT_ID").client_secret_env("ACME_CLIENT_SECRET")) against the sdk crate from main and from this branch.

Before (main) After (this PR), only client id set
before after partial
  • main: (no credential sources bound) / logged_in: false even with all env vars exported (bug reproduced)
  • PR, env unset: client-id, client-secret and required token-endpoint env vars listed as missing; logged_in: false
  • PR, all set: all slots ✓ active, logged_in: true
  • PR, only ACME_CLIENT_ID: ✓ active (not shadowed), rest missing, logged_in: false
  • PR, env unset + valid ~/.config/oauth-test/credentials.json: single ✓ active cached OAuth token (<path>), logged_in: true; expired token falls back to env slots
  • Regression: basic (2 slots) and bearer schemes unchanged
All env vars set / cached token / regression

all set
cached
regression

Note: required token-endpoint env(...) request properties also count as slots (4 in this fixture), so logged_in requires them too — matches what request-time auth needs.

rishabh-fern and others added 2 commits September 4, 2026 12:58
…the scheme

Two follow-ups to the credential-slot work in this branch.

A valid cached access token used to *replace* the acquisition env vars in
`credential_slots()`, so once a token was cached — and `inject_oauth2_caches`
wires a disk cache into every generated CLI before dispatch — `auth status`
showed only `cached OAuth token (<path>)` and dropped OAUTH_CLIENT_ID /
OAUTH_CLIENT_SECRET entirely. That is the one question the customer was
asking. The hook now returns `CredentialSlots { required, alternatives }`:
`required` slots are ANDed, `alternatives` are ORed against the whole set.
OAuth2TokenProvider always emits the env slots and adds a valid cached token
as an alternative, so the env vars stay visible and a cached token still
satisfies the scheme on its own.

The remedy line also always suggested `auth login --with-token`, which writes
the keyring. OAuth client credentials and basic auth never read the keyring,
so a token pasted there was silently ignored at request time. `remedy_line`
now picks by how the scheme actually resolves credentials: login flow ->
`auth login`; keyring source present -> `--with-token`; otherwise name the
env vars to set.

Both status paths now share `evaluate_slots`, so the human and JSON renderings
can no longer drift.

Verified end to end on a generated CLI (OAuth client credentials over a
namespaced token endpoint, alongside three basic schemes): env vars listed
with per-slot state, remedy naming exactly the missing vars, cached token
reported alongside the env vars, and bearer bindings still pointing at
`--with-token`.

Co-Authored-By: Claude <noreply@anthropic.com>
…d shadowing

`status_reports_oauth2_client_credentials_env_vars` mutated the process
environment without `#[serial]`, against a convention the module documents
inline (login.rs: "`#[serial]` keeps the process-global env mutation from
racing the other env-touching tests"). It matters more than it looks: the
seed fixtures vendor this crate and the cli generator's testScript runs
`cargo test --locked --all-features`, so the full 1987-test suite executes
inside every fixture — and cli-oauth, cli-basic-auth, cli-header-auth and
cli-oauth-login-flow are all absent from allowedFailures, so they gate PRs.

ADR-0008 and ARCHITECTURE.md 8.21 still described the pre-slot model, and
the ADR's failure-mode table pinned the literal `Run <bin> auth login`
string that remedy_line now varies three ways. The code cites ADR-0008 from
credential.rs and app.rs, so leaving it stale misleads the next reader.
Amended rather than superseded: this refines an accepted decision, it does
not reverse it. The new text also records the one known divergence between
`logged_in` and `has_credentials` rather than claiming they agree by
construction.

Co-Authored-By: Claude <noreply@anthropic.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.

1 participant