Skip to content

fix(cli): implement PKCE for secretless OAuth authorization-code login#3460

Open
rob-coco wants to merge 2 commits into
mvanhorn:mainfrom
rob-coco:fix/oauth-pkce-secretless-login
Open

fix(cli): implement PKCE for secretless OAuth authorization-code login#3460
rob-coco wants to merge 2 commits into
mvanhorn:mainfrom
rob-coco:fix/oauth-pkce-secretless-login

Conversation

@rob-coco

@rob-coco rob-coco commented Jul 5, 2026

Copy link
Copy Markdown

Intent

Generated OAuth2 auth login (from auth.go.tmpl) never implements PKCE, so the documented CLIENT_ID-only (no client secret) login path fails at token exchange in every printed CLI with authorization-code auth: the request carries neither client authentication nor a code_verifier, and spec-compliant token endpoints reject it.

Issue: Closes #3457. Refs mvanhorn/printing-press-library#1287 (downstream report against spotify-pp-cli with full reproduction; that CLI needs a regen + republish after this lands).

Approach

RFC 7636 PKCE, gated on the secretless path so confidential-client behavior is untouched:

  • When no client secret is configured, runOAuthLogin generates a per-attempt code_verifier (32 CSPRNG bytes, base64url unpadded), sends code_challenge + code_challenge_method=S256 on the authorize request, and sends code_verifier instead of client_secret on the token exchange. The secretless path is guaranteed-broken today, so the guard can only improve it; with a secret present the emitted flow is byte-identical in behavior.
  • PKCE logic lives in two small emitted helpers (generatePKCEVerifier, pkceCodeChallengeS256) so generated CLIs can unit-test them.
  • Loopback redirect URI switches from http://localhost:<port>/callback to http://127.0.0.1:<port>/callback per RFC 8252 §7.3. Some providers (e.g. Spotify, per the downstream issue) reject localhost outright; the published spotify CLI already carries this as a hand-patch that regen would otherwise revert.
  • The token exchange now uses a dedicated 30s-timeout HTTP client instead of http.PostForm's shared default client, per the timeout-separation rule in skills/printing-press/references/oauth2-pkce-cli-checklist.md (that checklist explicitly anticipates promoting these rules into the generator).
  • No change to the refresh path: client.go.tmpl's refreshAccessToken already sends client_id and omits the secret when empty, which is the correct PKCE public-client refresh shape (locked by the new golden case's client.go artifact).

Scope

Primary area: internal/generator/templates/auth.go.tmpl (machine change)

Why this belongs in this repo: the symptom surfaced in a printed CLI (spotify-pp-cli), but the broken file is generator output (DO NOT EDIT) emitted for every authorization-code OAuth CLI. A printed-CLI patch would be overwritten on regen and would not fix the other printed CLIs with the same flaw.

Risk

  • Providers that accept a bare secretless authorization_code grant (non-compliant but conceivable) now receive PKCE parameters. RFC 6749 requires servers to ignore unrecognized parameters, and that path was failing against every mainstream provider anyway.
  • The localhost127.0.0.1 redirect change means OAuth apps registered with a localhost redirect URI need 127.0.0.1 registered instead (RFC 8252 guidance; most providers treat loopback IP literals leniently, and several — including the provider in the downstream issue — already reject localhost).
  • No MCP surface, publish flow, scorer, or release behavior changes. Existing golden cases are byte-identical after golden.sh update (the only golden diff is the new case).

Output Contract

  • New generated-output test TestOAuthLoginUsesPKCEWhenNoClientSecret (internal/generator/auth_oauth_pkce_test.go): asserts emitted PKCE wiring, builds the generated binary, exercises the real flag→authorize-URL behavior under PRINTING_PRESS_VERIFY=1 with and without a secret, and injects runtime tests into the generated module run with -race — including the RFC 7636 appendix B challenge vector and verifier shape/uniqueness properties.
  • New golden case generate-golden-api-oauth2-authcode (fixture + expected internal/cli/auth.go and internal/client/client.go): locks the authorization-code template-selection branch and the PKCE/loopback/refresh contract. Previously no golden case covered auth.go.tmpl's authorization-code output (only client_credentials and device_code).

