Skip to content

Commit b3e4ecf

Browse files
committed
Addressed review comments - claude assisted
1 parent d16e906 commit b3e4ecf

4 files changed

Lines changed: 96 additions & 87 deletions

File tree

packages/b2c-tooling-sdk/src/auth/oauth-jwt.ts

Lines changed: 76 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -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-
*/
4744
interface 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-
*/
6151
interface 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
*/
437441
function 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
}

packages/b2c-tooling-sdk/src/cli/am-command.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,11 @@ export abstract class AmCommand<T extends typeof Command> extends OAuthCommand<T
6565
/**
6666
* Gets the auth method type that was used, based on the stored strategy.
6767
*/
68-
protected get authMethodUsed(): 'implicit' | 'client-credentials' | 'stateful' | undefined {
68+
protected get authMethodUsed(): 'implicit' | 'client-credentials' | 'jwt' | 'stateful' | undefined {
6969
if (!this._authStrategy) return undefined;
7070
if (this._authStrategy instanceof ImplicitOAuthStrategy) return 'implicit';
7171
if (this._authStrategy instanceof StatefulOAuthStrategy) return 'stateful';
72+
if (this._authStrategy instanceof JwtOAuthStrategy) return 'jwt';
7273
return 'client-credentials';
7374
}
7475

packages/b2c-tooling-sdk/src/cli/oauth-command.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -104,13 +104,11 @@ export abstract class OAuthCommand<T extends typeof Command> extends BaseCommand
104104
'jwt-cert': Flags.string({
105105
description: 'Path to JWT certificate file (cert.pem) for JWT Bearer authentication',
106106
env: 'SFCC_JWT_CERT',
107-
default: async () => process.env.SFCC_JWT_CERT_PATH || undefined,
108107
helpGroup: 'AUTH',
109108
}),
110109
'jwt-key': Flags.string({
111110
description: 'Path to JWT private key file (key.pem) for JWT Bearer authentication',
112111
env: 'SFCC_JWT_KEY',
113-
default: async () => process.env.SFCC_JWT_KEY_PATH || undefined,
114112
helpGroup: 'AUTH',
115113
}),
116114
'jwt-passphrase': Flags.string({
@@ -228,15 +226,24 @@ export abstract class OAuthCommand<T extends typeof Command> extends BaseCommand
228226
case 'jwt':
229227
// JWT Bearer authentication - requires client ID and cert/key pair
230228
if (config.clientId && config.jwtCertPath && config.jwtKeyPath) {
231-
this.logger.debug('[Auth] Using JWT Bearer authentication');
232-
return new JwtOAuthStrategy({
233-
clientId: config.clientId,
234-
certPath: config.jwtCertPath,
235-
keyPath: config.jwtKeyPath,
236-
passphrase: config.jwtPassphrase,
237-
accountManagerHost,
238-
scopes: config.scopes,
239-
});
229+
try {
230+
this.logger.debug('[Auth] Using JWT Bearer authentication');
231+
return new JwtOAuthStrategy({
232+
clientId: config.clientId,
233+
certPath: config.jwtCertPath,
234+
keyPath: config.jwtKeyPath,
235+
passphrase: config.jwtPassphrase,
236+
accountManagerHost,
237+
scopes: config.scopes,
238+
});
239+
} catch (error) {
240+
// JWT config is present but invalid (corrupted files, wrong passphrase, etc.)
241+
// Log warning and fall through to next auth method
242+
const message = error instanceof Error ? error.message : String(error);
243+
this.logger.warn(
244+
`[Auth] JWT authentication configured but invalid: ${message}. Trying next auth method.`,
245+
);
246+
}
240247
}
241248
break;
242249

packages/b2c-tooling-sdk/src/config/sources/env-source.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,7 @@ const ENV_VAR_MAP: Record<string, keyof NormalizedConfig> = {
4545
SFCC_SANDBOX_API_HOST: 'sandboxApiHost',
4646
// JWT Bearer auth env vars
4747
SFCC_JWT_CERT: 'jwtCertPath',
48-
SFCC_JWT_CERT_PATH: 'jwtCertPath',
4948
SFCC_JWT_KEY: 'jwtKeyPath',
50-
SFCC_JWT_KEY_PATH: 'jwtKeyPath',
5149
SFCC_JWT_PASSPHRASE: 'jwtPassphrase',
5250
// MRT env vars — MRT_* listed first as fallback, SFCC_MRT_* listed second to take precedence
5351
MRT_API_KEY: 'mrtApiKey',

0 commit comments

Comments
 (0)