Client Credentials Authentication
Context
Today, SpotifyClient and AsyncSpotifyClient require callers to supply an
access_token directly. We want to support Spotify's client credentials flow
so the SDK can obtain, cache, and refresh access tokens when given a client ID
and client secret.
Client credentials is a server-to-server OAuth flow. It returns an access token
with an expires_in TTL, no refresh token, and is intended for non-user data
access.
Example:
Goals
- Make authentication seamless: users can pass
client_idandclient_secretand start making API calls without manually obtaining tokens. - Keep async-first design: async auth code is the source of truth and sync is generated.
- Cache and refresh tokens automatically and safely under concurrency.
- Preserve backward compatibility for users who provide
access_token.
Non-goals
- Implementing Authorization Code, PKCE, or other user-auth flows.
- Persisting tokens securely to disk (can be added later via a cache interface).
- Building a full credential manager or secrets vault integration.
Proposed User Experience
Construction API
Preferred: add explicit constructors for client credentials to avoid ambiguity. Auth inputs are mutually exclusive across all entry points.
from spotify_sdk import SpotifyClient, AsyncSpotifyClient
client = SpotifyClient.from_client_credentials(
client_id="...",
client_secret="...",
)
async_client = AsyncSpotifyClient.from_client_credentials(
client_id="...",
client_secret="...",
)
Alternative (optional): allow client_id + client_secret on the existing
constructor, but error if mixed with access_token to keep behavior explicit.
Also allow passing a custom auth provider instance directly to the client constructor for advanced or future flows.
Example:
from spotify_sdk import AsyncSpotifyClient
from spotify_sdk.auth import AsyncClientCredentials
auth = AsyncClientCredentials(
client_id="...",
client_secret="...",
)
client = AsyncSpotifyClient(auth_provider=auth)
Environment Variable Support
If client_id or client_secret are omitted, read defaults from:
SPOTIFY_SDK_CLIENT_IDSPOTIFY_SDK_CLIENT_SECRET
Precedence: explicit constructor or factory arguments override environment
variables. If neither is provided, raise a configuration error. Environment
variables are ignored when auth_provider is supplied.
Backward Compatibility
access_tokenremains supported with the current behavior.- If multiple auth inputs are provided (
access_token, client credentials,auth_provider), raise a configuration error to avoid ambiguous precedence.
Invalid Combinations
| Inputs | Behavior |
|---|---|
access_token + auth_provider |
Error |
access_token + client_id/client_secret |
Error |
auth_provider + client_id/client_secret |
Error |
Auth Architecture
New Auth Module
Add an async-first auth module under src/spotify_sdk/_async/auth/:
AsyncAuthProvider: protocol withget_access_token()and optionalclose().AsyncClientCredentials: fetches access tokens from Spotify Accounts.TokenCacheinterface:get()/set()with in-memory default.
The sync variant in src/spotify_sdk/_sync/auth/ is generated by unasync.
Provider Protocol
from __future__ import annotations
from typing import Protocol
class AsyncAuthProvider(Protocol):
async def get_access_token(self) -> str:
"""Return a valid access token (refreshing if needed)."""
async def close(self) -> None:
"""Optional cleanup hook (default no-op)."""
Sync providers are generated from this protocol and return str without
awaiting.
Token Cache Contract
Token cache entries should include:
access_token: the token to use for requests.expires_at: absolute UTC timestamp (float seconds since epoch), computed fromnow + expires_in.
Expiry check should include a small skew window (e.g., 30 seconds) so refresh happens before the token is actually expired. Cache entries are owned by the provider, so additional fields are allowed.
Token Fetching
Client credentials token requests are sent to the Accounts service:
POST https://accounts.spotify.com/api/tokenAuthorization: Basic base64(client_id:client_secret)Content-Type: application/x-www-form-urlencoded- Body:
grant_type=client_credentials
The response includes access_token, token_type (Bearer), and expires_in
seconds, but no refresh token.
Base Client Integration
Modify AsyncBaseClient to accept either:
1) A fixed access_token, or
2) An AsyncAuthProvider instance.
Requests should build the Authorization header per request by calling
get_access_token() when an auth provider is configured. If a fixed
access_token is supplied, reuse it directly and skip the auth provider.
The header format is Authorization: Bearer <token>. Per-request overrides
are not supported in this design.
Caching and Refresh Strategy
- Store token and
expires_atin a cache entry. - Use a small skew window (e.g., 30 seconds) to refresh early.
- If no token or token is expired/near-expiry, fetch a new token.
- Because there is no refresh token for this flow, refresh means re-requesting via client credentials.
Concurrency Control
Use an anyio.Lock to ensure only one refresh occurs when multiple tasks
attempt to use an expired token concurrently. Other callers should await the
lock and then reuse the refreshed token.
For sync clients, use a standard threading.Lock to provide the same behavior.
Error Handling
- If token acquisition fails, raise
AuthenticationErrorwith the response payload for debugging. - Optionally, on a 401 response from the API, attempt a single forced refresh and retry once, then surface the error if it persists.
Token endpoint errors should follow the same retry/backoff strategy used by the base client (connection timeouts, 5xx) to keep behavior consistent.
Testing Plan
- Unit tests for token fetching, cache hit/miss, and refresh behavior.
- Concurrency test to ensure one refresh for N simultaneous requests.
- Error-path test for failed token request (invalid client ID/secret).
- Retry-on-401 test if implemented.
- Sync variants generated via
run_unasync.py.
Documentation Updates (Post-Implementation)
- Update
docs/reference/clients.mdto include client credentials usage. - Add a quickstart section showing
from_client_credentials. - Document limitations: client credentials do not access user data endpoints.
Open Questions
- Do we want constructor kwargs (
client_id,client_secret) or only explicit factory methods? - Should we expose a public token cache interface now, or keep it internal until persistent caching is requested?
- What skew window (seconds before expiry) should we use by default?
Future Auth Compatibility
The auth provider interface is intended to support additional flows later
(Authorization Code, PKCE, device code) without changing AsyncBaseClient.
Those providers can surface scopes and refresh tokens internally while still
exposing get_access_token() to the client layer. Provider-specific cache
entries can store extra fields (e.g., scopes, refresh tokens) without affecting
the base client.
References
- https://developer.spotify.com/documentation/web-api/concepts/authorization
- https://developer.spotify.com/documentation/web-api/tutorials/client-credentials-flow
- https://github.com/spotify/spotify-web-api-ts-sdk/blob/main/src/auth/ClientCredentialsStrategy.ts