Verification

  • Generator/template change: verified generated output, including emitted-code assertions or compiled generated CLI output — go test ./... (all packages, pass), new test compiles and runs the generated CLI
  • Generator/template change: covered the affected fallback or variant shape, not only happy-path fixtures — both secretless (PKCE) and with-secret (unchanged plain auth-code) variants asserted behaviorally
  • Generator/template change: checked emitted definitions and call sites for matching gates — helpers are called only from runOAuthLogin, which is unconditional in auth.go.tmpl; template is selected only when AuthorizationURL != ""; refresh call site in client.go.tmpl audited (already gated correctly)

Commands run:

  • go test ./... — pass
  • scripts/golden.sh verify — pass (31 cases, including the new one)
  • scripts/golden.sh update — only diff is the new generate-golden-api-oauth2-authcode expected dir; all pre-existing goldens byte-identical
  • scripts/verify-generator-output.sh generate-golden-api-oauth2-authcode — generated module tidies and builds

AI / Automation Disclosure

  • No AI or automation was used
  • Human-reviewed: AI or automation was used, and a human reviewed the work for intent, fit, and obvious issues before submission
  • AI-reviewed only: an AI agent reviewed the work, but no human reviewed it before submission
  • Fully automated: generated and submitted without human review for this specific change

🤖 Generated with Claude Code

Generated auth login sent a bare authorization_code grant when no client
secret was configured — no client authentication and no code_verifier —
which token endpoints reject. Every printed CLI with authorization-code
OAuth inherited the broken CLIENT_ID-only path.

When no secret is set, the emitted flow now generates a per-attempt RFC
7636 verifier, sends an S256 code_challenge on the authorize request,
and exchanges with code_verifier in place of client_secret. Secret-based
flows are unchanged. The loopback redirect moves from localhost to
127.0.0.1 per RFC 8252 §7.3, and the token exchange gets its own 30s
client instead of the shared default.

Adds a generated-output test (compiled binary, verify-env URL assertions
both ways, injected -race runtime tests incl. the RFC 7636 appendix B
vector) and a golden case locking the authorization-code + PKCE shape,
which previously had no golden coverage.

Closes mvanhorn#3457. Refs mvanhorn/printing-press-library#1287.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mergify

mergify Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Merge Protections

🔴 2 of 2 protections blocking · waiting on 🙋 you

Protection Waiting on
🔴 require-ready-label-and-ci 🙋 you
🔴 🚦 Auto-queue 🙋 you

🔴 require-ready-label-and-ci

Waiting for any of

  • label = ready-to-merge
  • title ~= ^chore\(main\): release
This rule is failing.
  • any of:
    • label = ready-to-merge
    • all of:
      • head = release-please--branches--main
      • title ~= ^chore\(main\): release
  • #changes-requested-reviews-by = 0
  • #review-threads-unresolved = 0
  • check-success = build-and-test
  • check-success = generated-test
  • check-success = go-lint
  • check-success = golden
  • check-success = pr-title
  • check-success = test
  • any of:
    • -files ~= ^(\.github/workflows/|\.github/scripts/|scripts/|\.github/CODEOWNERS$)
    • approved-reviews-by = mvanhorn
    • approved-reviews-by = tmchow
    • author = mvanhorn
    • author = tmchow
  • any of:
    • check-success = Greptile Review
    • check-neutral = Greptile Review
    • check-skipped = Greptile Review
    • head ~= ^mergify/merge-queue/
    • label = queued
    • all of:
      • head = release-please--branches--main
      • title ~= ^chore\(main\): release

🔴 🚦 Auto-queue

