@salesforce/b2c-tooling-sdk 1.21.3 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/data/guides/enrichment.json +294 -0
  2. package/data/guides/index.json +434 -27
  3. package/data/help/index.json +6 -1
  4. package/data/job-steps/index.json +1 -1
  5. package/data/job-steps/job-steps.json +1 -1
  6. package/data/script-api/index.json +49 -5
  7. package/data/tooling/index.json +432 -9
  8. package/data/xsd/commercefeaturestate.xsd +1 -0
  9. package/data/xsd/index.json +2 -1
  10. package/data/xsd/redirecturl.xsd +2 -0
  11. package/dist/esm/auth/index.d.ts +6 -3
  12. package/dist/esm/auth/index.js +4 -2
  13. package/dist/esm/auth/index.js.map +1 -1
  14. package/dist/esm/auth/oauth-implicit.d.ts +19 -0
  15. package/dist/esm/auth/oauth-implicit.js +103 -10
  16. package/dist/esm/auth/oauth-implicit.js.map +1 -1
  17. package/dist/esm/auth/oauth-pkce-fallback.d.ts +68 -0
  18. package/dist/esm/auth/oauth-pkce-fallback.js +128 -0
  19. package/dist/esm/auth/oauth-pkce-fallback.js.map +1 -0
  20. package/dist/esm/auth/oauth-pkce.d.ts +94 -0
  21. package/dist/esm/auth/oauth-pkce.js +579 -0
  22. package/dist/esm/auth/oauth-pkce.js.map +1 -0
  23. package/dist/esm/auth/resolve.d.ts +4 -3
  24. package/dist/esm/auth/resolve.js +17 -2
  25. package/dist/esm/auth/resolve.js.map +1 -1
  26. package/dist/esm/auth/session-store.d.ts +99 -0
  27. package/dist/esm/auth/session-store.js +242 -0
  28. package/dist/esm/auth/session-store.js.map +1 -0
  29. package/dist/esm/auth/stateful-oauth-strategy.d.ts +20 -24
  30. package/dist/esm/auth/stateful-oauth-strategy.js +16 -114
  31. package/dist/esm/auth/stateful-oauth-strategy.js.map +1 -1
  32. package/dist/esm/auth/types.d.ts +20 -4
  33. package/dist/esm/auth/types.js +1 -1
  34. package/dist/esm/auth/types.js.map +1 -1
  35. package/dist/esm/cli/am-command.d.ts +2 -2
  36. package/dist/esm/cli/am-command.js +13 -4
  37. package/dist/esm/cli/am-command.js.map +1 -1
  38. package/dist/esm/cli/base-command.js +3 -3
  39. package/dist/esm/cli/base-command.js.map +1 -1
  40. package/dist/esm/cli/config.js +3 -1
  41. package/dist/esm/cli/config.js.map +1 -1
  42. package/dist/esm/cli/lifecycle.d.ts +1 -1
  43. package/dist/esm/cli/lifecycle.js.map +1 -1
  44. package/dist/esm/cli/oauth-command.d.ts +12 -11
  45. package/dist/esm/cli/oauth-command.js +73 -41
  46. package/dist/esm/cli/oauth-command.js.map +1 -1
  47. package/dist/esm/cli/ods-command.d.ts +1 -1
  48. package/dist/esm/cli/ods-command.js +5 -3
  49. package/dist/esm/cli/ods-command.js.map +1 -1
  50. package/dist/esm/clients/ods.generated.d.ts +16 -0
  51. package/dist/esm/config/dw-json.d.ts +10 -0
  52. package/dist/esm/config/dw-json.js.map +1 -1
  53. package/dist/esm/config/index.d.ts +1 -0
  54. package/dist/esm/config/index.js +2 -0
  55. package/dist/esm/config/index.js.map +1 -1
  56. package/dist/esm/config/mapping.d.ts +13 -0
  57. package/dist/esm/config/mapping.js +66 -3
  58. package/dist/esm/config/mapping.js.map +1 -1
  59. package/dist/esm/config/redaction.d.ts +39 -0
  60. package/dist/esm/config/redaction.js +51 -0
  61. package/dist/esm/config/redaction.js.map +1 -0
  62. package/dist/esm/config/resolver.js +1 -0
  63. package/dist/esm/config/resolver.js.map +1 -1
  64. package/dist/esm/config/sources/env-source.d.ts +1 -1
  65. package/dist/esm/config/sources/env-source.js +14 -7
  66. package/dist/esm/config/sources/env-source.js.map +1 -1
  67. package/dist/esm/config/sources/package-json-source.js +3 -1
  68. package/dist/esm/config/sources/package-json-source.js.map +1 -1
  69. package/dist/esm/config/types.d.ts +14 -4
  70. package/dist/esm/defaults.d.ts +23 -4
  71. package/dist/esm/defaults.js +36 -4
  72. package/dist/esm/defaults.js.map +1 -1
  73. package/dist/esm/docs/content-cache.d.ts +1 -1
  74. package/dist/esm/docs/content-cache.js +1 -1
  75. package/dist/esm/docs/index.d.ts +1 -1
  76. package/dist/esm/docs/index.js.map +1 -1
  77. package/dist/esm/docs/types.d.ts +29 -0
  78. package/dist/esm/index.d.ts +6 -4
  79. package/dist/esm/index.js +3 -3
  80. package/dist/esm/index.js.map +1 -1
  81. package/dist/esm/instance/index.js +3 -3
  82. package/dist/esm/instance/index.js.map +1 -1
  83. package/dist/esm/operations/cap/index.d.ts +2 -2
  84. package/dist/esm/operations/cap/index.js +1 -1
  85. package/dist/esm/operations/cap/index.js.map +1 -1
  86. package/dist/esm/operations/cap/install.d.ts +17 -0
  87. package/dist/esm/operations/cap/install.js +29 -0
  88. package/dist/esm/operations/cap/install.js.map +1 -1
  89. package/dist/esm/operations/cap/package.js +36 -0
  90. package/dist/esm/operations/cap/package.js.map +1 -1
  91. package/dist/esm/operations/cap/validate.d.ts +9 -0
  92. package/dist/esm/operations/cap/validate.js +29 -0
  93. package/dist/esm/operations/cap/validate.js.map +1 -1
  94. package/dist/esm/operations/content/export.js +7 -3
  95. package/dist/esm/operations/content/export.js.map +1 -1
  96. package/dist/esm/operations/content/library.d.ts +26 -0
  97. package/dist/esm/operations/content/library.js +83 -2
  98. package/dist/esm/operations/content/library.js.map +1 -1
  99. package/dist/esm/operations/content/types.d.ts +10 -1
  100. package/dist/esm/operations/jobs/import-set.d.ts +170 -0
  101. package/dist/esm/operations/jobs/import-set.js +538 -0
  102. package/dist/esm/operations/jobs/import-set.js.map +1 -0
  103. package/dist/esm/operations/jobs/index.d.ts +3 -0
  104. package/dist/esm/operations/jobs/index.js +3 -0
  105. package/dist/esm/operations/jobs/index.js.map +1 -1
  106. package/dist/esm/operations/jobs/run.js +0 -11
  107. package/dist/esm/operations/jobs/run.js.map +1 -1
  108. package/dist/esm/operations/jobs/site-archive.js +13 -75
  109. package/dist/esm/operations/jobs/site-archive.js.map +1 -1
  110. package/dist/esm/operations/ods/index.d.ts +4 -0
  111. package/dist/esm/operations/ods/index.js +2 -0
  112. package/dist/esm/operations/ods/index.js.map +1 -1
  113. package/dist/esm/operations/ods/sandbox-settings.d.ts +51 -0
  114. package/dist/esm/operations/ods/sandbox-settings.js +63 -0
  115. package/dist/esm/operations/ods/sandbox-settings.js.map +1 -0
  116. package/dist/esm/operations/ods/wait-for-clones.d.ts +97 -0
  117. package/dist/esm/operations/ods/wait-for-clones.js +145 -0
  118. package/dist/esm/operations/ods/wait-for-clones.js.map +1 -0
  119. package/package.json +2 -1
  120. package/specs/ods-api-v1.json +43 -0
  121. package/dist/esm/auth/stateful-store.d.ts +0 -49
  122. package/dist/esm/auth/stateful-store.js +0 -166
  123. package/dist/esm/auth/stateful-store.js.map +0 -1
