Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.).

<!-- prettier-ignore -->
```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.

<!-- prettier-ignore -->
```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:
Expand Down
26 changes: 26 additions & 0 deletions examples/auth-token.ts
Original file line number Diff line number Diff line change
@@ -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);
29 changes: 29 additions & 0 deletions examples/oauth-client-creds.ts
Original file line number Diff line number Diff line change
@@ -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);
3 changes: 3 additions & 0 deletions src/lib/auth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { TokenManager } from './token-manager';
export type { AccessTokenAuth, ClientCredentialsAuth, AuthConfig, TokenManagerOptions } from './types';
export { SpotifyClient, type SpotifyClientOptions } from '../spotify-client';
84 changes: 84 additions & 0 deletions src/lib/auth/token-manager.ts
Original file line number Diff line number Diff line change
@@ -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<string> | 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<string> {
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<string> {
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}` : ''}`,

Check failure on line 71 in src/lib/auth/token-manager.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `body·?·`·-·${body}`·:·''` with `⏎··········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;
}
}
21 changes: 21 additions & 0 deletions src/lib/auth/types.ts
Original file line number Diff line number Diff line change
@@ -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;
}
54 changes: 54 additions & 0 deletions src/lib/spotify-client.ts
Original file line number Diff line number Diff line change
@@ -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<ClientOptions, 'accessToken'> {
auth: AuthConfig | string;
}

export class SpotifyClient extends Spotify {
private tokenManager: TokenManager;
private authConfig: AuthConfig;

constructor(options: SpotifyClientOptions) {
if (!options.auth) {
throw new Error(

Check failure on line 18 in src/lib/spotify-client.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `⏎········'The·`auth`·option·is·required.·Pass·an·access·token·string·or·an·AuthConfig·object.',⏎······` with `'The·`auth`·option·is·required.·Pass·an·access·token·string·or·an·AuthConfig·object.'`
'The `auth` option is required. Pass an access token string or an AuthConfig object.',
);
}

const authConfig: AuthConfig =
typeof options.auth === 'string'

Check failure on line 24 in src/lib/spotify-client.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `⏎········?·{·type:·'access_token',·accessToken:·options.auth·}⏎·······` with `·?·{·type:·'access_token',·accessToken:·options.auth·}`
? { type: 'access_token', accessToken: options.auth }
: options.auth;

const accessToken =

Check failure on line 28 in src/lib/spotify-client.ts

View workflow job for this annotation

GitHub Actions / lint

Delete `⏎·····`
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<NullableHeaders | undefined> {
const token = await this.tokenManager.getAccessToken();
return buildHeaders([{ Authorization: `Bearer ${token}` }]);
}

override withOptions(options: Partial<SpotifyClientOptions>): this {
const auth = options.auth ?? this.authConfig;
const merged = { ...options, auth } as SpotifyClientOptions;

const client = new SpotifyClient(merged) as this;
return client;
}
}
Loading
Loading