@@ -108,6 +108,7 @@ import { UserRetrieveProfileResponse, Users } from './resources/users/users';
108108import { type Fetch } from './internal/builtin-types' ;
109109import { HeadersLike , NullableHeaders , buildHeaders } from './internal/headers' ;
110110import { FinalRequestOptions , RequestOptions } from './internal/request-options' ;
111+ import { toBase64 } from './internal/utils/base64' ;
111112import { readEnv } from './internal/utils/env' ;
112113import {
113114 type LogLevel ,
@@ -120,9 +121,14 @@ import { isEmptyObj } from './internal/utils/values';
120121
121122export interface ClientOptions {
122123 /**
123- * Defaults to process.env['SPOTIFY_ACCESS_TOKEN '].
124+ * Defaults to process.env['SPOTIFY_CLIENT_ID '].
124125 */
125- accessToken ?: string | undefined ;
126+ clientID ?: string | null | undefined ;
127+
128+ /**
129+ * Defaults to process.env['SPOTIFY_CLIENT_SECRET'].
130+ */
131+ clientSecret ?: string | null | undefined ;
126132
127133 /**
128134 * Override the default base URL for the API, e.g., "https://api.example.com/v2/"
@@ -197,7 +203,8 @@ export interface ClientOptions {
197203 * API Client for interfacing with the Spotify API.
198204 */
199205export class Spotify {
200- accessToken : string ;
206+ clientID : string | null ;
207+ clientSecret : string | null ;
201208
202209 baseURL : string ;
203210 maxRetries : number ;
@@ -214,7 +221,8 @@ export class Spotify {
214221 /**
215222 * API Client for interfacing with the Spotify API.
216223 *
217- * @param {string | undefined } [opts.accessToken=process.env['SPOTIFY_ACCESS_TOKEN'] ?? undefined]
224+ * @param {string | null | undefined } [opts.clientID=process.env['SPOTIFY_CLIENT_ID'] ?? null]
225+ * @param {string | null | undefined } [opts.clientSecret=process.env['SPOTIFY_CLIENT_SECRET'] ?? null]
218226 * @param {string } [opts.baseURL=process.env['SPOTIFY_BASE_URL'] ?? https://api.spotify.com/v1] - Override the default base URL for the API.
219227 * @param {number } [opts.timeout=1 minute] - The maximum amount of time (in milliseconds) the client will wait for a response before timing out.
220228 * @param {MergedRequestInit } [opts.fetchOptions] - Additional `RequestInit` options to be passed to `fetch` calls.
@@ -225,17 +233,13 @@ export class Spotify {
225233 */
226234 constructor ( {
227235 baseURL = readEnv ( 'SPOTIFY_BASE_URL' ) ,
228- accessToken = readEnv ( 'SPOTIFY_ACCESS_TOKEN' ) ,
236+ clientID = readEnv ( 'SPOTIFY_CLIENT_ID' ) ?? null ,
237+ clientSecret = readEnv ( 'SPOTIFY_CLIENT_SECRET' ) ?? null ,
229238 ...opts
230239 } : ClientOptions = { } ) {
231- if ( accessToken === undefined ) {
232- throw new Errors . SpotifyError (
233- "The SPOTIFY_ACCESS_TOKEN environment variable is missing or empty; either provide it, or instantiate the Spotify client with an accessToken option, like new Spotify({ accessToken: 'My Access Token' })." ,
234- ) ;
235- }
236-
237240 const options : ClientOptions = {
238- accessToken,
241+ clientID,
242+ clientSecret,
239243 ...opts ,
240244 baseURL : baseURL || `https://api.spotify.com/v1` ,
241245 } ;
@@ -257,7 +261,8 @@ export class Spotify {
257261
258262 this . _options = options ;
259263
260- this . accessToken = accessToken ;
264+ this . clientID = clientID ;
265+ this . clientSecret = clientSecret ;
261266 }
262267
263268 /**
@@ -273,9 +278,11 @@ export class Spotify {
273278 logLevel : this . logLevel ,
274279 fetch : this . fetch ,
275280 fetchOptions : this . fetchOptions ,
276- accessToken : this . accessToken ,
281+ clientID : this . clientID ,
282+ clientSecret : this . clientSecret ,
277283 ...options ,
278284 } ) ;
285+ client . oauth2_0AuthState = this . oauth2_0AuthState ;
279286 return client ;
280287 }
281288
@@ -294,8 +301,72 @@ export class Spotify {
294301 return ;
295302 }
296303
304+ private oauth2_0AuthState :
305+ | {
306+ promise : Promise < {
307+ access_token : string ;
308+ token_type : string ;
309+ expires_in : number ;
310+ expires_at : Date ;
311+ refresh_token ?: string ;
312+ } > ;
313+ clientID : string ;
314+ clientSecret : string ;
315+ }
316+ | undefined ;
297317 protected async authHeaders ( opts : FinalRequestOptions ) : Promise < NullableHeaders | undefined > {
298- return buildHeaders ( [ { Authorization : `Bearer ${ this . accessToken } ` } ] ) ;
318+ if ( ! this . clientID || ! this . clientSecret ) {
319+ return undefined ;
320+ }
321+
322+ // Invalidate the cache if the token is expired
323+ if ( this . oauth2_0AuthState && + ( await this . oauth2_0AuthState . promise ) . expires_at < Date . now ( ) ) {
324+ this . oauth2_0AuthState = undefined ;
325+ }
326+
327+ // Invalidate the cache if the relevant state has been changed
328+ if (
329+ this . oauth2_0AuthState &&
330+ this . oauth2_0AuthState . clientID !== this . clientID &&
331+ this . oauth2_0AuthState . clientSecret !== this . clientSecret
332+ ) {
333+ this . oauth2_0AuthState = undefined ;
334+ }
335+
336+ if ( ! this . oauth2_0AuthState ) {
337+ this . oauth2_0AuthState = {
338+ promise : this . fetch ( this . buildURL ( 'https://accounts.spotify.com/api/token' , { } ) , {
339+ method : 'POST' ,
340+ headers : {
341+ 'Content-Type' : 'application/x-www-form-urlencoded' ,
342+ Authorization : `Basic ${ toBase64 ( `${ this . clientID } :${ this . clientSecret } ` ) } ` ,
343+ } ,
344+ body : 'grant_type=client_credentials' ,
345+ } ) . then ( async ( res ) => {
346+ if ( ! res . ok ) {
347+ const errText = await res . text ( ) . catch ( ( ) => '' ) ;
348+ const errJSON = errText ? safeJSON ( errText ) : undefined ;
349+ const errMessage = errJSON ? undefined : errText ;
350+ throw this . makeStatusError ( res . status , errJSON , errMessage , res . headers ) ;
351+ }
352+ const json = ( await res . json ( ) ) as {
353+ access_token : string ;
354+ token_type : string ;
355+ expires_in : number ;
356+ refresh_token ?: string ;
357+ } ;
358+ const now = new Date ( ) ;
359+ now . setSeconds ( now . getSeconds ( ) + json . expires_in ) ;
360+ return { ...json , expires_at : now } ;
361+ } ) ,
362+ clientID : this . clientID ,
363+ clientSecret : this . clientSecret ,
364+ } ;
365+ }
366+
367+ const token = await this . oauth2_0AuthState . promise ;
368+
369+ return buildHeaders ( [ { Authorization : `Bearer ${ token . access_token } ` } ] ) ;
299370 }
300371
301372 protected stringifyQuery ( query : Record < string , unknown > ) : string {
@@ -622,6 +693,13 @@ export class Spotify {
622693 if ( shouldRetryHeader === 'true' ) return true ;
623694 if ( shouldRetryHeader === 'false' ) return false ;
624695
696+ // Retry if the token has expired
697+ const oauth2_0Auth = await this . oauth2_0AuthState ?. promise ;
698+ if ( response . status === 401 && oauth2_0Auth && + oauth2_0Auth . expires_at - Date . now ( ) < 10 * 1000 ) {
699+ this . oauth2_0AuthState = undefined ;
700+ return true ;
701+ }
702+
625703 // Retry on request timeouts.
626704 if ( response . status === 408 ) return true ;
627705
0 commit comments