Skip to content

feat: add Spotify PKCE authentication and identity #4

Closed

Description

@rianjs

Parent: #1
Depends on: #3

Planned commit

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.

Activity

  1. rianjs commented on Jul 22, 2026

    @rianjs
    ContributorAuthor

    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:

    • Flags: --client-id, --redirect-uri, --credential-ref, inherited --backend, --non-interactive, --no-browser, --auth-code-stdin, --no-verify, and --overwrite.
    • 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:

    1. GOFLAGS=-tags=keyring_nopassage go test ./...
    2. GOFLAGS=-tags=keyring_nopassage go test -race ./...
    3. GOFLAGS=-tags=keyring_nopassage go vet ./...
    4. GOFLAGS=-tags=keyring_nopassage golangci-lint run
    5. make build
    6. CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -tags keyring_nopassage ./cmd/sptfy
    7. GOOS=windows GOARCH=amd64 go build -tags keyring_nopassage ./cmd/sptfy
    8. git diff --check
    9. 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.

    Non-goals

    • No Spotify SDK, generic OAuth/provider framework, client-secret flow, device-code flow, token environment fallback, telemetry, generic retry framework, search, pagination, cache, profile selector, or speculative command scaffolding.
    • No live Spotify call in hermetic unit tests; issue test: verify the initial Spotify CLI vertical slice #8 owns live smoke verification.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions