Authorization Code + Refresh Token Authentication
Context
spotify-sdk already supports:
- Static bearer tokens (
access_token=...). - App-only OAuth via
AsyncClientCredentials/ClientCredentials.
This document records the implemented Authorization Code + Refresh Token
approach for user-scoped endpoints (/me, library, follows, playback, playlist
writes). Design goals were:
- Fits the existing async-first + unasync architecture.
- Reuses current auth provider patterns (cache, retries, lock, env fallback).
- Avoids framework-specific behavior in core SDK.
Goals
- Add a provider for Spotify Authorization Code flow.
- Automatically refresh expired access tokens with
refresh_token. - Keep the client integration unchanged (
auth_provider=...). - Preserve compatibility with existing
TokenCacheusage patterns. - Keep sync API generated from async source.
Non-goals
- PKCE in this iteration.
- Embedding browser/callback side effects directly in provider methods.
- Secure persistent token storage implementation in core SDK.
Implemented Public API
New provider classes
spotify_sdk.auth.AsyncAuthorizationCode(canonical implementation).spotify_sdk.auth.AuthorizationCode(generated sync class).
Constructor
from spotify_sdk.auth import AsyncAuthorizationCode
auth = AsyncAuthorizationCode(
client_id="...",
client_secret="...",
redirect_uri="http://127.0.0.1:8080/callback",
scope=["user-read-private", "playlist-read-private"],
refresh_token=None, # optional bootstrap for returning users
token_cache=None, # defaults to InMemoryTokenCache
timeout=30.0,
max_retries=3,
skew_seconds=30,
http_client=None,
)
Methods
class AsyncAuthorizationCode:
def get_authorization_url(
self,
*,
state: str | None = None,
scope: str | list[str] | tuple[str, ...] | None = None,
show_dialog: bool = False,
) -> str: ...
@staticmethod
def parse_response_url(
url: str,
*,
expected_state: str | None = None,
) -> str: # returns authorization code
...
async def exchange_code(self, code: str) -> TokenInfo: ...
async def get_access_token(self) -> str: ...
async def close(self) -> None: ...
Local helper functions
from spotify_sdk.auth import (
AsyncAuthorizationCode,
AuthorizationCode,
async_authorize_local,
authorize_local,
)
sync_auth = AuthorizationCode(scope="user-read-private")
sync_token = authorize_local(sync_auth) # local loopback helper
async_auth = AsyncAuthorizationCode(scope="user-read-private")
async_token = await async_authorize_local(async_auth)
Both helpers are optional convenience layers for local/CLI flows. Core
provider methods remain explicit (get_authorization_url, parse_response_url,
exchange_code).
Typical usage
from spotify_sdk import AsyncSpotifyClient
from spotify_sdk.auth import AsyncAuthorizationCode
auth = AsyncAuthorizationCode(
client_id="...",
client_secret="...",
redirect_uri="http://127.0.0.1:8080/callback",
scope=["user-read-private", "playlist-read-private"],
)
state = "csrf-token-generated-by-app"
authorize_url = auth.get_authorization_url(state=state, show_dialog=True)
# App redirects user to authorize_url, then receives callback URL:
callback_url = "http://127.0.0.1:8080/callback?code=...&state=..."
code = auth.parse_response_url(callback_url, expected_state=state)
await auth.exchange_code(code)
async with AsyncSpotifyClient(auth_provider=auth) as client:
# token is refreshed transparently when expired
...
Provider Behavior
Token lifecycle
exchange_code(code)sendsgrant_type=authorization_coderequest.- Response token is cached (
access_token,expires_at,refresh_token). get_access_token()serves cached token until near expiry.- On expiry, provider calls token endpoint with
grant_type=refresh_token. - New token is cached. If refresh response omits
refresh_token, keep the previous refresh token.
Token endpoint requests
- Endpoint:
POST https://accounts.spotify.com/api/token - Headers:
Authorization: Basic base64(client_id:client_secret)Content-Type: application/x-www-form-urlencoded
- Exchange payload:
grant_type=authorization_codecode=<authorization_code>redirect_uri=<registered_redirect_uri>
- Refresh payload:
grant_type=refresh_tokenrefresh_token=<refresh_token>
Authorization URL
- Endpoint:
https://accounts.spotify.com/authorize - Query params:
response_type=codeclient_idredirect_uriscope(space-delimited, optional)state(optional, recommended)show_dialog=true(optional)
Data Model and Cache
TokenInfo stores access_token, expires_at, refresh_token, and scope.
TokenInfo shape
@dataclass(frozen=True)
class TokenInfo:
access_token: str
expires_at: float
refresh_token: str | None = None
scope: str | None = None
Rationale:
- Keeps one cache protocol for all auth providers.
- Existing call sites remain valid because new fields are optional.
- Avoids introducing a second cache interface for a single field addition.
Concurrency, Retry, and Errors
Concurrency
- Keep the same lock pattern as
AsyncClientCredentials:anyio.Lockin async code.threading.Lockin generated sync code.
- Use double-checked cache reads to avoid duplicate refresh calls.
Retry
- Reuse current backoff strategy and constants:
- Retry connection/timeouts and 5xx responses.
- Do not retry 4xx OAuth errors except explicit caller retry.
Error mapping
- OAuth failures (
invalid_grant,invalid_client, state mismatch, missing code) map toAuthenticationError. - Unexpected transport failures map to
SpotifyErrorconsistent with existing auth providers.
Integration with Existing SDK
No client constructor changes required
The existing client API already supports custom providers:
No changes required to AsyncBaseClient / BaseClient contract.
Implementation touchpoints
src/spotify_sdk/_async/auth/__init__.py:- Add
AsyncAuthorizationCode. - Extend
TokenInfo. - Add
ENV_REDIRECT_URIconstant.
- Add
src/spotify_sdk/auth/__init__.py:- Export
AsyncAuthorizationCodeand generatedAuthorizationCode.
- Export
scripts/run_unasync.py:- Add replacement mapping:
"AsyncAuthorizationCode": "AuthorizationCode"
- Tests:
- Add
tests/_async/test_auth_authorization_code.py. - Regenerate sync tests via
scripts/run_unasync.py.
- Add
Deviations from Spotipy (Intentional)
Compared with spotipy.SpotifyOAuth, this design keeps provider methods
explicit and side-effect free by default:
- Provider classes handle token mechanics (URL generation, code exchange, refresh) without forcing browser or prompt behavior.
- Local browser/callback automation is available via opt-in helper functions
(
authorize_localandasync_authorize_local).
Why:
- Keeps SDK deterministic and side-effect free in libraries/services.
- Works cleanly in web backends, CLIs, notebooks, and serverless contexts.
- Avoids coupling auth provider to UI/transport concerns.
- Preserves convenience for local development through helper APIs.
Security and Operational Notes
- Strongly recommend using
stateand validating it inparse_response_url. - Require exact redirect URI matching with Spotify app settings.
- Do not log tokens or authorization codes.
- In-memory cache is default; production apps should provide secure persistent cache implementations if session continuity is needed.
Testing Plan
- URL generation:
- required params
- optional
scope,state,show_dialog
- Callback parsing:
- success path
- missing code
error=responses- state mismatch
- Token exchange:
- successful authorization code exchange
- malformed token response handling
- Refresh:
- refresh on expiry
- preserve old refresh token when omitted in refresh response
- error mapping for token endpoint failures
- Concurrency:
- concurrent expired-token requests result in one refresh call
- Environment fallback:
client_id,client_secret, andredirect_uriresolution