Skip to content

Commit 9cd4bcc

Browse files
committed
bug fixing config source and retries
1 parent 33dbd2f commit 9cd4bcc

7 files changed

Lines changed: 186 additions & 16 deletions

File tree

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ async function openBrowser(url: string): Promise<void> {
100100
export class ImplicitOAuthStrategy implements AuthStrategy {
101101
private accountManagerHost: string;
102102
private localPort: number;
103+
private _hasHadSuccess = false;
103104

104105
constructor(private config: ImplicitOAuthConfig) {
105106
this.accountManagerHost = config.accountManagerHost || DEFAULT_ACCOUNT_MANAGER_HOST;
@@ -132,9 +133,14 @@ export class ImplicitOAuthStrategy implements AuthStrategy {
132133

133134
logger.debug({method, url, status: res.status, duration}, '[Auth] Response');
134135

135-
// RESILIENCE: If the server says 401, the token might have expired or been revoked.
136-
// We retry exactly once after invalidating the cached token.
137-
if (res.status === 401) {
136+
if (res.status !== 401) {
137+
this._hasHadSuccess = true;
138+
}
139+
140+
// RESILIENCE: If we previously had a successful response and now get a 401,
141+
// the token likely expired. Retry once after invalidating the cached token.
142+
// Skip retry on initial 401 to avoid retrying with bad credentials.
143+
if (res.status === 401 && this._hasHadSuccess) {
138144
logger.debug('[Auth] Received 401, invalidating token and retrying');
139145
this.invalidateToken();
140146
const newToken = await this.getAccessToken();

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ function decodeJWT(jwt: string): DecodedJWT {
3333

3434
export class OAuthStrategy implements AuthStrategy {
3535
private accountManagerHost: string;
36+
private _hasHadSuccess = false;
3637

3738
constructor(private config: OAuthConfig) {
3839
this.accountManagerHost = config.accountManagerHost || DEFAULT_ACCOUNT_MANAGER_HOST;
@@ -49,9 +50,14 @@ export class OAuthStrategy implements AuthStrategy {
4950
// Node.js fetch accepts dispatcher as an undocumented option
5051
let res = await fetch(url, {...init, headers} as RequestInit);
5152

52-
// RESILIENCE: If the server says 401, the token might have expired or been revoked.
53-
// We retry exactly once after invalidating the cached token.
54-
if (res.status === 401) {
53+
if (res.status !== 401) {
54+
this._hasHadSuccess = true;
55+
}
56+
57+
// RESILIENCE: If we previously had a successful response and now get a 401,
58+
// the token likely expired. Retry once after invalidating the cached token.
59+
// Skip retry on initial 401 to avoid retrying with bad credentials.
60+
if (res.status === 401 && this._hasHadSuccess) {
5561
this.invalidateToken();
5662
const newToken = await this.getAccessToken();
5763
headers.set('Authorization', `Bearer ${newToken}`);

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,8 @@ export abstract class OdsCommand<T extends typeof Command> extends OAuthCommand<
3636
static baseFlags = {
3737
...OAuthCommand.baseFlags,
3838
'sandbox-api-host': Flags.string({
39-
description: 'ODS API hostname',
39+
description: `ODS API hostname (default: ${DEFAULT_ODS_HOST})`,
4040
env: 'SFCC_SANDBOX_API_HOST',
41-
default: DEFAULT_ODS_HOST,
4241
// helpGroup: 'ODS',
4342
}),
4443
};

packages/b2c-tooling-sdk/src/clients/middleware.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ const requestBodies = new WeakMap<Request, ArrayBuffer | null>();
5656
*/
5757
export function createAuthMiddleware(auth: AuthStrategy): Middleware {
5858
const logger = getLogger();
59+
let hasHadSuccess = false;
5960

6061
return {
6162
async onRequest({request}) {
@@ -75,10 +76,16 @@ export function createAuthMiddleware(auth: AuthStrategy): Middleware {
7576
},
7677

7778
async onResponse({request, response}) {
78-
// Only retry on 401 if we haven't already retried this request
79-
// and the strategy supports token invalidation
79+
if (response.status !== 401) {
80+
hasHadSuccess = true;
81+
}
82+
83+
// Only retry on 401 if we have had a prior successful response (indicating
84+
// token expiry rather than bad credentials), haven't already retried this
85+
// request, and the strategy supports token invalidation
8086
if (
8187
response.status === 401 &&
88+
hasHadSuccess &&
8289
!retriedRequests.has(request) &&
8390
auth.invalidateToken &&
8491
auth.getAuthorizationHeader

packages/b2c-tooling-sdk/test/auth/oauth-implicit.test.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,20 +74,64 @@ describe('auth/oauth-implicit', () => {
7474
const headers = new Headers(init?.headers);
7575
seenAuth.push(headers.get('Authorization') ?? '');
7676

77+
// First call succeeds (establishes prior success)
7778
if (fetchCalls === 1) {
79+
return new Response('ok', {status: 200});
80+
}
81+
82+
// Second call returns 401 (simulates expired token)
83+
if (fetchCalls === 2) {
7884
return new Response('unauthorized', {status: 401});
7985
}
8086

87+
// Third call succeeds (retry with fresh token)
8188
return new Response('ok', {status: 200});
8289
};
8390

91+
// First call succeeds - establishes prior success
92+
const firstRes = await strategy.fetch('https://example.com/test');
93+
expect(firstRes.status).to.equal(200);
94+
95+
// Second call gets 401 then retries with fresh token
8496
const res = await strategy.fetch('https://example.com/test');
8597
expect(res.status).to.equal(200);
8698

87-
expect(fetchCalls).to.equal(2);
99+
expect(fetchCalls).to.equal(3);
88100
expect(tokenCalls).to.equal(2);
89101
expect(seenAuth[0]).to.equal('Bearer tok-1');
90-
expect(seenAuth[1]).to.equal('Bearer tok-2');
102+
expect(seenAuth[1]).to.equal('Bearer tok-1');
103+
expect(seenAuth[2]).to.equal('Bearer tok-2');
104+
105+
strategy.invalidateToken();
106+
});
107+
108+
it('does not retry on initial 401 when no prior success', async () => {
109+
const clientId = 'implicit-client-no-retry';
110+
const strategy = new ImplicitOAuthStrategy({clientId, scopes: ['a']});
111+
112+
let tokenCalls = 0;
113+
(strategy as unknown as {implicitFlowLogin: () => Promise<TokenResponse>}).implicitFlowLogin = async () => {
114+
tokenCalls++;
115+
return {
116+
accessToken: 'tok-bad',
117+
expires: futureDate(30),
118+
scopes: ['a'],
119+
};
120+
};
121+
122+
let fetchCalls = 0;
123+
124+
globalThis.fetch = async (_input: string | URL | Request, _init?: RequestInit) => {
125+
fetchCalls++;
126+
return new Response('unauthorized', {status: 401});
127+
};
128+
129+
// First call gets 401 - should NOT retry since no prior success
130+
const res = await strategy.fetch('https://example.com/test');
131+
expect(res.status).to.equal(401);
132+
133+
expect(fetchCalls).to.equal(1); // No retry
134+
expect(tokenCalls).to.equal(1); // Only fetched once
91135

92136
strategy.invalidateToken();
93137
});

packages/b2c-tooling-sdk/test/auth/oauth.test.ts

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -269,13 +269,18 @@ describe('auth/oauth', () => {
269269
apiRequestCount++;
270270
const authHeader = request.headers.get('Authorization');
271271

272-
// First request with old token fails
272+
// First request succeeds (establishes prior success)
273273
if (apiRequestCount === 1 && authHeader === `Bearer ${mockToken1}`) {
274+
return HttpResponse.json({success: true});
275+
}
276+
277+
// Second request with old token fails (simulates expired token)
278+
if (apiRequestCount === 2 && authHeader === `Bearer ${mockToken1}`) {
274279
return new HttpResponse(null, {status: 401});
275280
}
276281

277-
// Second request with new token succeeds
278-
if (apiRequestCount === 2 && authHeader === `Bearer ${mockToken2}`) {
282+
// Third request with new token succeeds
283+
if (apiRequestCount === 3 && authHeader === `Bearer ${mockToken2}`) {
279284
return HttpResponse.json({success: true});
280285
}
281286

@@ -288,12 +293,50 @@ describe('auth/oauth', () => {
288293
clientSecret: 'test-secret',
289294
});
290295

296+
// First call succeeds - establishes prior success
297+
const firstResponse = await strategy.fetch(TEST_API_URL);
298+
expect(firstResponse.status).to.equal(200);
299+
300+
// Second call gets 401 then retries with fresh token
291301
const response = await strategy.fetch(TEST_API_URL);
292302

293303
expect(tokenRequestCount).to.equal(2); // Fetched twice
294-
expect(apiRequestCount).to.equal(2); // API called twice
304+
expect(apiRequestCount).to.equal(3); // Initial success + 401 + retry
295305
expect(response.status).to.equal(200);
296306
});
307+
308+
it('should not retry on initial 401 with bad credentials', async () => {
309+
const clientId = 'test-client-bad-creds';
310+
const mockToken = createMockJWT({sub: clientId});
311+
let tokenRequestCount = 0;
312+
let apiRequestCount = 0;
313+
314+
server.use(
315+
http.post(AM_URL, () => {
316+
tokenRequestCount++;
317+
return HttpResponse.json({
318+
access_token: mockToken,
319+
expires_in: 1800,
320+
});
321+
}),
322+
http.get(TEST_API_URL, () => {
323+
apiRequestCount++;
324+
return new HttpResponse(null, {status: 401});
325+
}),
326+
);
327+
328+
const strategy = new OAuthStrategy({
329+
clientId,
330+
clientSecret: 'test-secret',
331+
});
332+
333+
// First call gets 401 - should NOT retry since no prior success
334+
const response = await strategy.fetch(TEST_API_URL);
335+
336+
expect(tokenRequestCount).to.equal(1); // Only fetched once
337+
expect(apiRequestCount).to.equal(1); // API called once, no retry
338+
expect(response.status).to.equal(401);
339+
});
297340
});
298341

299342
describe('getAuthorizationHeader', () => {

packages/b2c-tooling-sdk/test/clients/middleware.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,12 @@ describe('clients/middleware', () => {
9191
throw new Error('Expected middleware to return a Request');
9292
}
9393

94+
// Simulate a prior successful response so that hasHadSuccess is true
95+
await middleware.onResponse!({
96+
request: modifiedRequest,
97+
response: new Response('OK', {status: 200}),
98+
} as unknown as OnResponseParams);
99+
94100
// Stub global fetch to return success on retry
95101
const originalFetch = globalThis.fetch;
96102
globalThis.fetch = sinon.stub().resolves(new Response('OK', {status: 200}));
@@ -146,6 +152,12 @@ describe('clients/middleware', () => {
146152
throw new Error('Expected middleware to return a Request');
147153
}
148154

155+
// Simulate a prior successful response so that hasHadSuccess is true
156+
await middleware.onResponse!({
157+
request: modifiedRequest,
158+
response: new Response('OK', {status: 200}),
159+
} as unknown as OnResponseParams);
160+
149161
// Stub global fetch to also return 401
150162
const originalFetch = globalThis.fetch;
151163
globalThis.fetch = sinon.stub().resolves(new Response('Unauthorized', {status: 401}));
@@ -245,6 +257,12 @@ describe('clients/middleware', () => {
245257
throw new Error('Expected middleware to return a Request');
246258
}
247259

260+
// Simulate a prior successful response so that hasHadSuccess is true
261+
await middleware.onResponse!({
262+
request: modifiedRequest,
263+
response: new Response('OK', {status: 200}),
264+
} as unknown as OnResponseParams);
265+
248266
// Stub global fetch to return success
249267
const originalFetch = globalThis.fetch;
250268
globalThis.fetch = sinon.stub().resolves(new Response('Created', {status: 201}));
@@ -269,6 +287,53 @@ describe('clients/middleware', () => {
269287
}
270288
});
271289

290+
it('does not retry on initial 401 when no prior successful response', async () => {
291+
let invalidateCalled = false;
292+
293+
const auth: AuthStrategy = {
294+
async fetch() {
295+
throw new Error('not used in this unit test');
296+
},
297+
async getAuthorizationHeader() {
298+
return 'Bearer test-token';
299+
},
300+
invalidateToken() {
301+
invalidateCalled = true;
302+
},
303+
};
304+
305+
const middleware = createAuthMiddleware(auth);
306+
type OnRequestParams = Parameters<NonNullable<typeof middleware.onRequest>>[0];
307+
type OnResponseParams = Parameters<NonNullable<typeof middleware.onResponse>>[0];
308+
309+
const request = new Request('https://example.com/ping', {method: 'GET'});
310+
const modifiedRequest = await middleware.onRequest!({request} as unknown as OnRequestParams);
311+
if (!modifiedRequest) {
312+
throw new Error('Expected middleware to return a Request');
313+
}
314+
315+
// Stub global fetch (should not be called)
316+
const originalFetch = globalThis.fetch;
317+
const fetchStub = sinon.stub();
318+
globalThis.fetch = fetchStub;
319+
320+
try {
321+
// Send a 401 without any prior successful response
322+
const unauthorizedResponse = new Response('Unauthorized', {status: 401});
323+
const result = await middleware.onResponse!({
324+
request: modifiedRequest,
325+
response: unauthorizedResponse,
326+
} as unknown as OnResponseParams);
327+
328+
// Should return original 401 without retrying
329+
expect(result).to.equal(unauthorizedResponse);
330+
expect(fetchStub.called).to.equal(false);
331+
expect(invalidateCalled).to.equal(false);
332+
} finally {
333+
globalThis.fetch = originalFetch;
334+
}
335+
});
336+
272337
it('passes through non-401 responses unchanged', async () => {
273338
const auth: AuthStrategy = {
274339
async fetch() {

0 commit comments

Comments
 (0)