diff --git a/README.md b/README.md index 4ecc698..528b86b 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,51 @@ const album = await client.albums.retrieve('4aawyAB9vmqN3uQ7FjRGTy'); console.log(album.id); ``` +## Authentication + +The SDK supports multiple authentication modes via the `SpotifyClient` class. Choose the mode that fits your use case. + +### Client Credentials (server-to-server) + +Use this when your app needs to access Spotify catalog data without a user context. Works for browsing albums, artists, playlists, and search. Cannot access user-specific endpoints (`/me`, saved tracks, user playlists, etc.). + + +```ts +import { SpotifyClient } from '@stainless-commons/spotify/lib/auth'; + +const client = new SpotifyClient({ + auth: { + type: 'client_credentials', + clientId: process.env['SPOTIFY_CLIENT_ID']!, + clientSecret: process.env['SPOTIFY_CLIENT_SECRET']!, + }, +}); + +const featured = await client.browse.getFeaturedPlaylists(); +console.log(featured.playlists.items.map((p) => p.name)); +``` + +The client automatically fetches and caches an access token using the [Client Credentials flow](https://developer.spotify.com/documentation/web-api/tutorials/client-credentials-flow), refreshing it before expiry. + +### Access Token (user-authorized) + +Use this when a user has authorized your app via OAuth and you have an access token. Required for user-specific endpoints like `/me`, saved tracks, and user playlists. + + +```ts +import { SpotifyClient } from '@stainless-commons/spotify/lib/auth'; + +const client = new SpotifyClient({ + auth: process.env['SPOTIFY_ACCESS_TOKEN']!, +}); + +const me = await client.me.retrieve(); +console.log(me.display_name); +``` + +> [!NOTE] +> The base `Spotify` client still works for simple access token usage. `SpotifyClient` adds support for additional auth modes and automatic token management. + ### Request & Response types This library includes TypeScript definitions for all request params and response fields. You may import and use them like so: diff --git a/examples/auth-token.ts b/examples/auth-token.ts new file mode 100644 index 0000000..7941692 --- /dev/null +++ b/examples/auth-token.ts @@ -0,0 +1,26 @@ +import { SpotifyClient } from '@stainless-commons/spotify/lib/auth'; + +/** + * Access Token flow: user-level auth with a pre-obtained token. + * Required for user-specific endpoints (/me, saved tracks, user playlists). + * + * Required env vars: + * SPOTIFY_ACCESS_TOKEN (user-scoped OAuth token) + */ +const client = new SpotifyClient({ + auth: process.env['SPOTIFY_ACCESS_TOKEN']!, +}); + +async function main() { + const me = await client.me.retrieve(); + + console.log('Current user profile:'); + console.log(` Display name: ${me.display_name}`); + console.log(` ID: ${me.id}`); + console.log(` Email: ${me.email}`); + console.log(` Country: ${me.country}`); + console.log(` Product: ${me.product}`); + console.log(` Followers: ${me.followers?.total}`); +} + +main().catch(console.error); diff --git a/examples/oauth-client-creds.ts b/examples/oauth-client-creds.ts new file mode 100644 index 0000000..8d29854 --- /dev/null +++ b/examples/oauth-client-creds.ts @@ -0,0 +1,29 @@ +import { SpotifyClient } from '@stainless-commons/spotify/lib/auth'; + +/** + * Client Credentials flow: app-level auth, no user context. + * Good for browsing catalog data (albums, artists, search). + * + * Required env vars: + * SPOTIFY_CLIENT_ID + * SPOTIFY_CLIENT_SECRET + */ +const client = new SpotifyClient({ + auth: { + type: 'client_credentials', + clientId: process.env['SPOTIFY_CLIENT_ID']!, + clientSecret: process.env['SPOTIFY_CLIENT_SECRET']!, + }, +}); + +async function main() { + const response = await client.browse.getNewReleases(); + console.log('New album releases:\n'); + + for (const album of response.albums?.items ?? []) { + const artists = album.artists?.map((a) => a.name).join(', ') ?? 'Unknown'; + console.log(` - ${album.name} by ${artists}`); + } +} + +main().catch(console.error); diff --git a/src/lib/auth/index.ts b/src/lib/auth/index.ts new file mode 100644 index 0000000..b047d67 --- /dev/null +++ b/src/lib/auth/index.ts @@ -0,0 +1,3 @@ +export { TokenManager } from './token-manager'; +export type { AccessTokenAuth, ClientCredentialsAuth, AuthConfig, TokenManagerOptions } from './types'; +export { SpotifyClient, type SpotifyClientOptions } from '../spotify-client'; diff --git a/src/lib/auth/token-manager.ts b/src/lib/auth/token-manager.ts new file mode 100644 index 0000000..e18d0d1 --- /dev/null +++ b/src/lib/auth/token-manager.ts @@ -0,0 +1,84 @@ +import type { Fetch } from '../../internal/builtin-types'; +import { toBase64 } from '../../internal/utils/base64'; +import type { AuthConfig, TokenManagerOptions } from './types'; + +const DEFAULT_TOKEN_ENDPOINT = 'https://accounts.spotify.com/api/token'; +const DEFAULT_EXPIRY_BUFFER_SECONDS = 300; + +interface CachedToken { + accessToken: string; + expiresAt: number; +} + +export class TokenManager { + private auth: AuthConfig; + private fetch: Fetch; + private tokenEndpoint: string; + private expiryBufferSeconds: number; + private cachedToken: CachedToken | null = null; + private refreshPromise: Promise | null = null; + + constructor(options: TokenManagerOptions) { + this.auth = options.auth; + this.fetch = options.fetch ?? globalThis.fetch; + this.tokenEndpoint = options.tokenEndpoint ?? DEFAULT_TOKEN_ENDPOINT; + this.expiryBufferSeconds = options.expiryBufferSeconds ?? DEFAULT_EXPIRY_BUFFER_SECONDS; + } + + async getAccessToken(): Promise { + if (this.auth.type === 'access_token') { + return this.auth.accessToken; + } + + if (this.cachedToken && Date.now() < this.cachedToken.expiresAt) { + return this.cachedToken.accessToken; + } + + if (this.refreshPromise) { + return this.refreshPromise; + } + + this.refreshPromise = this.fetchClientCredentialsToken(); + + try { + return await this.refreshPromise; + } finally { + this.refreshPromise = null; + } + } + + clearCache(): void { + this.cachedToken = null; + this.refreshPromise = null; + } + + private async fetchClientCredentialsToken(): Promise { + const { clientId, clientSecret } = this.auth as { clientId: string; clientSecret: string }; + const credentials = toBase64(`${clientId}:${clientSecret}`); + + const response = await this.fetch.call(undefined, this.tokenEndpoint, { + method: 'POST', + headers: { + Authorization: `Basic ${credentials}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: 'grant_type=client_credentials', + }); + + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new Error( + `Failed to fetch client credentials token: ${response.status} ${response.statusText}${body ? ` - ${body}` : ''}`, + ); + } + + const data = (await response.json()) as { access_token: string; expires_in: number; token_type: string }; + + this.cachedToken = { + accessToken: data.access_token, + expiresAt: Date.now() + (data.expires_in - this.expiryBufferSeconds) * 1000, + }; + + return data.access_token; + } +} diff --git a/src/lib/auth/types.ts b/src/lib/auth/types.ts new file mode 100644 index 0000000..5f214d9 --- /dev/null +++ b/src/lib/auth/types.ts @@ -0,0 +1,21 @@ +import type { Fetch } from '../../internal/builtin-types'; + +export interface AccessTokenAuth { + type: 'access_token'; + accessToken: string; +} + +export interface ClientCredentialsAuth { + type: 'client_credentials'; + clientId: string; + clientSecret: string; +} + +export type AuthConfig = AccessTokenAuth | ClientCredentialsAuth; + +export interface TokenManagerOptions { + auth: AuthConfig; + fetch?: Fetch | undefined; + tokenEndpoint?: string | undefined; + expiryBufferSeconds?: number | undefined; +} diff --git a/src/lib/spotify-client.ts b/src/lib/spotify-client.ts new file mode 100644 index 0000000..7a26889 --- /dev/null +++ b/src/lib/spotify-client.ts @@ -0,0 +1,54 @@ +import { Spotify, type ClientOptions } from '../client'; +import type { NullableHeaders } from '../internal/headers'; +import { buildHeaders } from '../internal/headers'; +import type { FinalRequestOptions } from '../internal/request-options'; +import { TokenManager } from './auth/token-manager'; +import type { AuthConfig } from './auth/types'; + +export interface SpotifyClientOptions extends Omit { + auth: AuthConfig | string; +} + +export class SpotifyClient extends Spotify { + private tokenManager: TokenManager; + private authConfig: AuthConfig; + + constructor(options: SpotifyClientOptions) { + if (!options.auth) { + throw new Error( + 'The `auth` option is required. Pass an access token string or an AuthConfig object.', + ); + } + + const authConfig: AuthConfig = + typeof options.auth === 'string' + ? { type: 'access_token', accessToken: options.auth } + : options.auth; + + const accessToken = + authConfig.type === 'access_token' ? authConfig.accessToken : '__deferred__'; + + super({ ...options, accessToken }); + + this.authConfig = authConfig; + this.tokenManager = new TokenManager({ + auth: authConfig, + fetch: options.fetch, + tokenEndpoint: (options as any).tokenEndpoint, + expiryBufferSeconds: (options as any).expiryBufferSeconds, + }); + } + + protected override async authHeaders(opts: FinalRequestOptions): Promise { + const token = await this.tokenManager.getAccessToken(); + return buildHeaders([{ Authorization: `Bearer ${token}` }]); + } + + override withOptions(options: Partial): this { + const auth = options.auth ?? this.authConfig; + const merged = { ...options, auth } as SpotifyClientOptions; + + const client = new SpotifyClient(merged) as this; + return client; + } +} diff --git a/tests/lib/auth/token-manager.test.ts b/tests/lib/auth/token-manager.test.ts new file mode 100644 index 0000000..d3ba494 --- /dev/null +++ b/tests/lib/auth/token-manager.test.ts @@ -0,0 +1,234 @@ +import { TokenManager } from '@stainless-commons/spotify/lib/auth/token-manager'; +import type { AuthConfig } from '@stainless-commons/spotify/lib/auth/types'; + +function mockFetch(handler: (url: string, init: RequestInit) => Promise) { + return handler as unknown as typeof fetch; +} + +function tokenResponse(accessToken: string, expiresIn: number) { + return new Response( + JSON.stringify({ access_token: accessToken, expires_in: expiresIn, token_type: 'bearer' }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); +} + +describe('TokenManager', () => { + describe('access_token auth', () => { + test('returns static token immediately with no HTTP calls', async () => { + const fetchFn = jest.fn(); + const manager = new TokenManager({ + auth: { type: 'access_token', accessToken: 'my-static-token' }, + fetch: fetchFn as any, + }); + + const token = await manager.getAccessToken(); + expect(token).toBe('my-static-token'); + expect(fetchFn).not.toHaveBeenCalled(); + }); + }); + + describe('client_credentials auth', () => { + const auth: AuthConfig = { + type: 'client_credentials', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + }; + + test('fetches token with correct request format', async () => { + let capturedUrl: string | undefined; + let capturedInit: RequestInit | undefined; + + const fetchFn = mockFetch(async (url, init) => { + capturedUrl = url; + capturedInit = init; + return tokenResponse('new-token', 3600); + }); + + const manager = new TokenManager({ auth, fetch: fetchFn }); + const token = await manager.getAccessToken(); + + expect(token).toBe('new-token'); + expect(capturedUrl).toBe('https://accounts.spotify.com/api/token'); + expect(capturedInit?.method).toBe('POST'); + + const headers = capturedInit?.headers as Record; + expect(headers['Content-Type']).toBe('application/x-www-form-urlencoded'); + + const expectedCredentials = Buffer.from('test-client-id:test-client-secret').toString('base64'); + expect(headers['Authorization']).toBe(`Basic ${expectedCredentials}`); + expect(capturedInit?.body).toBe('grant_type=client_credentials'); + }); + + test('caches token within TTL', async () => { + let fetchCount = 0; + const fetchFn = mockFetch(async () => { + fetchCount++; + return tokenResponse('cached-token', 3600); + }); + + const manager = new TokenManager({ auth, fetch: fetchFn }); + + const token1 = await manager.getAccessToken(); + const token2 = await manager.getAccessToken(); + + expect(token1).toBe('cached-token'); + expect(token2).toBe('cached-token'); + expect(fetchCount).toBe(1); + }); + + test('refreshes token after expiry', async () => { + let fetchCount = 0; + const fetchFn = mockFetch(async () => { + fetchCount++; + return tokenResponse(`token-${fetchCount}`, 3600); + }); + + const manager = new TokenManager({ auth, fetch: fetchFn, expiryBufferSeconds: 0 }); + const now = Date.now; + + try { + let currentTime = 1000000; + Date.now = () => currentTime; + + const token1 = await manager.getAccessToken(); + expect(token1).toBe('token-1'); + + // Advance past expiry (3600s) + currentTime += 3601 * 1000; + + const token2 = await manager.getAccessToken(); + expect(token2).toBe('token-2'); + expect(fetchCount).toBe(2); + } finally { + Date.now = now; + } + }); + + test('applies expiry buffer', async () => { + let fetchCount = 0; + const fetchFn = mockFetch(async () => { + fetchCount++; + return tokenResponse(`token-${fetchCount}`, 3600); + }); + + const manager = new TokenManager({ auth, fetch: fetchFn, expiryBufferSeconds: 300 }); + const now = Date.now; + + try { + let currentTime = 1000000; + Date.now = () => currentTime; + + await manager.getAccessToken(); + expect(fetchCount).toBe(1); + + // Advance to within buffer zone (3600 - 300 = 3300s effective TTL) + currentTime += 3301 * 1000; + + await manager.getAccessToken(); + expect(fetchCount).toBe(2); + } finally { + Date.now = now; + } + }); + + test('deduplicates concurrent requests', async () => { + let fetchCount = 0; + const fetchFn = mockFetch(async () => { + fetchCount++; + // Simulate network delay + await new Promise((r) => setTimeout(r, 10)); + return tokenResponse('deduped-token', 3600); + }); + + const manager = new TokenManager({ auth, fetch: fetchFn }); + + const results = await Promise.all([ + manager.getAccessToken(), + manager.getAccessToken(), + manager.getAccessToken(), + manager.getAccessToken(), + manager.getAccessToken(), + ]); + + expect(results).toEqual(Array(5).fill('deduped-token')); + expect(fetchCount).toBe(1); + }); + + test('throws on HTTP error without caching failure', async () => { + let fetchCount = 0; + const fetchFn = mockFetch(async () => { + fetchCount++; + if (fetchCount === 1) { + return new Response('Unauthorized', { status: 401, statusText: 'Unauthorized' }); + } + return tokenResponse('recovered-token', 3600); + }); + + const manager = new TokenManager({ auth, fetch: fetchFn }); + + await expect(manager.getAccessToken()).rejects.toThrow( + 'Failed to fetch client credentials token: 401 Unauthorized - Unauthorized', + ); + + // Should retry on next call since failure wasn't cached + const token = await manager.getAccessToken(); + expect(token).toBe('recovered-token'); + expect(fetchCount).toBe(2); + }); + + test('propagates network errors and allows retry', async () => { + let fetchCount = 0; + const fetchFn = mockFetch(async () => { + fetchCount++; + if (fetchCount === 1) { + throw new Error('Network failure'); + } + return tokenResponse('recovered-token', 3600); + }); + + const manager = new TokenManager({ auth, fetch: fetchFn }); + + await expect(manager.getAccessToken()).rejects.toThrow('Network failure'); + + const token = await manager.getAccessToken(); + expect(token).toBe('recovered-token'); + expect(fetchCount).toBe(2); + }); + + test('clearCache forces re-fetch', async () => { + let fetchCount = 0; + const fetchFn = mockFetch(async () => { + fetchCount++; + return tokenResponse(`token-${fetchCount}`, 3600); + }); + + const manager = new TokenManager({ auth, fetch: fetchFn }); + + const token1 = await manager.getAccessToken(); + expect(token1).toBe('token-1'); + + manager.clearCache(); + + const token2 = await manager.getAccessToken(); + expect(token2).toBe('token-2'); + expect(fetchCount).toBe(2); + }); + + test('uses custom token endpoint', async () => { + let capturedUrl: string | undefined; + const fetchFn = mockFetch(async (url) => { + capturedUrl = url; + return tokenResponse('token', 3600); + }); + + const manager = new TokenManager({ + auth, + fetch: fetchFn, + tokenEndpoint: 'https://custom.example.com/token', + }); + + await manager.getAccessToken(); + expect(capturedUrl).toBe('https://custom.example.com/token'); + }); + }); +}); diff --git a/tests/lib/spotify-client.test.ts b/tests/lib/spotify-client.test.ts new file mode 100644 index 0000000..8f0aaa5 --- /dev/null +++ b/tests/lib/spotify-client.test.ts @@ -0,0 +1,231 @@ +import { SpotifyClient } from '@stainless-commons/spotify/lib/spotify-client'; + +function mockFetch(handler: (url: string | URL | Request, init?: RequestInit) => Promise) { + return handler as unknown as typeof fetch; +} + +function jsonResponse(data: object = {}) { + return new Response(JSON.stringify(data), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function tokenResponse(accessToken: string = 'fetched-token', expiresIn: number = 3600) { + return new Response( + JSON.stringify({ access_token: accessToken, expires_in: expiresIn, token_type: 'bearer' }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); +} + +describe('SpotifyClient', () => { + describe('access token auth', () => { + test('string shorthand sets Bearer header', async () => { + const client = new SpotifyClient({ + auth: 'my-token', + baseURL: 'http://localhost:5000/', + }); + + const { req } = await client.buildRequest({ path: '/foo', method: 'get' }); + expect(req.headers.get('authorization')).toBe('Bearer my-token'); + }); + + test('object form sets Bearer header', async () => { + const client = new SpotifyClient({ + auth: { type: 'access_token', accessToken: 'my-token' }, + baseURL: 'http://localhost:5000/', + }); + + const { req } = await client.buildRequest({ path: '/foo', method: 'get' }); + expect(req.headers.get('authorization')).toBe('Bearer my-token'); + }); + }); + + describe('client credentials auth', () => { + test('fetches token and uses it for API requests', async () => { + const requests: { url: string; init?: RequestInit }[] = []; + + const fetchFn = mockFetch(async (url, init) => { + const urlStr = typeof url === 'string' ? url : url.toString(); + requests.push({ url: urlStr, ...(init !== undefined ? { init } : {}) }); + + if (urlStr.includes('accounts.spotify.com')) { + return tokenResponse('cc-token'); + } + return jsonResponse({ data: 'test' }); + }); + + const client = new SpotifyClient({ + auth: { + type: 'client_credentials', + clientId: 'my-id', + clientSecret: 'my-secret', + }, + baseURL: 'http://localhost:5000/', + fetch: fetchFn, + }); + + const response = await client.get('/albums/123'); + + expect(response).toEqual({ data: 'test' }); + expect(requests).toHaveLength(2); + // First request is the token fetch + expect(requests[0]!.url).toBe('https://accounts.spotify.com/api/token'); + // Second request is the API call with the fetched token + const apiHeaders = requests[1]!.init?.headers; + const authHeader = + apiHeaders instanceof Headers + ? apiHeaders.get('authorization') + : (apiHeaders as Record)?.['authorization']; + expect(authHeader).toBe('Bearer cc-token'); + }); + + test('token refresh happens transparently', async () => { + let tokenFetchCount = 0; + + const fetchFn = mockFetch(async (url) => { + const urlStr = typeof url === 'string' ? url : url.toString(); + if (urlStr.includes('accounts.spotify.com')) { + tokenFetchCount++; + return tokenResponse(`token-${tokenFetchCount}`, 3600); + } + return jsonResponse(); + }); + + const client = new SpotifyClient({ + auth: { + type: 'client_credentials', + clientId: 'my-id', + clientSecret: 'my-secret', + }, + baseURL: 'http://localhost:5000/', + fetch: fetchFn, + expiryBufferSeconds: 0, + } as any); + + const now = Date.now; + try { + let currentTime = 1000000; + Date.now = () => currentTime; + + await client.get('/test1'); + expect(tokenFetchCount).toBe(1); + + // Advance past token expiry + currentTime += 3601 * 1000; + + await client.get('/test2'); + expect(tokenFetchCount).toBe(2); + } finally { + Date.now = now; + } + }); + }); + + describe('resource accessors', () => { + test('all resource accessors are available', () => { + const client = new SpotifyClient({ + auth: 'my-token', + baseURL: 'http://localhost:5000/', + }); + + expect(client.albums).toBeDefined(); + expect(client.artists).toBeDefined(); + expect(client.shows).toBeDefined(); + expect(client.episodes).toBeDefined(); + expect(client.audiobooks).toBeDefined(); + expect(client.me).toBeDefined(); + expect(client.chapters).toBeDefined(); + expect(client.tracks).toBeDefined(); + expect(client.search).toBeDefined(); + expect(client.playlists).toBeDefined(); + expect(client.users).toBeDefined(); + expect(client.browse).toBeDefined(); + expect(client.audioFeatures).toBeDefined(); + expect(client.audioAnalysis).toBeDefined(); + expect(client.recommendations).toBeDefined(); + expect(client.markets).toBeDefined(); + }); + }); + + describe('error propagation', () => { + test('propagates token fetch errors', async () => { + const fetchFn = mockFetch(async (url) => { + const urlStr = typeof url === 'string' ? url : url.toString(); + if (urlStr.includes('accounts.spotify.com')) { + return new Response('Bad credentials', { status: 401, statusText: 'Unauthorized' }); + } + return jsonResponse(); + }); + + const client = new SpotifyClient({ + auth: { + type: 'client_credentials', + clientId: 'bad-id', + clientSecret: 'bad-secret', + }, + baseURL: 'http://localhost:5000/', + fetch: fetchFn, + }); + + await expect(client.get('/foo')).rejects.toThrow('Failed to fetch client credentials token'); + }); + }); + + describe('withOptions', () => { + test('returns SpotifyClient preserving auth config', () => { + const client = new SpotifyClient({ + auth: 'my-token', + baseURL: 'http://localhost:5000/', + }); + + const newClient = client.withOptions({ + baseURL: 'http://localhost:6000/', + }); + + expect(newClient).toBeInstanceOf(SpotifyClient); + expect(newClient.baseURL).toBe('http://localhost:6000/'); + }); + + test('can override auth', async () => { + const client = new SpotifyClient({ + auth: 'old-token', + baseURL: 'http://localhost:5000/', + }); + + const newClient = client.withOptions({ + auth: 'new-token', + }); + + const { req } = await newClient.buildRequest({ path: '/foo', method: 'get' }); + expect(req.headers.get('authorization')).toBe('Bearer new-token'); + }); + }); + + describe('custom fetch', () => { + test('custom fetch is used for both token and API requests', async () => { + let fetchCallCount = 0; + const fetchFn = mockFetch(async (url) => { + fetchCallCount++; + const urlStr = typeof url === 'string' ? url : url.toString(); + if (urlStr.includes('accounts.spotify.com')) { + return tokenResponse(); + } + return jsonResponse({ custom: true }); + }); + + const client = new SpotifyClient({ + auth: { + type: 'client_credentials', + clientId: 'id', + clientSecret: 'secret', + }, + baseURL: 'http://localhost:5000/', + fetch: fetchFn, + }); + + await client.get('/test'); + expect(fetchCallCount).toBe(2); + }); + }); +});