Waiting for

  • label = ready-to-merge
This rule is failing.

When all merge protections are satisfied and these conditions match, this pull request will be queued automatically.

  • label = ready-to-merge

@greptile-apps

greptile-apps Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes the secretless OAuth2 authorization-code login path in the CLI generator template (auth.go.tmpl), which was guaranteed-broken against spec-compliant providers because the generated code sent neither a client secret nor a PKCE code_verifier to the token endpoint. The fix implements RFC 7636 PKCE on the secretless path only, keeping confidential-client behavior byte-identical.

  • PKCE added to secretless path: when clientSecret == "", runOAuthLogin generates a 32-byte CSPRNG verifier (base64url-unpadded, 43 chars — within RFC 7636's 43–128 range), sends code_challenge/code_challenge_method=S256 on the authorize request, and sends code_verifier on the token exchange instead of client_secret.
  • Loopback URI hardened: both the verify-env short-circuit and the live listener redirect URI switch from http://localhost:… to http://127.0.0.1:… per RFC 8252 §7.3, fixing providers (e.g. Spotify) that reject localhost outright.
  • Token exchange upgraded: http.PostForm (shared default client, no cancellation) replaced with http.NewRequestWithContext(cmd.Context(), …) and a dedicated 30 s http.Client, making the exchange cancellable and timeout-isolated from the browser-wait timer.

Confidence Score: 5/5

Safe to merge. The change fixes a definitively broken code path — secretless authorization-code login was always rejected by spec-compliant providers — and the fix is narrowly scoped to the secretless branch, leaving confidential-client behavior untouched.

The PKCE implementation is technically correct: 32-byte CSPRNG verifier, base64url-unpadded encoding (43 chars, within RFC 7636's 43–128 range), SHA-256 challenge derived from the ASCII verifier, S256 method declared. The localhost → 127.0.0.1 redirect change is low-risk and RFC 8252-prescribed. The token exchange upgrade to http.NewRequestWithContext(cmd.Context(), …) with a dedicated http.Client is strictly an improvement. Test coverage is thorough: RFC 7636 appendix B vector, verifier shape/uniqueness, both secretless and with-secret behavioral paths under the verify env, and a new golden case locking both auth.go and client.go contracts for the authorization-code template branch. No pre-existing goldens were modified.

No files require special attention. The template (auth.go.tmpl) is the only functional change and its generated output is fully locked by the new golden case and the behavioral test.

Important Files Changed

Filename Overview
internal/generator/templates/auth.go.tmpl Core template change: adds RFC 7636 PKCE helpers and wires them into the secretless path, switches all loopback redirect URIs to 127.0.0.1, and upgrades the token exchange to use a context-aware dedicated HTTP client. Implementation is correct — verifier entropy (32 bytes), encoding (base64url unpadded), and challenge derivation (SHA-256 of ASCII verifier) all match the RFC.
internal/generator/auth_oauth_pkce_test.go New test file: generates a CLI from a minimal OAuth2 authorization-code spec, asserts PKCE wiring in the emitted source, builds the generated binary, exercises the authorize URL under PRINTING_PRESS_VERIFY=1 for both secretless and with-secret paths, then injects and runs the RFC 7636 appendix B vector test and verifier-shape/uniqueness test against the generated module with -race.
testdata/golden/expected/generate-golden-api-oauth2-authcode/printing-press-oauth2-authcode/internal/cli/auth.go New golden fixture locking the authorization-code auth.go contract: PKCE helpers present and correctly implemented, 127.0.0.1 loopback URIs throughout, token exchange uses http.NewRequestWithContext with cmd.Context() and a dedicated 30 s client, and the secretless/with-secret branch is correctly gated.
testdata/golden/expected/generate-golden-api-oauth2-authcode/printing-press-oauth2-authcode/internal/client/client.go New golden fixture locking the client.go contract for the authorization-code auth shape: refreshAccessToken sends client_id always and client_secret only when non-empty — the correct public-client refresh shape.
testdata/golden/fixtures/golden-api-oauth2-authcode.yaml New OpenAPI 3.0 fixture declaring an authorizationCode OAuth2 flow; minimal but sufficient to exercise the authorization-code template-selection branch that was previously uncovered by any golden case.
testdata/golden/cases/generate-golden-api-oauth2-authcode/artifacts.txt Golden case artifacts descriptor locking both auth.go and client.go as checked artifacts, with inline documentation explaining what each file asserts about the PKCE and refresh contract.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant User
    participant CLI as Generated CLI (auth login)
    participant Browser
    participant Provider as OAuth Provider

    User->>CLI: auth login [--client-id X]
    CLI->>CLI: "clientSecret == "" ?"
    alt Secretless (PKCE path)
        CLI->>CLI: generatePKCEVerifier() → codeVerifier
        CLI->>CLI: pkceCodeChallengeS256(codeVerifier) → challenge
        CLI->>Browser: "Open authorize URL with code_challenge + method=S256"
    else With client secret
        CLI->>Browser: Open authorize URL (no PKCE params)
    end
    Browser->>Provider: "GET /oauth/authorize?code_challenge=…"
    Provider->>Browser: "Redirect → http://127.0.0.1:PORT/callback?code=…"
    Browser->>CLI: "GET /callback?code=CODE&state=STATE"
    CLI->>CLI: validate state
    alt Secretless
        CLI->>Provider: "POST /token {grant_type=authorization_code, code_verifier=…}"
    else With client secret
        CLI->>Provider: "POST /token {grant_type=authorization_code, client_secret=…}"
    end
    Provider->>CLI: "{access_token, refresh_token, expires_in}"
    CLI->>CLI: cfg.SaveTokens(…)
    CLI->>User: Authentication successful!
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant User
    participant CLI as Generated CLI (auth login)
    participant Browser
    participant Provider as OAuth Provider

    User->>CLI: auth login [--client-id X]
    CLI->>CLI: "clientSecret == "" ?"
    alt Secretless (PKCE path)
        CLI->>CLI: generatePKCEVerifier() → codeVerifier
        CLI->>CLI: pkceCodeChallengeS256(codeVerifier) → challenge
        CLI->>Browser: "Open authorize URL with code_challenge + method=S256"
    else With client secret
        CLI->>Browser: Open authorize URL (no PKCE params)
    end
    Browser->>Provider: "GET /oauth/authorize?code_challenge=…"
    Provider->>Browser: "Redirect → http://127.0.0.1:PORT/callback?code=…"
    Browser->>CLI: "GET /callback?code=CODE&state=STATE"
    CLI->>CLI: validate state
    alt Secretless
        CLI->>Provider: "POST /token {grant_type=authorization_code, code_verifier=…}"
    else With client secret
        CLI->>Provider: "POST /token {grant_type=authorization_code, client_secret=…}"
    end
    Provider->>CLI: "{access_token, refresh_token, expires_in}"
    CLI->>CLI: cfg.SaveTokens(…)
    CLI->>User: Authentication successful!
Loading

Reviews (2): Last reviewed commit: "fix(cli): address review — context-cance..." | Re-trigger Greptile

Comment thread internal/generator/templates/auth.go.tmpl Outdated
Comment thread internal/generator/auth_oauth_pkce_test.go
… test assertions

Token exchange now uses http.NewRequestWithContext(cmd.Context(), ...)
so Ctrl+C cancels an in-flight exchange, matching the refreshAccessToken
idiom in client.go.tmpl. Split the whitespace-sensitive combined
assertion into independent Contains checks; mutual exclusivity stays
covered by the verify-env behavioral checks.

Co-Authored-By: Claude Fable 5 <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.

fix(cli): generated OAuth auth login lacks PKCE — CLIENT_ID-only flow always fails at token exchange

1 participant