Skip to content

Commit 7a3015f

Browse files
authored
fix(sdk): add automatic 401 retry with token refresh to openapi-fetch middleware (#103)
* fix(sdk): add automatic 401 retry with token refresh to openapi-fetch middleware The SDK had automatic token refresh for auth.fetch() calls (WebDAV), but openapi-fetch clients (OCAPI, SLAS, SCAPI, etc.) did not retry on 401. For long-running operations, if a token expires mid-operation, API calls would fail. This adds an onResponse handler to createAuthMiddleware() that: - Detects 401 responses - Invalidates the cached token via auth.invalidateToken() - Gets a fresh token via auth.getAuthorizationHeader() - Retries the request once with the new token Uses WeakSet/WeakMap to track retried requests and preserve request bodies. * chore(sdk): add token refresh test script Utility script for manually testing 401 retry behavior during long-running operations. Polls active code version every 10 seconds.
1 parent 36b54c0 commit 7a3015f

File tree

5 files changed

+461
-2
lines changed

5 files changed

+461
-2
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@salesforce/b2c-tooling-sdk': patch
3+
---
4+
5+
Add automatic 401 retry with token refresh to openapi-fetch middleware. This ensures API clients (OCAPI, SLAS, SCAPI, etc.) automatically refresh expired tokens during long-running operations.
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
#!/usr/bin/env npx tsx
2+
/*
3+
* Copyright (c) 2025, Salesforce, Inc.
4+
* SPDX-License-Identifier: Apache-2
5+
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
6+
*/
7+
8+
/**
9+
* Test script for verifying automatic 401 retry with token refresh.
10+
*
11+
* Polls the active code version every 10 seconds to test that long-running
12+
* operations correctly handle token expiration and refresh.
13+
*
14+
* Usage:
15+
* # With command line args
16+
* npx tsx scripts/test-token-refresh.ts --client-id=YOUR_CLIENT_ID --client-secret=YOUR_SECRET
17+
*
18+
* # With environment variables
19+
* SFCC_OAUTH_CLIENT_ID=xxx SFCC_OAUTH_CLIENT_SECRET=yyy npx tsx scripts/test-token-refresh.ts
20+
*
21+
* # With trace logging to see token refresh behavior
22+
* SFCC_LOG_LEVEL=trace npx tsx scripts/test-token-refresh.ts --client-id=xxx --client-secret=yyy
23+
*
24+
* # Disable redaction to see full tokens (NOT recommended for shared terminals)
25+
* SFCC_LOG_REDACT=false SFCC_LOG_LEVEL=trace npx tsx scripts/test-token-refresh.ts ...
26+
*
27+
* # Implicit flow (client ID only, opens browser)
28+
* npx tsx scripts/test-token-refresh.ts --client-id=YOUR_CLIENT_ID
29+
*
30+
* The script will run until interrupted (Ctrl+C).
31+
*/
32+
33+
import {resolveConfig} from '../src/config/resolver.js';
34+
import {configureLogger, getLogger} from '../src/logging/logger.js';
35+
import {getActiveCodeVersion} from '../src/operations/code/versions.js';
36+
37+
function parseArgs(): {clientId?: string; clientSecret?: string} {
38+
const args: {clientId?: string; clientSecret?: string} = {};
39+
40+
for (const arg of process.argv.slice(2)) {
41+
if (arg.startsWith('--client-id=')) {
42+
args.clientId = arg.slice('--client-id='.length);
43+
} else if (arg.startsWith('--client-secret=')) {
44+
args.clientSecret = arg.slice('--client-secret='.length);
45+
}
46+
}
47+
48+
return args;
49+
}
50+
51+
const POLL_INTERVAL_MS = 10_000; // 10 seconds
52+
53+
async function main() {
54+
// Configure logger based on environment
55+
const logLevel = (process.env.SFCC_LOG_LEVEL as 'trace' | 'debug' | 'info' | 'warn' | 'error') || 'info';
56+
const redact = process.env.SFCC_LOG_REDACT !== 'false'; // Redaction on by default
57+
configureLogger({level: logLevel, redact});
58+
59+
const logger = getLogger();
60+
61+
logger.info('=== Token Refresh Test Script ===');
62+
logger.info(`Poll interval: ${POLL_INTERVAL_MS / 1000} seconds`);
63+
logger.info(`Log level: ${logLevel}`);
64+
logger.info('Press Ctrl+C to stop\n');
65+
66+
// Parse command line args
67+
const args = parseArgs();
68+
69+
// Resolve config from dw.json / environment, with CLI overrides
70+
const config = resolveConfig({
71+
clientId: args.clientId || process.env.SFCC_OAUTH_CLIENT_ID,
72+
clientSecret: args.clientSecret || process.env.SFCC_OAUTH_CLIENT_SECRET,
73+
});
74+
75+
if (!config.hasB2CInstanceConfig()) {
76+
logger.error('No B2C instance configuration found. Check dw.json or environment variables.');
77+
process.exit(1);
78+
}
79+
80+
const instance = config.createB2CInstance();
81+
const authMethod = config.values.clientSecret ? 'client-credentials' : 'implicit';
82+
logger.info({hostname: config.values.hostname, authMethod}, `Connected to: ${config.values.hostname}`);
83+
logger.info(`Auth method: ${authMethod}${authMethod === 'implicit' ? ' (will open browser on first request)' : ''}`);
84+
85+
let pollCount = 0;
86+
const startTime = Date.now();
87+
88+
const poll = async () => {
89+
pollCount++;
90+
const elapsed = Math.floor((Date.now() - startTime) / 1000);
91+
const elapsedMinutes = Math.floor(elapsed / 60);
92+
const elapsedSeconds = elapsed % 60;
93+
94+
try {
95+
const activeVersion = await getActiveCodeVersion(instance);
96+
const timestamp = new Date().toISOString();
97+
98+
logger.info(
99+
{
100+
poll: pollCount,
101+
elapsed: `${elapsedMinutes}m ${elapsedSeconds}s`,
102+
activeCodeVersion: activeVersion?.id,
103+
},
104+
`[${timestamp}] Poll #${pollCount} (${elapsedMinutes}m ${elapsedSeconds}s) - Active code version: ${activeVersion?.id ?? 'none'}`,
105+
);
106+
} catch (error) {
107+
const timestamp = new Date().toISOString();
108+
logger.error(
109+
{
110+
poll: pollCount,
111+
elapsed: `${elapsedMinutes}m ${elapsedSeconds}s`,
112+
error: error instanceof Error ? error.message : String(error),
113+
},
114+
`[${timestamp}] Poll #${pollCount} FAILED: ${error instanceof Error ? error.message : String(error)}`,
115+
);
116+
}
117+
};
118+
119+
// Initial poll
120+
await poll();
121+
122+
// Set up interval
123+
const intervalId = setInterval(poll, POLL_INTERVAL_MS);
124+
125+
// Handle graceful shutdown
126+
process.on('SIGINT', () => {
127+
logger.info('\nShutting down...');
128+
clearInterval(intervalId);
129+
130+
const elapsed = Math.floor((Date.now() - startTime) / 1000);
131+
const elapsedMinutes = Math.floor(elapsed / 60);
132+
const elapsedSeconds = elapsed % 60;
133+
134+
logger.info(
135+
{totalPolls: pollCount, totalTime: `${elapsedMinutes}m ${elapsedSeconds}s`},
136+
`\nCompleted ${pollCount} polls over ${elapsedMinutes}m ${elapsedSeconds}s`,
137+
);
138+
process.exit(0);
139+
});
140+
}
141+
142+
main().catch((error) => {
143+
console.error('Fatal error:', error);
144+
process.exit(1);
145+
});

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ export interface AuthStrategy {
2525
* Optional: Helper for legacy clients (like a strict WebDAV lib) that need the raw header.
2626
*/
2727
getAuthorizationHeader?(): Promise<string>;
28+
29+
/**
30+
* Optional: Invalidates the cached token, forcing re-authentication on next request.
31+
* Used by middleware to retry requests after receiving a 401 response.
32+
*/
33+
invalidateToken?(): void;
2834
}
2935

3036
/**

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

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,24 +38,88 @@ function headersToObject(headers: Headers): Record<string, string> {
3838
return result;
3939
}
4040

41+
// Track which requests have been retried to prevent infinite loops
42+
const retriedRequests = new WeakSet<Request>();
43+
44+
// Store cloned request bodies for potential retry (body can only be read once)
45+
const requestBodies = new WeakMap<Request, ArrayBuffer | null>();
46+
4147
/**
4248
* Creates authentication middleware for openapi-fetch.
4349
*
4450
* This middleware intercepts requests and adds OAuth authentication headers
45-
* using the provided AuthStrategy.
51+
* using the provided AuthStrategy. It also handles 401 responses by invalidating
52+
* the token and retrying the request once with a fresh token.
4653
*
4754
* @param auth - The authentication strategy to use
48-
* @returns Middleware that adds auth headers to requests
55+
* @returns Middleware that adds auth headers to requests and retries on 401
4956
*/
5057
export function createAuthMiddleware(auth: AuthStrategy): Middleware {
58+
const logger = getLogger();
59+
5160
return {
5261
async onRequest({request}) {
5362
if (auth.getAuthorizationHeader) {
5463
const authHeader = await auth.getAuthorizationHeader();
5564
request.headers.set('Authorization', authHeader);
5665
}
66+
67+
// Clone the request body before it gets consumed, so we can retry if needed
68+
if (request.body && auth.invalidateToken && auth.getAuthorizationHeader) {
69+
const clonedRequest = request.clone();
70+
const bodyBuffer = await clonedRequest.arrayBuffer();
71+
requestBodies.set(request, bodyBuffer);
72+
}
73+
5774
return request;
5875
},
76+
77+
async onResponse({request, response}) {
78+
// Only retry on 401 if we haven't already retried this request
79+
// and the strategy supports token invalidation
80+
if (
81+
response.status === 401 &&
82+
!retriedRequests.has(request) &&
83+
auth.invalidateToken &&
84+
auth.getAuthorizationHeader
85+
) {
86+
logger.debug('[AuthMiddleware] Received 401, invalidating token and retrying');
87+
88+
// Mark this request as retried to prevent infinite loops
89+
retriedRequests.add(request);
90+
91+
// Invalidate the cached token
92+
auth.invalidateToken();
93+
94+
// Get a fresh token
95+
const newAuthHeader = await auth.getAuthorizationHeader();
96+
97+
// Rebuild the request with the new auth header
98+
const newHeaders = new Headers(request.headers);
99+
newHeaders.set('Authorization', newAuthHeader);
100+
101+
// Get the saved body (if any)
102+
const savedBody = requestBodies.get(request);
103+
104+
// Create a new request with the fresh token
105+
const retryRequest = new Request(request.url, {
106+
method: request.method,
107+
headers: newHeaders,
108+
body: savedBody,
109+
// TypeScript doesn't know about duplex, but it's needed for streaming bodies
110+
...(savedBody ? {duplex: 'half'} : {}),
111+
} as RequestInit);
112+
113+
// Retry the request
114+
const retryResponse = await fetch(retryRequest);
115+
116+
logger.debug({status: retryResponse.status}, `[AuthMiddleware] Retry response: ${retryResponse.status}`);
117+
118+
return retryResponse;
119+
}
120+
121+
return response;
122+
},
59123
};
60124
}
61125

0 commit comments

Comments
 (0)