You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
feat: add Spotify PKCE authentication and identity #4
feat: add Spotify PKCE authentication and identity
Work
Implement Authorization Code with PKCE for a user-supplied Spotify client ID; do not accept or store a client secret.
Use a cryptographic verifier/state, strict callback-state validation, an ephemeral 127.0.0.1 listener, and a five-minute timeout.
Implement init with a small huh/Bubble Tea flow and complete flag parity: client ID, redirect URI, credential ref, backend, non-interactive, no-browser, auth-code-stdin, no-verify, and overwrite.
Print authorization URLs and all setup interaction to stderr.
Request only user-read-private for the initial slice.
Persist refreshed access tokens back to the same oauth_token entry, retaining refresh token/scopes when Spotify omits them.
Implement me [--json] using GET /me, including stable account ID, display name, Spotify ID/URI, and granted scopes.
Make me fail nonzero for missing/invalid auth, refresh failure, insufficient scope, or unreachable Spotify.
Acceptance
Interactive, no-browser, non-interactive, denial, timeout, and auth-code-stdin paths are tested.
Token verification precedes committed init state unless --no-verify is supplied.
Partial config/credential writes roll back.
Refresh and invalid_grant behavior are covered without token leakage.
Plan — Issue #4: Spotify PKCE authentication and identity
Outcome
Ship a secure first-time authorization path and scripted health check on top of issue #3:
sptfy init supports a small huh/Bubble Tea setup form plus a complete prompt-free flag path.
Authorization uses Spotify PKCE with no client secret, strict state/callback validation, a five-minute loopback timeout, and optional redirected-URL stdin.
Config and the single oauth_token credential commit only after token exchange and optional /me verification; a config-save failure restores or removes the credential.
sptfy me [--json] loads, refreshes, persists, and uses the active credential, then renders stable identity and granted scopes.
Design constraints and deliberate limits
Reuse golang.org/x/oauth2 for PKCE options, token exchange, bearer transport, and standards-compliant refresh. Add no Spotify SDK and no generic provider abstraction.
Reuse github.com/charmbracelet/huh for the requested small setup form and github.com/pkg/browser for the existing cross-platform browser-open pattern. Do not build a custom TUI or OS command matrix.
Production Spotify account/API origins remain fixed. Internal endpoint injection exists only at the HTTP/auth construction seam for httptest; it is not user configuration.
Add only the /me data method and persistent OAuth transport needed now. Retry policy, search types, pagination, projection, and general resource rendering remain feat: add token-efficient track search #7.
Continue the existing typed exit-code mapping: bad flags/callbacks are usage 2, missing/expired auth and insufficient scope are config 3, and unreachable/non-auth Spotify failures are upstream 5.
All setup interaction and authorization URLs go to stderr. me data goes to stdout. No color is needed for this slice.
TDD implementation sequence
1. Lock the OAuth token conversion and refresh contract
Create internal/auth tests first, then the minimum implementation:
Convert between the existing strict token.Envelope and oauth2.Token without changing the stored version-1 wire format.
Build Spotify OAuth config with fixed authorize/token endpoints, AuthStyleInParams, client ID, exact redirect URI, and only user-read-private.
Wrap the OAuth token source so an access-token rotation is persisted under the same active ref using overwrite. Retain the previous refresh token and granted scopes when Spotify omits them.
Treat persistence failure as request failure rather than silently losing rotated credentials. Redact access token, refresh token, authorization code, verifier, and state from surfaced errors.
Tests use an httptest token endpoint to prove the PKCE refresh form includes client_id, invalid_grant is classified for reauthorization, rotated tokens persist once, and omitted refresh/scope values are retained. No credential value may appear in captured errors.
2. Implement and test strict PKCE callback acquisition
In internal/auth, add the concrete authorizer using stdlib crypto, URL, listener, server, and context primitives plus x/oauth2 exchange:
Generate the verifier with oauth2.GenerateVerifier() and generate an independent state with package-local crypto/rand; derive S256 through x/oauth2. Do not thread entropy through the root dependency graph.
For a portless http://127.0.0.1/... redirect, bind 127.0.0.1:0, insert the chosen port, and reuse that exact URI for authorization and exchange. Honor an explicit loopback port.
Always write the authorization URL to stderr. Skip the browser for --no-browser and --auth-code-stdin; browser-open failure is a stderr warning, not a failed authorization.
Listener mode accepts one callback on the configured path, compares state in constant time before accepting either code or denial, rejects missing/duplicate code-state values, reports denial without reflecting query values, and stops on command cancellation or the five-minute deadline.
Stdin mode starts no listener and accepts only a complete redirected URL matching the configured scheme/host/path and state; reject a raw code.
Exchange the code with the same redirect URI and verifier, normalize the returned token into the version-1 envelope, and use requested scopes only when the OAuth response legitimately omits its scope field.
Tests prove dynamic-port success, exact exchange redirect/verifier, state mismatch, denial, timeout/cancellation, no-browser behavior, browser failure fallback, and strict redirected-URL stdin. Parse the emitted authorization URL to validate the generated state and S256 relationship instead of injecting entropy. State and challenge may appear only inside that required URL; state must not appear in callback responses, later diagnostics, or errors. Verifier, authorization code, and tokens must never appear on any output channel. Use a package-local short test deadline seam; production remains five minutes.
3. Add the minimal typed Spotify /me client and pure renderer
Add internal/client and internal/output tests first:
client.Me(ctx) performs only GET /v1/me, uses a bounded response body, closes it, honors context cancellation, and returns a typed user containing account_id, optional display name, optional Spotify ID, and optional URI.
Map 401/403 separately from network and other upstream failures without including response bodies or authorization headers in errors. Require a non-empty stable account_id on success.
Pure text rendering emits stable tab-separated key/value lines with - for optional empty values and sorted granted scopes. JSON rendering uses a small explicit control-plane result shape.
Tests lock exact text/JSON output, optional values, malformed/oversized responses, 401, 403, other status, and unreachable transport behavior.
4. Add init orchestration with flag parity and rollback
Add internal/cmd/initcmd tests before wiring the command:
Interactive mode is allowed only when stdin is a TTY and --non-interactive is absent. The small huh form edits client ID, redirect URI, credential ref, and backend using the same validation as flags. A non-TTY or explicit non-interactive run never invokes the form and names any missing required flag.
Load existing non-secret config as defaults. Validate client ID, redirect URI, credential ref, and explicit backend before opening a browser/listener or making network requests.
Preflight the target oauth_token: without --overwrite, fail before authorization when it exists; with overwrite, snapshot the old value for possible rollback.
Authorize in memory, verify through /me before any write unless --no-verify, then write the credential and atomically save config. If config save fails, restore the snapshot or delete a newly created credential and join any rollback failure into the secret-free error.
After a successful commit, print only concise setup/identity confirmation to stderr. Emit no primary stdout data from init.
Tests prove prompt/flag parity, interactive and prompt-free paths, missing input, existing-credential refusal, overwrite, verification ordering, --no-verify, credential-write failure, config-save rollback for both new and replaced credentials, rollback failure reporting, and sentinel no-leak behavior.
5. Add me command orchestration
Add internal/cmd/mecmd tests before implementation:
Require a configured client ID, valid active credential ref, present oauth_token, and granted user-read-private scope.
Build the persistent authenticated client, call /me, and render text by default or JSON with --json.
Persist a refreshed envelope to the same profile/key with overwrite and never change credential_ref.
Map missing/invalid/expired auth, invalid_grant, 401, 403, or insufficient scope to actionable config errors; map unreachable and other Spotify failures to upstream errors.
Keep stdout empty on failures and keep all errors secret-free on stderr through the existing root boundary.
Tests include valid identity, JSON, missing config/credential/scope, refresh success and persistence, omitted refresh/scope preservation, invalid_grant, 401/403, unreachable upstream, output writer failure, and process-level exit/stdout/stderr checks.
6. Wire only production effects at the composition root
Register init and me in internal/cmd/root.
Extend root dependencies only for real effects: terminal detection, setup prompt, browser opener, HTTP transport/endpoints, the existing clock, and the config-save seam required for deterministic rollback tests. Keep entropy and callback timeout inside internal/auth; keep the concrete production choices in cmd/sptfy/main.go; do not add a service locator or package globals.
Reuse the existing credentials.Opener, config, token, and exitcode packages rather than creating parallel stores or error systems.
Add process tests proving setup chatter/URL routing to stderr, me data to stdout, and stable exit classes.
7. Dependency, documentation, and regression checks
Add only the three direct dependencies justified above; run go mod tidy and inspect the resulting diff.
Update README/development documentation only where the now-shipped init/me usage or Spotify dashboard redirect setup would otherwise be missing or stale; do not broaden future-command documentation.
Run the no-leak sentinel across init exchange/denial/refresh/me error paths.
Verification
Run with an isolated writable Go cache where needed:
GOFLAGS=-tags=keyring_nopassage go test ./...
GOFLAGS=-tags=keyring_nopassage go test -race ./...
GOFLAGS=-tags=keyring_nopassage go vet ./...
GOFLAGS=-tags=keyring_nopassage golangci-lint run
make build
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -tags keyring_nopassage ./cmd/sptfy
GOOS=windows GOARCH=amd64 go build -tags keyring_nopassage ./cmd/sptfy
git diff --check
Required plan-execute gates: shared Sol plan convergence, shared Sol PR convergence, one fresh Terra TDD review, live cr approval, final shared Sol drift review, and green PR feat: ship initial Spotify CLI vertical slice #9 CI.
Activity
rianjs commentedon Jul 22, 2026
Plan — Issue #4: Spotify PKCE authentication and identity
Outcome
Ship a secure first-time authorization path and scripted health check on top of issue #3:
sptfy initsupports a smallhuh/Bubble Tea setup form plus a complete prompt-free flag path.oauth_tokencredential commit only after token exchange and optional/meverification; a config-save failure restores or removes the credential.sptfy me [--json]loads, refreshes, persists, and uses the active credential, then renders stable identity and granted scopes.Design constraints and deliberate limits
golang.org/x/oauth2for PKCE options, token exchange, bearer transport, and standards-compliant refresh. Add no Spotify SDK and no generic provider abstraction.github.com/charmbracelet/huhfor the requested small setup form andgithub.com/pkg/browserfor the existing cross-platform browser-open pattern. Do not build a custom TUI or OS command matrix.httptest; it is not user configuration./medata method and persistent OAuth transport needed now. Retry policy, search types, pagination, projection, and general resource rendering remain feat: add token-efficient track search #7.medata goes to stdout. No color is needed for this slice.TDD implementation sequence
1. Lock the OAuth token conversion and refresh contract
Create
internal/authtests first, then the minimum implementation:token.Envelopeandoauth2.Tokenwithout changing the stored version-1 wire format.AuthStyleInParams, client ID, exact redirect URI, and onlyuser-read-private.Tests use an
httptesttoken endpoint to prove the PKCE refresh form includesclient_id,invalid_grantis classified for reauthorization, rotated tokens persist once, and omitted refresh/scope values are retained. No credential value may appear in captured errors.2. Implement and test strict PKCE callback acquisition
In
internal/auth, add the concrete authorizer using stdlib crypto, URL, listener, server, and context primitives plusx/oauth2exchange:oauth2.GenerateVerifier()and generate an independent state with package-localcrypto/rand; derive S256 throughx/oauth2. Do not thread entropy through the root dependency graph.http://127.0.0.1/...redirect, bind127.0.0.1:0, insert the chosen port, and reuse that exact URI for authorization and exchange. Honor an explicit loopback port.--no-browserand--auth-code-stdin; browser-open failure is a stderr warning, not a failed authorization.Tests prove dynamic-port success, exact exchange redirect/verifier, state mismatch, denial, timeout/cancellation, no-browser behavior, browser failure fallback, and strict redirected-URL stdin. Parse the emitted authorization URL to validate the generated state and S256 relationship instead of injecting entropy. State and challenge may appear only inside that required URL; state must not appear in callback responses, later diagnostics, or errors. Verifier, authorization code, and tokens must never appear on any output channel. Use a package-local short test deadline seam; production remains five minutes.
3. Add the minimal typed Spotify
/meclient and pure rendererAdd
internal/clientandinternal/outputtests first:client.Me(ctx)performs onlyGET /v1/me, uses a bounded response body, closes it, honors context cancellation, and returns a typed user containingaccount_id, optional display name, optional Spotify ID, and optional URI.account_idon success.-for optional empty values and sorted granted scopes. JSON rendering uses a small explicit control-plane result shape.Tests lock exact text/JSON output, optional values, malformed/oversized responses, 401, 403, other status, and unreachable transport behavior.
4. Add
initorchestration with flag parity and rollbackAdd
internal/cmd/initcmdtests before wiring the command:--client-id,--redirect-uri,--credential-ref, inherited--backend,--non-interactive,--no-browser,--auth-code-stdin,--no-verify, and--overwrite.--non-interactiveis absent. The smallhuhform edits client ID, redirect URI, credential ref, and backend using the same validation as flags. A non-TTY or explicit non-interactive run never invokes the form and names any missing required flag.oauth_token: without--overwrite, fail before authorization when it exists; with overwrite, snapshot the old value for possible rollback./mebefore any write unless--no-verify, then write the credential and atomically save config. If config save fails, restore the snapshot or delete a newly created credential and join any rollback failure into the secret-free error.init.Tests prove prompt/flag parity, interactive and prompt-free paths, missing input, existing-credential refusal, overwrite, verification ordering,
--no-verify, credential-write failure, config-save rollback for both new and replaced credentials, rollback failure reporting, and sentinel no-leak behavior.5. Add
mecommand orchestrationAdd
internal/cmd/mecmdtests before implementation:oauth_token, and granteduser-read-privatescope./me, and render text by default or JSON with--json.credential_ref.invalid_grant, 401, 403, or insufficient scope to actionable config errors; map unreachable and other Spotify failures to upstream errors.Tests include valid identity, JSON, missing config/credential/scope, refresh success and persistence, omitted refresh/scope preservation,
invalid_grant, 401/403, unreachable upstream, output writer failure, and process-level exit/stdout/stderr checks.6. Wire only production effects at the composition root
initandmeininternal/cmd/root.internal/auth; keep the concrete production choices incmd/sptfy/main.go; do not add a service locator or package globals.credentials.Opener,config,token, andexitcodepackages rather than creating parallel stores or error systems.medata to stdout, and stable exit classes.7. Dependency, documentation, and regression checks
go mod tidyand inspect the resulting diff.init/meusage or Spotify dashboard redirect setup would otherwise be missing or stale; do not broaden future-command documentation.Verification
Run with an isolated writable Go cache where needed:
GOFLAGS=-tags=keyring_nopassage go test ./...GOFLAGS=-tags=keyring_nopassage go test -race ./...GOFLAGS=-tags=keyring_nopassage go vet ./...GOFLAGS=-tags=keyring_nopassage golangci-lint runmake buildCGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -tags keyring_nopassage ./cmd/sptfyGOOS=windows GOARCH=amd64 go build -tags keyring_nopassage ./cmd/sptfygit diff --checkcrapproval, final shared Sol drift review, and green PR feat: ship initial Spotify CLI vertical slice #9 CI.Non-goals