11import { stringify } from "node:querystring" ;
2- import { decodeJwt } from "jose" ;
2+ import { type JWTPayload , decodeJwt } from "jose" ;
33
44export interface ClientCredentialsResponse {
55 token_type : string ;
@@ -8,6 +8,27 @@ export interface ClientCredentialsResponse {
88 access_token : string ;
99}
1010
11+ interface Options {
12+ /**
13+ *
14+ * Whether to log debug messages & tokens during the token retrieval process.
15+ * Useful for troubleshooting and understanding the flow of operations.
16+ *
17+ * Use with Caution, this could leak secrets!
18+ */
19+ debugMode ?: boolean ;
20+ /**
21+ * Whether to skip any token caching and force fresh retrievals
22+ *
23+ */
24+ forceFreshAuth ?: boolean ;
25+ /**
26+ * Treat token as expired if it has less than this many seconds remaining.
27+ * Helps avoid clock skew / in-flight expiry. (Default 30 secs)
28+ */
29+ expirySkewSeconds ?: number ;
30+ }
31+
1132export class ClientCredentials {
1233 private static accessToken : string ;
1334 private static readonly grantType = "client_credentials" ;
@@ -18,15 +39,20 @@ export class ClientCredentials {
1839 * @param clientId - The client id
1940 * @param clientSecret - The client secret
2041 * @param scope - The scope of the access token
21- * @param debugMode - Whether to log debug messages
42+ * @param resource
43+ * @param options
2244 */
2345 constructor (
2446 private readonly tokenUrl : string ,
2547 private readonly clientId : string ,
2648 private readonly clientSecret : string ,
2749 private readonly scope : string | undefined ,
2850 private readonly resource : string | undefined ,
29- private readonly debugMode : boolean = false ,
51+ private readonly options : Options = {
52+ debugMode : false ,
53+ forceFreshAuth : false ,
54+ expirySkewSeconds : 30 ,
55+ } ,
3056 ) { }
3157
3258 /**
@@ -35,14 +61,20 @@ export class ClientCredentials {
3561 * @returns {Promise<string> } - The access token
3662 */
3763 public async getAccessToken ( ) : Promise < string > {
38- if ( ! ClientCredentials . accessToken || this . isAccessTokenExpired ( ) ) {
64+ if (
65+ this . options ?. forceFreshAuth ||
66+ ! ClientCredentials . accessToken ||
67+ ClientCredentials . isAccessTokenExpired (
68+ this . options . expirySkewSeconds ?? 30 ,
69+ )
70+ ) {
3971 const { access_token } = await this . fetchClientCredentials ( ) ;
4072
41- if ( this . debugMode )
73+ if ( this . options ?. debugMode )
4274 console . log ( "[DEBUG] New access token fetched:" , access_token ) ;
4375
4476 ClientCredentials . accessToken = access_token ;
45- } else if ( this . debugMode ) {
77+ } else if ( this . options ?. debugMode ) {
4678 console . log (
4779 "[DEBUG] Using existing access token:" ,
4880 ClientCredentials . accessToken ,
@@ -94,20 +126,22 @@ export class ClientCredentials {
94126 * @returns {boolean } - Whether the access token is expired
95127 * @private
96128 */
97- private isAccessTokenExpired ( ) : boolean {
98- try {
99- const decodedAccessToken = decodeJwt ( ClientCredentials . accessToken ) ;
129+ private static isAccessTokenExpired ( skewSeconds : number ) : boolean {
130+ let decodedAccessToken : JWTPayload ;
100131
101- const currentTime = new Date ( ) . getTime ( ) / 1000 ;
102-
103- // Check if exp exists before comparing as it can be undefined
104- if ( ! decodedAccessToken ?. exp ) {
105- return true ;
106- }
107- return currentTime > decodedAccessToken . exp ;
132+ try {
133+ decodedAccessToken = decodeJwt ( ClientCredentials . accessToken ) ;
108134 } catch ( err ) {
109135 console . error ( "Error decoding access token:" , err ) ;
110136 return true ;
111137 }
138+
139+ const currentTime = Math . floor ( Date . now ( ) / 1000 ) ;
140+
141+ const exp = decodedAccessToken ?. exp ;
142+ if ( ! exp ) return true ;
143+
144+ // treat as expired if we're within the skew window
145+ return currentTime >= exp - skewSeconds ;
112146 }
113147}
0 commit comments