@@ -0,0 +1,94 @@
1
+ import type { AuthStrategy, AccessTokenResponse, DecodedJWT, FetchInit } from './types.js';
2
+ /**
3
+ * Thrown when the Authorization Code + PKCE flow fails in a way that indicates
4
+ * the Account Manager client is not registered for this grant (e.g. an old
5
+ * implicit-only public client, or a missing/mismatched redirect URI) rather
6
+ * than a transient or user-driven failure.
7
+ *
8
+ * {@link PkceWithImplicitFallbackStrategy} keys its automatic fallback off this
9
+ * type so it retries with the legacy implicit flow ONLY for grant/registration
10
+ * failures — never for user-cancel, state mismatch, or a port-in-use error.
11
+ *
12
+ * @remarks Part of the implicit→PKCE migration safety net. Remove together with
13
+ * the fallback strategy once all public clients are PKCE-capable.
14
+ */
15
+ export declare class PkceGrantUnsupportedError extends Error {
16
+ /** The OAuth 2.0 `error` code from the authorize redirect or token response, when available. */
17
+ readonly oauthError?: string;
18
+ /** The OAuth stage at which the failure occurred. */
19
+ readonly stage: 'authorize' | 'token';
20
+ constructor(message: string, stage: 'authorize' | 'token', oauthError?: string);
21
+ }
22
+ /**
23
+ * Configuration for the OAuth Authorization Code + PKCE flow.
24
+ */
25
+ export interface PkceOAuthConfig {
26
+ clientId: string;
27
+ scopes?: string[];
28
+ accountManagerHost?: string;
29
+ /** Local port for the redirect server (default 8080 or SFCC_OAUTH_LOCAL_PORT). */
30
+ localPort?: number;
31
+ /** Override redirect URI (default `http://localhost:${localPort}` or SFCC_REDIRECT_URI). */
32
+ redirectUri?: string;
33
+ /** Custom browser opener. Receives the authorization URL. */
34
+ openBrowser?: (url: string) => Promise<void>;
35
+ /**
36
+ * Persist tokens (access + refresh) to disk between CLI invocations,
37
+ * keyed by clientId. When a refresh token is available it is used to
38
+ * silently obtain a new access token instead of opening the browser.
39
+ * Defaults to `true`.
40
+ */
41
+ persistSession?: boolean;
42
+ }
43
+ /**
44
+ * OAuth 2.0 Authorization Code Flow with PKCE.
45
+ *
46
+ * Used for public clients (no client secret). Replaces the legacy implicit flow,
47
+ * which is deprecated for public clients per OAuth 2.1.
48
+ *
49
+ * Flow:
50
+ * 1. Generate PKCE verifier + S256 challenge.
51
+ * 2. Open browser to `/dwsso/oauth2/authorize?response_type=code&code_challenge=...`.
52
+ * 3. Capture redirect with `?code=...` on a localhost listener.
53
+ * 4. POST `grant_type=authorization_code` + `code_verifier` to `/dwsso/oauth2/access_token`.
54
+ *
55
+ * Tokens may include a refresh_token (depends on client registration in Account Manager).
56
+ */
57
+ export declare class PkceOAuthStrategy implements AuthStrategy {
58
+ private config;
59
+ private accountManagerHost;
60
+ private localPort;
61
+ private redirectUri;
62
+ private persistSession;
63
+ private _hasHadSuccess;
64
+ private _refreshToken;
65
+ private _sub;
66
+ private _hydrated;
67
+ constructor(config: PkceOAuthConfig);
68
+ /**
69
+ * Load any persisted session for this clientId. Idempotent.
70
+ */
71
+ private hydrate;
72
+ fetch(url: string, init?: FetchInit): Promise<Response>;
73
+ getAuthorizationHeader(): Promise<string>;
74
+ getJWT(): Promise<DecodedJWT>;
75
+ getTokenResponse(): Promise<AccessTokenResponse>;
76
+ invalidateToken(): void;
77
+ private isCachedTokenUsable;
78
+ private getAccessToken;
79
+ /**
80
+ * Exchange a stored refresh_token for a new access token. Returns null if no
81
+ * refresh token is available or the exchange fails (e.g. revoked / expired).
82
+ * On failure, the refresh token is forgotten so the next request triggers
83
+ * the browser flow.
84
+ */
85
+ private tryRefresh;
86
+ /**
87
+ * Persist the current token + refresh token. No-op when `persistSession`
88
+ * is disabled. Updates `_sub` from the JWT when available so the persisted
89
+ * record carries the authenticated user identity for diagnostics.
90
+ */
91
+ private persistTokens;
92
+ private runFlow;
93
+ private waitForAuthCode;
94
+ }
@@ -0,0 +1,579 @@
1
+ /*
2
+ * Copyright (c) 2025, Salesforce, Inc.
3
+ * SPDX-License-Identifier: Apache-2
4
+ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
5
+ */
6
+ import { createHash, randomBytes } from 'node:crypto';
7
+ import { createServer } from 'node:http';
8
+ import { URL } from 'node:url';
9
+ import { dispatchFetch } from './dispatch-fetch.js';
10
+ import { getLogger } from '../logging/logger.js';
11
+ import { decodeJWT } from './oauth.js';
12
+ import { DEFAULT_ACCOUNT_MANAGER_HOST } from '../defaults.js';
13
+ import { findAuthSession, saveAuthSession } from './session-store.js';
14
+ const DEFAULT_LOCAL_PORT = 8080;
15
+ const ACCESS_TOKEN_CACHE = new Map();
16
+ const PENDING_AUTH = new Map();
17
+ /**
18
+ * Thrown when the Authorization Code + PKCE flow fails in a way that indicates
19
+ * the Account Manager client is not registered for this grant (e.g. an old
20
+ * implicit-only public client, or a missing/mismatched redirect URI) rather
21
+ * than a transient or user-driven failure.
22
+ *
23
+ * {@link PkceWithImplicitFallbackStrategy} keys its automatic fallback off this
24
+ * type so it retries with the legacy implicit flow ONLY for grant/registration
25
+ * failures — never for user-cancel, state mismatch, or a port-in-use error.
26
+ *
27
+ * @remarks Part of the implicit→PKCE migration safety net. Remove together with
28
+ * the fallback strategy once all public clients are PKCE-capable.
29
+ */
30
+ export class PkceGrantUnsupportedError extends Error {
31
+ /** The OAuth 2.0 `error` code from the authorize redirect or token response, when available. */
32
+ oauthError;
33
+ /** The OAuth stage at which the failure occurred. */
34
+ stage;
35
+ constructor(message, stage, oauthError) {
36
+ super(message);
37
+ this.name = 'PkceGrantUnsupportedError';
38
+ this.stage = stage;
39
+ this.oauthError = oauthError;
40
+ }
41
+ }
42
+ /**
43
+ * OAuth 2.0 `error` codes that genuinely indicate the client is not registered
44
+ * as a PKCE-capable public client — the only failures the implicit fallback can
45
+ * legitimately rescue. Implicit shares the same authorize endpoint and redirect
46
+ * URI but returns the token on the redirect with NO token-endpoint call and NO
47
+ * client authentication, so it rescues clients that support implicit but not
48
+ * the Authorization Code grant:
49
+ *
50
+ * - `invalid_client` — at the token exchange, Account Manager demands client
51
+ * authentication (e.g. "Parameter client_assertion_type is missing"), i.e. the
52
+ * client is confidential/implicit-only, NOT a public/PKCE client. This is the
53
+ * primary real-world migration case for legacy implicit clients.
54
+ * - `unauthorized_client` — client is not permitted to use this grant type.
55
+ * - `unsupported_response_type` — authorize endpoint rejects `response_type=code`.
56
+ * - `unsupported_grant_type` — token endpoint rejects `authorization_code`.
57
+ *
58
+ * Every other error is a real failure that would fail identically under
59
+ * implicit and MUST NOT trigger the fallback, e.g. `invalid_scope` (bad
60
+ * scopes), `access_denied` (user cancelled consent), `invalid_grant`
61
+ * (expired/replayed code or PKCE verifier mismatch), `invalid_request`.
62
+ */
63
+ const PKCE_GRANT_UNSUPPORTED_OAUTH_ERRORS = new Set([
64
+ 'invalid_client',
65
+ 'unauthorized_client',
66
+ 'unsupported_response_type',
67
+ 'unsupported_grant_type',
68
+ ]);
69
+ /**
70
+ * Returns true only when an OAuth `error` code indicates the client cannot use
71
+ * the Authorization Code (PKCE) grant — the narrow set of failures the implicit
72
+ * fallback should rescue. Unknown/absent codes return false so ambiguous
73
+ * failures surface directly instead of silently downgrading to implicit.
74
+ */
75
+ function isPkceGrantUnsupportedError(oauthError) {
76
+ return oauthError !== undefined && PKCE_GRANT_UNSUPPORTED_OAUTH_ERRORS.has(oauthError);
77
+ }
78
+ function base64url(buf) {
79
+ return buf.toString('base64').replace(/=+$/, '').replaceAll('+', '-').replaceAll('/', '_');
80
+ }
81
+ function generatePkcePair() {
82
+ const verifier = base64url(randomBytes(32));
83
+ const challenge = base64url(createHash('sha256').update(verifier).digest());
84
+ return { verifier, challenge };
85
+ }
86
+ function parseOAuthErrorBody(text) {
87
+ try {
88
+ const parsed = JSON.parse(text);
89
+ return {
90
+ error: typeof parsed.error === 'string' ? parsed.error : undefined,
91
+ errorDescription: typeof parsed.error_description === 'string' ? parsed.error_description : undefined,
92
+ };
93
+ }
94
+ catch {
95
+ return {};
96
+ }
97
+ }
98
+ async function openBrowserDefault(url) {
99
+ try {
100
+ const open = await import('open');
101
+ await open.default(url);
102
+ }
103
+ catch {
104
+ getLogger().debug('Could not automatically open browser');
105
+ }
106
+ }
107
+ /**
108
+ * OAuth 2.0 Authorization Code Flow with PKCE.
109
+ *
110
+ * Used for public clients (no client secret). Replaces the legacy implicit flow,
111
+ * which is deprecated for public clients per OAuth 2.1.
112
+ *
113
+ * Flow:
114
+ * 1. Generate PKCE verifier + S256 challenge.
115
+ * 2. Open browser to `/dwsso/oauth2/authorize?response_type=code&code_challenge=...`.
116
+ * 3. Capture redirect with `?code=...` on a localhost listener.
117
+ * 4. POST `grant_type=authorization_code` + `code_verifier` to `/dwsso/oauth2/access_token`.
118
+ *
119
+ * Tokens may include a refresh_token (depends on client registration in Account Manager).
120
+ */
121
+ export class PkceOAuthStrategy {
122
+ config;
123
+ accountManagerHost;
124
+ localPort;
125
+ redirectUri;
126
+ persistSession;
127
+ _hasHadSuccess = false;
128
+ _refreshToken = null;
129
+ _sub = '';
130
+ _hydrated = false;
131
+ constructor(config) {
132
+ this.config = config;
133
+ this.accountManagerHost = config.accountManagerHost || DEFAULT_ACCOUNT_MANAGER_HOST;
134
+ this.localPort = config.localPort || parseInt(process.env.SFCC_OAUTH_LOCAL_PORT || '', 10) || DEFAULT_LOCAL_PORT;
135
+ this.redirectUri = config.redirectUri || process.env.SFCC_REDIRECT_URI || `http://localhost:${this.localPort}`;
136
+ this.persistSession = config.persistSession !== false;
137
+ getLogger().debug({
138
+ clientId: this.config.clientId,
139
+ accountManagerHost: this.accountManagerHost,
140
+ port: this.localPort,
141
+ redirectUri: this.redirectUri,
142
+ persistSession: this.persistSession,
143
+ }, '[Auth] PkceOAuthStrategy initialized');
144
+ }
145
+ /**
146
+ * Load any persisted session for this clientId. Idempotent.
147
+ */
148
+ hydrate() {
149
+ if (!this.persistSession || this._hydrated)
150
+ return;
151
+ this._hydrated = true;
152
+ try {
153
+ const stored = findAuthSession(this.config.clientId);
154
+ if (!stored || stored.flow !== 'pkce')
155
+ return;
156
+ this._sub = stored.sub ?? '';
157
+ this._refreshToken = stored.refreshToken ?? null;
158
+ if (!ACCESS_TOKEN_CACHE.has(this.config.clientId) && stored.accessToken) {
159
+ const expires = stored.expiresAt ? new Date(stored.expiresAt) : new Date(0);
160
+ ACCESS_TOKEN_CACHE.set(this.config.clientId, {
161
+ accessToken: stored.accessToken,
162
+ expires,
163
+ scopes: stored.scopes ?? [],
164
+ });
165
+ }
166
+ getLogger().debug({ clientId: this.config.clientId, sub: this._sub, hasRefresh: this._refreshToken !== null }, '[Auth] Hydrated PKCE session from store');
167
+ }
168
+ catch (error) {
169
+ getLogger().debug({ err: error }, '[Auth] PKCE store hydration failed');
170
+ }
171
+ }
172
+ async fetch(url, init = {}) {
173
+ const logger = getLogger();
174
+ const method = init.method || 'GET';
175
+ const token = await this.getAccessToken();
176
+ const headers = new Headers(init.headers);
177
+ headers.set('Authorization', `Bearer ${token}`);
178
+ headers.set('x-dw-client-id', this.config.clientId);
179
+ let res = await dispatchFetch(url, { ...init, headers });
180
+ logger.debug({ method, url, status: res.status }, '[Auth] Response');
181
+ if (res.status !== 401) {
182
+ this._hasHadSuccess = true;
183
+ }
184
+ if (res.status === 401 && this._hasHadSuccess) {
185
+ logger.debug('[Auth] Received 401, invalidating PKCE token and retrying');
186
+ this.invalidateToken();
187
+ const newToken = await this.getAccessToken();
188
+ headers.set('Authorization', `Bearer ${newToken}`);
189
+ res = await dispatchFetch(url, { ...init, headers });
190
+ logger.debug({ method, url, status: res.status }, '[Auth] Retry response');
191
+ }
192
+ return res;
193
+ }
194
+ async getAuthorizationHeader() {
195
+ const token = await this.getAccessToken();
196
+ return `Bearer ${token}`;
197
+ }
198
+ async getJWT() {
199
+ const token = await this.getAccessToken();
200
+ return decodeJWT(token);
201
+ }
202
+ async getTokenResponse() {
203
+ this.hydrate();
204
+ const cached = ACCESS_TOKEN_CACHE.get(this.config.clientId);
205
+ if (cached && this.isCachedTokenUsable(cached)) {
206
+ return cached;
207
+ }
208
+ if (this._refreshToken) {
209
+ const refreshed = await this.tryRefresh();
210
+ if (refreshed)
211
+ return refreshed;
212
+ }
213
+ const tokenResponse = await this.runFlow();
214
+ ACCESS_TOKEN_CACHE.set(this.config.clientId, tokenResponse);
215
+ return tokenResponse;
216
+ }
217
+ invalidateToken() {
218
+ // Only drop the cached access token. The refresh token is preserved so the
219
+ // next getAccessToken() can silently mint a new access token via
220
+ // tryRefresh() instead of re-opening the browser. tryRefresh() forgets the
221
+ // refresh token on its own when the exchange genuinely fails (revoked /
222
+ // expired), which is the only case where we fall back to the browser flow.
223
+ ACCESS_TOKEN_CACHE.delete(this.config.clientId);
224
+ }
225
+ isCachedTokenUsable(cached) {
226
+ const requiredScopes = this.config.scopes || [];
227
+ const hasAllScopes = requiredScopes.every((scope) => cached.scopes.includes(scope));
228
+ return hasAllScopes && Date.now() <= cached.expires.getTime();
229
+ }
230
+ async getAccessToken() {
231
+ this.hydrate();
232
+ const clientId = this.config.clientId;
233
+ const cached = ACCESS_TOKEN_CACHE.get(clientId);
234
+ if (cached && this.isCachedTokenUsable(cached)) {
235
+ return cached.accessToken;
236
+ }
237
+ if (cached) {
238
+ ACCESS_TOKEN_CACHE.delete(clientId);
239
+ }
240
+ const pending = PENDING_AUTH.get(clientId);
241
+ if (pending) {
242
+ const tokenResponse = await pending;
243
+ return tokenResponse.accessToken;
244
+ }
245
+ const authPromise = (async () => {
246
+ if (this._refreshToken) {
247
+ const refreshed = await this.tryRefresh();
248
+ if (refreshed)
249
+ return refreshed;
250
+ }
251
+ return this.runFlow();
252
+ })();
253
+ PENDING_AUTH.set(clientId, authPromise);
254
+ try {
255
+ const tokenResponse = await authPromise;
256
+ ACCESS_TOKEN_CACHE.set(clientId, tokenResponse);
257
+ return tokenResponse.accessToken;
258
+ }
259
+ finally {
260
+ PENDING_AUTH.delete(clientId);
261
+ }
262
+ }
263
+ /**
264
+ * Exchange a stored refresh_token for a new access token. Returns null if no
265
+ * refresh token is available or the exchange fails (e.g. revoked / expired).
266
+ * On failure, the refresh token is forgotten so the next request triggers
267
+ * the browser flow.
268
+ */
269
+ async tryRefresh() {
270
+ const logger = getLogger();
271
+ if (!this._refreshToken)
272
+ return null;
273
+ const tokenUrl = `https://${this.accountManagerHost}/dwsso/oauth2/access_token`;
274
+ const body = new URLSearchParams({
275
+ grant_type: 'refresh_token',
276
+ refresh_token: this._refreshToken,
277
+ client_id: this.config.clientId,
278
+ });
279
+ if (this.config.scopes && this.config.scopes.length > 0) {
280
+ body.set('scope', this.config.scopes.join(' '));
281
+ }
282
+ logger.debug({ method: 'POST', url: tokenUrl }, '[Auth REQ] POST /dwsso/oauth2/access_token (refresh_token)');
283
+ // refresh_token is a secret; trace only the non-sensitive parameters.
284
+ logger.trace({
285
+ method: 'POST',
286
+ url: tokenUrl,
287
+ body: { grant_type: 'refresh_token', client_id: this.config.clientId, scope: this.config.scopes?.join(' ') },
288
+ }, '[Auth REQ BODY] POST /dwsso/oauth2/access_token (refresh_token)');
289
+ let response;
290
+ try {
291
+ response = await dispatchFetch(tokenUrl, {
292
+ method: 'POST',
293
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
294
+ body: body.toString(),
295
+ });
296
+ }
297
+ catch (error) {
298
+ logger.debug({ err: error }, '[Auth] PKCE refresh request failed');
299
+ this._refreshToken = null;
300
+ return null;
301
+ }
302
+ logger.debug({ url: tokenUrl, status: response.status }, `[Auth RESP] POST /dwsso/oauth2/access_token ${response.status}`);
303
+ if (!response.ok) {
304
+ const text = await response.text();
305
+ const oauthError = parseOAuthErrorBody(text);
306
+ logger.trace({ url: tokenUrl, status: response.status, body: oauthError }, '[Auth RESP BODY] POST /dwsso/oauth2/access_token (refresh_token)');
307
+ logger.debug({ status: response.status, oauthError: oauthError.error }, '[Auth] PKCE refresh failed; falling back to browser flow');
308
+ this._refreshToken = null;
309
+ return null;
310
+ }
311
+ let parsed;
312
+ try {
313
+ parsed = (await response.json());
314
+ }
315
+ catch (error) {
316
+ logger.debug({ err: error }, '[Auth] PKCE refresh returned non-JSON response');
317
+ this._refreshToken = null;
318
+ return null;
319
+ }
320
+ if (typeof parsed.access_token !== 'string' || parsed.access_token.length === 0) {
321
+ logger.debug('[Auth] PKCE refresh response did not contain an access token');
322
+ this._refreshToken = null;
323
+ return null;
324
+ }
325
+ const expiresIn = typeof parsed.expires_in === 'number' ? parsed.expires_in : 0;
326
+ const expires = new Date(Date.now() + expiresIn * 1000);
327
+ const scopes = typeof parsed.scope === 'string' ? parsed.scope.split(' ') : (this.config.scopes ?? []);
328
+ const tokenResponse = {
329
+ accessToken: parsed.access_token,
330
+ expires,
331
+ scopes,
332
+ };
333
+ if (typeof parsed.refresh_token === 'string' && parsed.refresh_token.length > 0) {
334
+ this._refreshToken = parsed.refresh_token;
335
+ }
336
+ try {
337
+ logger.trace({ jwt: decodeJWT(parsed.access_token).payload }, '[Auth] Refreshed PKCE access token JWT payload');
338
+ }
339
+ catch {
340
+ // access token is not a JWT; nothing to trace
341
+ }
342
+ this.persistTokens(tokenResponse);
343
+ logger.debug({ clientId: this.config.clientId }, '[Auth] PKCE token refreshed silently');
344
+ return tokenResponse;
345
+ }
346
+ /**
347
+ * Persist the current token + refresh token. No-op when `persistSession`
348
+ * is disabled. Updates `_sub` from the JWT when available so the persisted
349
+ * record carries the authenticated user identity for diagnostics.
350
+ */
351
+ persistTokens(tokenResponse) {
352
+ if (!this.persistSession)
353
+ return;
354
+ let sub = this._sub;
355
+ try {
356
+ const decoded = decodeJWT(tokenResponse.accessToken);
357
+ if (typeof decoded.payload.sub === 'string' && decoded.payload.sub.length > 0) {
358
+ sub = decoded.payload.sub;
359
+ }
360
+ }
361
+ catch {
362
+ // ignore — token may not be a JWT, fall back to existing _sub
363
+ }
364
+ this._sub = sub;
365
+ const record = {
366
+ clientId: this.config.clientId,
367
+ flow: 'pkce',
368
+ accessToken: tokenResponse.accessToken,
369
+ refreshToken: this._refreshToken,
370
+ sub,
371
+ expiresAt: tokenResponse.expires.toISOString(),
372
+ scopes: tokenResponse.scopes,
373
+ accountManagerHost: this.accountManagerHost,
374
+ };
375
+ try {
376
+ saveAuthSession(record);
377
+ }
378
+ catch (error) {
379
+ getLogger().debug({ err: error }, '[Auth] Failed to persist PKCE session');
380
+ }
381
+ }
382
+ async runFlow() {
383
+ const logger = getLogger();
384
+ logger.trace({ clientId: this.config.clientId, scopes: this.config.scopes, redirectUri: this.redirectUri }, '[Auth] Starting Authorization Code + PKCE flow');
385
+ const { verifier, challenge } = generatePkcePair();
386
+ const state = base64url(randomBytes(16));
387
+ // Verifier is a secret; trace only the derived challenge and state.
388
+ logger.trace({ codeChallenge: challenge, codeChallengeMethod: 'S256', state }, '[Auth] Generated PKCE challenge');
389
+ const params = new URLSearchParams({
390
+ client_id: this.config.clientId,
391
+ redirect_uri: this.redirectUri,
392
+ response_type: 'code',
393
+ code_challenge: challenge,
394
+ code_challenge_method: 'S256',
395
+ state,
396
+ });
397
+ if (this.config.scopes && this.config.scopes.length > 0) {
398
+ params.set('scope', this.config.scopes.join(' '));
399
+ }
400
+ const authorizeUrl = `https://${this.accountManagerHost}/dwsso/oauth2/authorize?${params.toString()}`;
401
+ logger.info({ url: authorizeUrl }, `Login URL: ${authorizeUrl}`);
402
+ logger.info('If the URL does not open automatically, copy/paste it into a browser on this machine.');
403
+ const code = await this.waitForAuthCode(state, async () => {
404
+ if (this.config.openBrowser) {
405
+ await this.config.openBrowser(authorizeUrl);
406
+ }
407
+ else {
408
+ await openBrowserDefault(authorizeUrl);
409
+ }
410
+ });
411
+ logger.debug({ codePrefix: code.slice(0, 8) }, '[Auth] Got authorization code, exchanging for token');
412
+ const tokenUrl = `https://${this.accountManagerHost}/dwsso/oauth2/access_token`;
413
+ const tokenBody = new URLSearchParams({
414
+ grant_type: 'authorization_code',
415
+ code,
416
+ redirect_uri: this.redirectUri,
417
+ client_id: this.config.clientId,
418
+ code_verifier: verifier,
419
+ });
420
+ logger.debug({ method: 'POST', url: tokenUrl }, '[Auth REQ] POST /dwsso/oauth2/access_token (authorization_code)');
421
+ // Redact the single-use code and PKCE verifier from the traced body.
422
+ logger.trace({
423
+ method: 'POST',
424
+ url: tokenUrl,
425
+ body: { grant_type: 'authorization_code', redirect_uri: this.redirectUri, client_id: this.config.clientId },
426
+ }, '[Auth REQ BODY] POST /dwsso/oauth2/access_token');
427
+ const tokenRes = await dispatchFetch(tokenUrl, {
428
+ method: 'POST',
429
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
430
+ body: tokenBody.toString(),
431
+ });
432
+ const rawText = await tokenRes.text();
433
+ logger.debug({ url: tokenUrl, status: tokenRes.status }, `[Auth RESP] POST /dwsso/oauth2/access_token ${tokenRes.status}`);
434
+ if (!tokenRes.ok) {
435
+ // Distinguish "this client can't do the code grant" (which the implicit
436
+ // fallback can rescue) from real errors that would fail identically under
437
+ // implicit — e.g. invalid_scope, invalid_grant. Only the former is typed
438
+ // as PkceGrantUnsupportedError; everything else is a plain Error so the
439
+ // fallback wrapper propagates it instead of silently downgrading.
440
+ const errorBody = parseOAuthErrorBody(rawText);
441
+ const oauthError = errorBody.error;
442
+ logger.trace({ url: tokenUrl, status: tokenRes.status, body: errorBody }, '[Auth RESP BODY] POST /dwsso/oauth2/access_token');
443
+ const detail = errorBody.errorDescription ?? errorBody.error;
444
+ const message = `PKCE token exchange failed (${tokenRes.status})${detail ? `: ${detail}` : ''}`;
445
+ if (isPkceGrantUnsupportedError(oauthError)) {
446
+ throw new PkceGrantUnsupportedError(message, 'token', oauthError);
447
+ }
448
+ throw new Error(message);
449
+ }
450
+ let parsed;
451
+ try {
452
+ parsed = JSON.parse(rawText);
453
+ }
454
+ catch {
455
+ // Do not echo an untrusted response body: a malformed success response
456
+ // can still contain live token material.
457
+ throw new Error('PKCE token exchange returned a non-JSON response');
458
+ }
459
+ if (typeof parsed.access_token !== 'string' || parsed.access_token.length === 0) {
460
+ throw new Error('PKCE token exchange response did not contain an access token');
461
+ }
462
+ // Never log the raw token response: it contains both the access token and
463
+ // the long-lived refresh token, and an opaque string bypasses pino's
464
+ // field-based redaction. JWT claims are traced below after decoding.
465
+ logger.trace({
466
+ url: tokenUrl,
467
+ status: tokenRes.status,
468
+ body: {
469
+ expires_in: parsed.expires_in,
470
+ scope: parsed.scope,
471
+ hasAccessToken: typeof parsed.access_token === 'string' && parsed.access_token.length > 0,
472
+ hasRefreshToken: typeof parsed.refresh_token === 'string' && parsed.refresh_token.length > 0,
473
+ },
474
+ }, '[Auth RESP BODY] POST /dwsso/oauth2/access_token');
475
+ const expiresIn = typeof parsed.expires_in === 'number' ? parsed.expires_in : 0;
476
+ const expires = new Date(Date.now() + expiresIn * 1000);
477
+ const scopes = typeof parsed.scope === 'string' ? parsed.scope.split(' ') : (this.config.scopes ?? []);
478
+ const tokenResponse = {
479
+ accessToken: parsed.access_token,
480
+ expires,
481
+ scopes,
482
+ };
483
+ if (typeof parsed.refresh_token === 'string' && parsed.refresh_token.length > 0) {
484
+ this._refreshToken = parsed.refresh_token;
485
+ }
486
+ logger.trace({ scopes, expires, hasRefreshToken: !!parsed.refresh_token }, '[Auth] PKCE token exchange succeeded');
487
+ try {
488
+ logger.trace({ jwt: decodeJWT(parsed.access_token).payload }, '[Auth] PKCE access token JWT payload');
489
+ }
490
+ catch {
491
+ // access token is not a JWT; nothing to trace
492
+ }
493
+ this.persistTokens(tokenResponse);
494
+ return tokenResponse;
495
+ }
496
+ waitForAuthCode(expectedState, openBrowser) {
497
+ const logger = getLogger();
498
+ return new Promise((resolve, reject) => {
499
+ const sockets = new Set();
500
+ let settled = false;
501
+ const settleAfterClose = (settle) => {
502
+ if (settled)
503
+ return;
504
+ settled = true;
505
+ const forceCloseTimer = setTimeout(() => {
506
+ for (const socket of sockets)
507
+ socket.destroy();
508
+ }, 100);
509
+ server.close(() => {
510
+ clearTimeout(forceCloseTimer);
511
+ for (const socket of sockets)
512
+ socket.destroy();
513
+ settle();
514
+ });
515
+ };
516
+ const server = createServer((req, res) => {
517
+ const requestUrl = new URL(req.url || '/', `http://localhost:${this.localPort}`);
518
+ const code = requestUrl.searchParams.get('code');
519
+ const state = requestUrl.searchParams.get('state') ?? '';
520
+ const error = requestUrl.searchParams.get('error');
521
+ const errorDescription = requestUrl.searchParams.get('error_description');
522
+ if (error) {
523
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
524
+ res.end(`Authentication failed: ${errorDescription ?? error}`);
525
+ // Only a grant/registration error (e.g. unsupported_response_type,
526
+ // unauthorized_client) means the client can't do the code grant — the
527
+ // implicit fallback can rescue that. A user-driven error like
528
+ // access_denied (cancelled consent) MUST NOT fall back, so type it
529
+ // only for the grant-unsupported set; otherwise reject plainly.
530
+ const message = `OAuth error: ${errorDescription ?? error}`;
531
+ const authError = isPkceGrantUnsupportedError(error)
532
+ ? new PkceGrantUnsupportedError(message, 'authorize', error)
533
+ : new Error(message);
534
+ settleAfterClose(() => reject(authError));
535
+ return;
536
+ }
537
+ if (!code) {
538
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
539
+ res.end('Waiting for authorization code...');
540
+ return;
541
+ }
542
+ if (state !== expectedState) {
543
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
544
+ res.end('State mismatch.');
545
+ settleAfterClose(() => reject(new Error('OAuth state mismatch — aborting')));
546
+ return;
547
+ }
548
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
549
+ res.end('Authentication successful! You may close this browser window and return to your terminal.');
550
+ settleAfterClose(() => resolve(code));
551
+ });
552
+ server.on('connection', (socket) => {
553
+ sockets.add(socket);
554
+ socket.on('close', () => sockets.delete(socket));
555
+ });
556
+ server.listen(this.localPort, async () => {
557
+ logger.debug({ port: this.localPort }, `[Auth] PKCE redirect server listening on port ${this.localPort}`);
558
+ logger.info('Waiting for user to authenticate...');
559
+ try {
560
+ await openBrowser();
561
+ }
562
+ catch (error) {
563
+ const browserError = error instanceof Error ? error : new Error(String(error));
564
+ settleAfterClose(() => reject(browserError));
565
+ }
566
+ });
567
+ server.on('error', (err) => {
568
+ const hint = 'code' in err && err.code === 'EADDRINUSE'
569
+ ? ` Port ${this.localPort} is in use; set SFCC_OAUTH_LOCAL_PORT or pass localPort to use a different port.`
570
+ : '';
571
+ if (!settled) {
572
+ settled = true;
573
+ reject(new Error(`Failed to start OAuth redirect server: ${err.message}.${hint}`));
574
+ }
575
+ });
576
+ });
577
+ }
578
+ }
579
+ //# sourceMappingURL=oauth-pkce.js.map