fix(cli): implement PKCE for secretless OAuth authorization-code login#3460
fix(cli): implement PKCE for secretless OAuth authorization-code login#3460rob-coco wants to merge 2 commits into
Conversation
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>
Merge Protections🔴 2 of 2 protections blocking · waiting on 🙋 you
🔴 require-ready-label-and-ciWaiting for any of
This rule is failing.
🔴 🚦 Auto-queueWaiting for
This rule is failing.When all merge protections are satisfied and these conditions match, this pull request will be queued automatically.
|
Greptile SummaryThis PR fixes the secretless OAuth2 authorization-code login path in the CLI generator template (
Confidence Score: 5/5Safe 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
|
… 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>
Intent
Generated OAuth2
auth login(fromauth.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 acode_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:
runOAuthLogingenerates a per-attemptcode_verifier(32 CSPRNG bytes, base64url unpadded), sendscode_challenge+code_challenge_method=S256on the authorize request, and sendscode_verifierinstead ofclient_secreton 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.generatePKCEVerifier,pkceCodeChallengeS256) so generated CLIs can unit-test them.http://localhost:<port>/callbacktohttp://127.0.0.1:<port>/callbackper RFC 8252 §7.3. Some providers (e.g. Spotify, per the downstream issue) rejectlocalhostoutright; the published spotify CLI already carries this as a hand-patch that regen would otherwise revert.http.PostForm's shared default client, per the timeout-separation rule inskills/printing-press/references/oauth2-pkce-cli-checklist.md(that checklist explicitly anticipates promoting these rules into the generator).client.go.tmpl'srefreshAccessTokenalready sendsclient_idand omits the secret when empty, which is the correct PKCE public-client refresh shape (locked by the new golden case'sclient.goartifact).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
authorization_codegrant (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.localhost→127.0.0.1redirect change means OAuth apps registered with alocalhostredirect URI need127.0.0.1registered instead (RFC 8252 guidance; most providers treat loopback IP literals leniently, and several — including the provider in the downstream issue — already rejectlocalhost).golden.sh update(the only golden diff is the new case).Output Contract
TestOAuthLoginUsesPKCEWhenNoClientSecret(internal/generator/auth_oauth_pkce_test.go): asserts emitted PKCE wiring, builds the generated binary, exercises the real flag→authorize-URL behavior underPRINTING_PRESS_VERIFY=1with 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.generate-golden-api-oauth2-authcode(fixture + expectedinternal/cli/auth.goandinternal/client/client.go): locks the authorization-code template-selection branch and the PKCE/loopback/refresh contract. Previously no golden case coveredauth.go.tmpl's authorization-code output (only client_credentials and device_code).Verification
go test ./...(all packages, pass), new test compiles and runs the generated CLIrunOAuthLogin, which is unconditional inauth.go.tmpl; template is selected only whenAuthorizationURL != ""; refresh call site inclient.go.tmplaudited (already gated correctly)Commands run:
go test ./...— passscripts/golden.sh verify— pass (31 cases, including the new one)scripts/golden.sh update— only diff is the newgenerate-golden-api-oauth2-authcodeexpected dir; all pre-existing goldens byte-identicalscripts/verify-generator-output.sh generate-golden-api-oauth2-authcode— generated module tidies and buildsAI / Automation Disclosure
🤖 Generated with Claude Code