@@ -41,27 +41,15 @@ export interface JwtOAuthConfig {
4141 scopes ?: string [ ] ;
4242}
4343
44- /**
45- * JWT payload structure for OAuth 2.0 JWT Bearer flow.
46- */
4744interface JwtPayload {
48- /** Issuer - OAuth client ID */
4945 iss : string ;
50- /** Subject - OAuth client ID */
5146 sub : string ;
52- /** Audience - token endpoint URL */
5347 aud : string ;
54- /** Expiration time (Unix timestamp in seconds) */
5548 exp : number ;
5649}
5750
58- /**
59- * JWT header structure.
60- */
6151interface JwtHeader {
62- /** Algorithm - must be RS256 for RSA-SHA256 */
6352 alg : string ;
64- /** Type - must be JWT */
6553 typ : string ;
6654}
6755
@@ -92,11 +80,20 @@ export class JwtOAuthStrategy implements AuthStrategy {
9280 private readonly config : JwtOAuthConfig ;
9381 private readonly logger = getLogger ( ) ;
9482 private readonly cacheKey : string ;
83+ private _hasHadSuccess = false ;
84+ private readonly privateKey : crypto . KeyObject ;
9585
9686 constructor ( config : JwtOAuthConfig ) {
9787 this . validateConfig ( config ) ;
9888 this . config = config ;
9989 this . cacheKey = getOAuthCacheKey ( this . config . clientId , 'jwt' , this . config . accountManagerHost , this . config . scopes ) ;
90+
91+ // Cache private key to avoid file I/O on every token request
92+ const keyContent = fs . readFileSync ( config . keyPath , 'utf8' ) ;
93+ this . privateKey = crypto . createPrivateKey ( {
94+ key : keyContent ,
95+ passphrase : config . passphrase ,
96+ } ) ;
10097 }
10198
10299 /**
@@ -194,18 +191,33 @@ export class JwtOAuthStrategy implements AuthStrategy {
194191 /**
195192 * Performs a fetch request with JWT Bearer authentication.
196193 * Automatically injects the Authorization header with a fresh access token.
194+ * Includes 401 retry logic and x-dw-client-id header.
197195 */
198- async fetch ( url : string , init ? : FetchInit ) : Promise < Response > {
196+ async fetch ( url : string , init : FetchInit = { } ) : Promise < Response > {
199197 const token = await this . getAccessToken ( ) ;
200198
201- const headers = new Headers ( init ? .headers ) ;
199+ const headers = new Headers ( init . headers ) ;
202200 headers . set ( 'Authorization' , `Bearer ${ token } ` ) ;
201+ headers . set ( 'x-dw-client-id' , this . config . clientId ) ;
202+
203+ // Pass through dispatcher for TLS/mTLS support
204+ let res = await fetch ( url , { ...init , headers} as RequestInit ) ;
205+
206+ if ( res . status !== 401 ) {
207+ this . _hasHadSuccess = true ;
208+ }
209+
210+ // If we previously had a successful response and now get a 401,
211+ // the token likely expired. Retry once after invalidating the cached token.
212+ // Skip retry on initial 401 to avoid retrying with bad credentials.
213+ if ( res . status === 401 && this . _hasHadSuccess ) {
214+ this . invalidateToken ( ) ;
215+ const newToken = await this . getAccessToken ( ) ;
216+ headers . set ( 'Authorization' , `Bearer ${ newToken } ` ) ;
217+ res = await fetch ( url , { ...init , headers} as RequestInit ) ;
218+ }
203219
204- // Type assertion needed for undici dispatcher compatibility
205- return fetch ( url , {
206- ...init ,
207- headers,
208- } as RequestInit ) ;
220+ return res ;
209221 }
210222
211223 /**
@@ -216,6 +228,29 @@ export class JwtOAuthStrategy implements AuthStrategy {
216228 return `Bearer ${ token } ` ;
217229 }
218230
231+ /**
232+ * Gets the decoded JWT payload.
233+ */
234+ async getJWT ( ) : Promise < ReturnType < typeof decodeJWT > > {
235+ const token = await this . getAccessToken ( ) ;
236+ return decodeJWT ( token ) ;
237+ }
238+
239+ /**
240+ * Creates a new JwtOAuthStrategy with additional scopes merged in.
241+ * Used by clients that have specific scope requirements.
242+ *
243+ * @param additionalScopes - Scopes to add to this strategy's existing scopes
244+ * @returns A new JwtOAuthStrategy instance with merged scopes
245+ */
246+ withAdditionalScopes ( additionalScopes : string [ ] ) : JwtOAuthStrategy {
247+ const mergedScopes = [ ...new Set ( [ ...( this . config . scopes || [ ] ) , ...additionalScopes ] ) ] ;
248+ return new JwtOAuthStrategy ( {
249+ ...this . config ,
250+ scopes : mergedScopes ,
251+ } ) ;
252+ }
253+
219254 /**
220255 * Gets the full token response including expiration and scopes.
221256 * Useful for commands that need to display or return token metadata.
@@ -228,16 +263,8 @@ export class JwtOAuthStrategy implements AuthStrategy {
228263 return cached ;
229264 }
230265
231- // Get new token
232- await this . getAccessToken ( ) ;
233-
234- // Return from cache (getAccessToken stores it)
235- const tokenResponse = getCachedOAuthToken ( this . cacheKey , this . config . scopes || [ ] ) ;
236- if ( ! tokenResponse ) {
237- throw new Error ( 'Failed to retrieve token response from cache' ) ;
238- }
239-
240- return tokenResponse ;
266+ // Get new token (returns full response)
267+ return this . requestNewToken ( ) ;
241268 }
242269
243270 /**
@@ -249,7 +276,7 @@ export class JwtOAuthStrategy implements AuthStrategy {
249276 }
250277
251278 /**
252- * Gets an access token, using cached token if still valid.
279+ * Gets an access token string , using cached token if still valid.
253280 */
254281 private async getAccessToken ( ) : Promise < string > {
255282 // Check global cache first
@@ -259,6 +286,16 @@ export class JwtOAuthStrategy implements AuthStrategy {
259286 return cached . accessToken ;
260287 }
261288
289+ // Request new token and return just the access token string
290+ const tokenResponse = await this . requestNewToken ( ) ;
291+ return tokenResponse . accessToken ;
292+ }
293+
294+ /**
295+ * Requests a new access token from Account Manager using JWT Bearer flow.
296+ * Returns the full token response and caches it.
297+ */
298+ private async requestNewToken ( ) : Promise < AccessTokenResponse > {
262299 this . logger . trace ( '[JwtOAuthStrategy] Requesting new access token with JWT Bearer flow' ) ;
263300
264301 // Generate signed JWT
@@ -336,7 +373,7 @@ export class JwtOAuthStrategy implements AuthStrategy {
336373 const scope = decoded . payload . scope as string | string [ ] | undefined ;
337374 const scopes = Array . isArray ( scope ) ? scope : scope ?. split ( ' ' ) || this . config . scopes || [ ] ;
338375
339- // Store in global cache
376+ // Build and cache token response
340377 const tokenResponse : AccessTokenResponse = {
341378 accessToken : data . access_token ,
342379 expires : expiryDate ,
@@ -353,51 +390,33 @@ export class JwtOAuthStrategy implements AuthStrategy {
353390 '[JwtOAuthStrategy] Access token obtained successfully' ,
354391 ) ;
355392
356- return data . access_token ;
393+ return tokenResponse ;
357394 }
358395
359396 /**
360397 * Creates and signs a JWT token for OAuth authentication.
361- *
362- * JWT structure: HEADER.PAYLOAD.SIGNATURE
363- * - HEADER: {"alg":"RS256","typ":"JWT"}
364- * - PAYLOAD: {"iss":"clientId","sub":"clientId","aud":"tokenUrl","exp":timestamp}
365- * - SIGNATURE: RSA-SHA256 signature of HEADER.PAYLOAD
366- *
367- * All parts are Base64URL encoded (not standard Base64).
398+ * Uses RS256 algorithm and Base64URL encoding per RFC 7519.
368399 */
369400 private async createSignedJwt ( ) : Promise < string > {
370- // 1. Read private key from file
371- const keyContent = fs . readFileSync ( this . config . keyPath , 'utf8' ) ;
372- const privateKey = crypto . createPrivateKey ( {
373- key : keyContent ,
374- passphrase : this . config . passphrase ,
375- } ) ;
376-
377- // 2. Create JWT header (Base64URL encoded)
378401 const header : JwtHeader = {
379402 alg : 'RS256' ,
380403 typ : 'JWT' ,
381404 } ;
382405 const encodedHeader = base64UrlEncode ( JSON . stringify ( header ) ) ;
383-
384- // 3. Create JWT payload (Base64URL encoded)
385406 const now = Math . floor ( Date . now ( ) / 1000 ) ;
386407 const tokenUrl = `https://${ this . config . accountManagerHost } /dwsso/oauth2/access_token` ;
387408 const payload : JwtPayload = {
388- iss : this . config . clientId , // Issuer (client ID)
389- sub : this . config . clientId , // Subject (client ID)
390- aud : tokenUrl , // Audience (token endpoint)
391- exp : now + 60 , // Expiration (1 minute from now)
409+ iss : this . config . clientId ,
410+ sub : this . config . clientId ,
411+ aud : tokenUrl ,
412+ exp : now + 60 ,
392413 } ;
393414 const encodedPayload = base64UrlEncode ( JSON . stringify ( payload ) ) ;
394415
395- // 4. Create signature using RSA-SHA256
396416 const signatureInput = `${ encodedHeader } .${ encodedPayload } ` ;
397- const signature = crypto . sign ( 'RSA-SHA256' , Buffer . from ( signatureInput ) , privateKey ) ;
417+ const signature = crypto . sign ( 'RSA-SHA256' , Buffer . from ( signatureInput ) , this . privateKey ) ;
398418 const encodedSignature = base64UrlEncode ( signature ) ;
399419
400- // 5. Combine into final JWT
401420 const jwt = `${ encodedHeader } .${ encodedPayload } .${ encodedSignature } ` ;
402421
403422 this . logger . trace (
@@ -417,26 +436,10 @@ export class JwtOAuthStrategy implements AuthStrategy {
417436
418437/**
419438 * Encodes data as Base64URL (RFC 4648 Section 5).
420- *
421- * Base64URL is a URL-safe variant of Base64 that:
422- * - Replaces '+' with '-'
423- * - Replaces '/' with '_'
424- * - Removes padding '=' characters
425- *
426- * This encoding is required for JWTs to be safely used in URLs and HTTP headers.
427- *
428- * @param data - String or Buffer to encode
429- * @returns Base64URL encoded string
430- *
431- * @example
432- * ```typescript
433- * base64UrlEncode('{"alg":"RS256"}'); // eyJhbGciOiJSUzI1NiJ9
434- * base64UrlEncode(Buffer.from([1, 2, 3])); // AQID
435- * ```
439+ * Replaces +/= with URL-safe characters.
436440 */
437441function base64UrlEncode ( data : string | Buffer ) : string {
438442 const buffer = typeof data === 'string' ? Buffer . from ( data ) : data ;
439443 const base64 = buffer . toString ( 'base64' ) ;
440- // Replace standard Base64 characters with URL-safe variants and remove padding
441444 return base64 . replace ( / \+ / g, '-' ) . replace ( / \/ / g, '_' ) . replace ( / = / g, '' ) ;
442445}
0 commit comments