Skip to content

Commit 6088508

Browse files
Merge pull request #277 from contentstack/feat/DX-9341-v2
feat(DX-9341): add oauth support in AM
2 parents 27dcc22 + 333af16 commit 6088508

8 files changed

Lines changed: 416 additions & 17 deletions

File tree

.talismanrc

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ fileignoreconfig:
1818
- filename: packages/contentstack-cli-cm-regex-validate/test/utils/connect-stack.test.ts
1919
checksum: 018980aa2b919967b9ef9ab1bdf635d4867fe21593fba5890afa443f440228ff
2020
- filename: packages/contentstack-bulk-operations/src/messages/index.ts
21-
checksum: ba38daefd561d9acd018b6ebcb65847eceab6d375b28988d7b5624dcea9ccfe0
21+
checksum: c8acefdd18f499a82d839e3cf99e34af9dd464f63f92282739411174ae7b7c37
2222
- filename: packages/contentstack-bulk-operations/src/utils/operation-flag-matrix.ts
2323
checksum: 99a3c3eb422a17f73f4c8f15088004fa4c95df3545bdf510310c1f3a20e4c2c2
2424
- filename: packages/contentstack-bulk-operations/src/base-bulk-command.ts
@@ -43,4 +43,8 @@ fileignoreconfig:
4343
checksum: cda61a9c90bb39f27c09951b8a2623851296aefd0d3220d066032287a712d899
4444
- filename: packages/contentstack-asset-management/test/unit/utils/cs-assets-api-adapter.test.ts
4545
checksum: 63c6bff4d51842d8fa3cce88545259d0a2c3cfe71df95d303d993f692cee883b
46+
- filename: packages/contentstack-asset-management/src/utils/cs-assets-api-adapter.ts
47+
checksum: bc4a53f96be6a10786e00133245c7bdc43c965c8a98b753e3879e1110cf9c601
48+
- filename: packages/contentstack-bulk-operations/test/unit/commands/bulk-assets-cs-assets.test.ts
49+
checksum: 1b6b5b0302d3efd9508e2e5d4fe3783da97a110735ca3106ce829cfe93874150
4650
version: '1.0'

packages/contentstack-asset-management/src/export/assets.ts

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@ import { resolve as pResolve } from 'node:path';
22
import { Readable } from 'node:stream';
33
import { mkdir, writeFile } from 'node:fs/promises';
44
import chunk from 'lodash/chunk';
5-
import { configHandler, log, FsUtility } from '@contentstack/cli-utilities';
5+
import { log, FsUtility } from '@contentstack/cli-utilities';
66

77
import type { CSAssetsAPIConfig, LinkedWorkspace } from '../types/cs-assets-api';
88
import type { ExportContext } from '../types/export-types';
99
import { CSAssetsExportAdapter } from './base';
10-
import { writeStreamToFile, getArrayFromResponse } from '../utils/export-helpers';
10+
import { writeStreamToFile, getArrayFromResponse, getSecuredAssetAuth, SecuredAssetAuthError } from '../utils/export-helpers';
11+
import type { SecuredAssetAuth } from '../utils/export-helpers';
1112
import { forEachChunkedJsonStore } from '../utils/chunked-json-reader';
1213
import { withRetry, RetryableHttpError, isRetryableStatus, parseRetryAfterMs } from '../utils/retry';
1314
import type { CustomPromiseHandler } from '../utils/cs-assets-api-adapter';
@@ -111,7 +112,6 @@ export default class ExportAssets extends CSAssetsExportAdapter {
111112
await mkdir(filesDir, { recursive: true });
112113

113114
const securedAssets = this.exportContext.securedAssets ?? false;
114-
const authtoken = securedAssets ? configHandler.get('authtoken') : null;
115115
log.debug(
116116
`Asset downloads: securedAssets=${securedAssets}, concurrency=${this.downloadAssetsBatchConcurrency}, expected=${expectedDownloads}`,
117117
this.exportContext.context,
@@ -120,6 +120,9 @@ export default class ExportAssets extends CSAssetsExportAdapter {
120120

121121
let downloadOk = 0;
122122
let downloadFail = 0;
123+
// Set when a 401 persists after a forced token refresh — from then on, skip the network
124+
// entirely and abort the phase, instead of individually failing every remaining asset.
125+
let authFailure: SecuredAssetAuthError | null = null;
123126

124127
await forEachChunkedJsonStore<AssetRecord>(
125128
assetsDir,
@@ -154,25 +157,60 @@ export default class ExportAssets extends CSAssetsExportAdapter {
154157
async (records) => {
155158
const valid = records.filter((asset) => this.isDownloadable(asset));
156159
if (valid.length === 0) return;
160+
const chunkAuthFailure = authFailure;
161+
if (chunkAuthFailure) {
162+
// Auth already failed hard — count the remaining downloadables without hitting the network.
163+
for (const rec of valid) {
164+
downloadFail += 1;
165+
this.tick(false, `asset: ${rec.filename ?? rec.file_name ?? rec.uid ?? 'asset'}`, chunkAuthFailure.message);
166+
}
167+
return;
168+
}
169+
// Resolve per chunk so long download phases pick up proactively refreshed OAuth tokens.
170+
let auth: SecuredAssetAuth = securedAssets ? await getSecuredAssetAuth() : {};
157171
const apiBatches = chunk(valid, this.downloadAssetsBatchConcurrency);
158172
const promisifyHandler: CustomPromiseHandler = async ({ index, batchIndex }) => {
159173
const asset = apiBatches[batchIndex][index] as AssetRecord;
160174
const uid = (asset.uid ?? asset._uid) as string;
161175
const url = asset.url as string;
162176
const filename = asset.filename ?? asset.file_name ?? 'asset';
163177
if (!url || !uid) return;
178+
const knownAuthFailure = authFailure as SecuredAssetAuthError | null;
179+
if (knownAuthFailure) {
180+
downloadFail += 1;
181+
this.tick(false, `asset: ${filename}`, knownAuthFailure.message);
182+
return;
183+
}
164184
try {
165185
const separator = url.includes('?') ? '&' : '?';
166-
const downloadUrl = securedAssets && authtoken ? `${url}${separator}authtoken=${authtoken}` : url;
186+
const doFetch = () =>
187+
fetch(
188+
securedAssets && auth.authtoken ? `${url}${separator}authtoken=${auth.authtoken}` : url,
189+
securedAssets && auth.headers ? { headers: auth.headers } : undefined,
190+
);
167191
// Binary GET is idempotent — retry transient failures with backoff.
168192
const response = await withRetry(
169193
async () => {
170194
let resp: Response;
171195
try {
172-
resp = await fetch(downloadUrl);
196+
resp = await doFetch();
173197
} catch (e) {
174198
throw new RetryableHttpError(`download network error: ${(e as Error)?.message ?? String(e)}`);
175199
}
200+
if (securedAssets && resp.status === 401) {
201+
// Token expired or was revoked mid-run — force one refresh (deduped upstream)
202+
// and refetch. A second 401 means auth is unrecoverable: abort the phase.
203+
auth = await getSecuredAssetAuth(true);
204+
try {
205+
resp = await doFetch();
206+
} catch (e) {
207+
throw new RetryableHttpError(`download network error: ${(e as Error)?.message ?? String(e)}`);
208+
}
209+
if (resp.status === 401) {
210+
authFailure = new SecuredAssetAuthError(resp.status);
211+
throw authFailure;
212+
}
213+
}
176214
if (!resp.ok) {
177215
if (isRetryableStatus(resp.status)) {
178216
throw new RetryableHttpError(`HTTP ${resp.status}`, resp.status, parseRetryAfterMs(resp.headers.get('retry-after')));
@@ -209,6 +247,16 @@ export default class ExportAssets extends CSAssetsExportAdapter {
209247
},
210248
);
211249

250+
const terminalAuthFailure = authFailure as SecuredAssetAuthError | null;
251+
if (terminalAuthFailure) {
252+
// Fail the space loudly — a "completed with errors" summary would bury the real cause.
253+
log.error(
254+
`Aborted asset downloads for space ${spaceUid}: ${terminalAuthFailure.message}`,
255+
this.exportContext.context,
256+
);
257+
throw terminalAuthFailure;
258+
}
259+
212260
log.info(
213261
downloadFail === 0
214262
? `Finished downloading ${downloadOk} asset file(s) for space ${spaceUid}`

packages/contentstack-asset-management/src/query-export/cs-assets-query-exporter.ts

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
import { resolve as pResolve } from 'node:path';
22
import { mkdir, writeFile } from 'node:fs/promises';
33
import { Readable } from 'node:stream';
4-
import { log, handleAndLogError, configHandler } from '@contentstack/cli-utilities';
4+
import { log, handleAndLogError } from '@contentstack/cli-utilities';
55

66
import type { CsAssetsQueryExportOptions, CSAssetsAPIConfig, LinkedWorkspace } from '../types/cs-assets-api';
77
import type { ExportContext } from '../types/export-types';
88
import ExportAssetTypes from '../export/asset-types';
99
import ExportFields from '../export/fields';
1010
import { CSAssetsExportAdapter } from '../export/base';
1111
import chunk from 'lodash/chunk';
12-
import { getAssetItems, writeStreamToFile } from '../utils/export-helpers';
12+
import { getAssetItems, writeStreamToFile, getSecuredAssetAuth, SecuredAssetAuthError } from '../utils/export-helpers';
13+
import type { SecuredAssetAuth } from '../utils/export-helpers';
1314
import { withRetry, RetryableHttpError, isRetryableStatus, parseRetryAfterMs } from '../utils/retry';
1415
import type { CustomPromiseHandler } from '../utils/cs-assets-api-adapter';
1516

@@ -223,26 +224,64 @@ class QueryExportWorkspaceAdapter extends CSAssetsExportAdapter {
223224
await mkdir(filesDir, { recursive: true });
224225

225226
const securedAssets = this.exportContext.securedAssets ?? false;
226-
const authtoken = securedAssets ? configHandler.get('authtoken') : null;
227+
// Set when a 401 persists after a forced token refresh — from then on, skip the network
228+
// entirely and abort the phase, instead of individually failing every remaining asset.
229+
let authFailure: SecuredAssetAuthError | null = null;
230+
// OAuth → Authorization: Bearer header; basic auth → ?authtoken= query param.
231+
// Resolved lazily once per sequential batch (handlers within a batch share one resolve), so
232+
// long download runs keep picking up proactively refreshed OAuth tokens.
233+
let auth: SecuredAssetAuth = {};
234+
let authBatchIndex = -1;
235+
let authResolve: Promise<void> = Promise.resolve();
236+
const ensureAuthForBatch = (batchIndex: number): Promise<void> => {
237+
if (!securedAssets) return Promise.resolve();
238+
if (batchIndex !== authBatchIndex) {
239+
authBatchIndex = batchIndex;
240+
authResolve = getSecuredAssetAuth().then((resolved) => {
241+
auth = resolved;
242+
});
243+
}
244+
return authResolve;
245+
};
227246

228247
const apiBatches = chunk(downloadable, this.downloadAssetsBatchConcurrency);
229248
const promisifyHandler: CustomPromiseHandler = async ({ index, batchIndex }) => {
230249
const asset = apiBatches[batchIndex][index];
231250
const uid = String(asset.uid ?? asset._uid);
232251
const url = String(asset.url);
233252
const filename = String(asset.filename ?? asset.file_name ?? 'asset');
253+
if (authFailure) return; // auth failed hard — don't hit the network for remaining assets
234254
try {
255+
await ensureAuthForBatch(batchIndex);
235256
const separator = url.includes('?') ? '&' : '?';
236-
const downloadUrl = securedAssets && authtoken ? `${url}${separator}authtoken=${authtoken}` : url;
257+
const doFetch = () =>
258+
fetch(
259+
securedAssets && auth.authtoken ? `${url}${separator}authtoken=${auth.authtoken}` : url,
260+
securedAssets && auth.headers ? { headers: auth.headers } : undefined,
261+
);
237262
// Binary GET is idempotent — retry transient failures with backoff.
238263
const response = await withRetry(
239264
async () => {
240265
let resp: Response;
241266
try {
242-
resp = await fetch(downloadUrl);
267+
resp = await doFetch();
243268
} catch (e) {
244269
throw new RetryableHttpError(`download network error: ${(e as Error)?.message ?? String(e)}`);
245270
}
271+
if (securedAssets && resp.status === 401) {
272+
// Token expired or was revoked mid-run — force one refresh (deduped upstream)
273+
// and refetch. A second 401 means auth is unrecoverable: abort the phase.
274+
auth = await getSecuredAssetAuth(true);
275+
try {
276+
resp = await doFetch();
277+
} catch (e) {
278+
throw new RetryableHttpError(`download network error: ${(e as Error)?.message ?? String(e)}`);
279+
}
280+
if (resp.status === 401) {
281+
authFailure = new SecuredAssetAuthError(resp.status);
282+
throw authFailure;
283+
}
284+
}
246285
if (!resp.ok) {
247286
if (isRetryableStatus(resp.status)) {
248287
throw new RetryableHttpError(`HTTP ${resp.status}`, resp.status, parseRetryAfterMs(resp.headers.get('retry-after')));
@@ -265,5 +304,15 @@ class QueryExportWorkspaceAdapter extends CSAssetsExportAdapter {
265304
};
266305

267306
await this.makeConcurrentCall({ apiBatches, module: 'asset downloads' }, promisifyHandler);
307+
308+
const terminalAuthFailure = authFailure as SecuredAssetAuthError | null;
309+
if (terminalAuthFailure) {
310+
// Fail the space loudly — silently skipping the rest would look like a successful export.
311+
log.error(
312+
`Aborted asset downloads for space ${spaceUid}: ${terminalAuthFailure.message}`,
313+
this.exportContext.context,
314+
);
315+
throw terminalAuthFailure;
316+
}
268317
}
269318
}

packages/contentstack-asset-management/src/utils/export-helpers.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,51 @@
11
import { createWriteStream } from 'node:fs';
2+
import { authHandler, authenticationHandler, configHandler } from '@contentstack/cli-utilities';
3+
4+
export interface SecuredAssetAuth {
5+
/** OAuth: header to attach to the download fetch (value is already "Bearer <token>"). */
6+
headers?: Record<string, string>;
7+
/** Basic auth: token to append as ?authtoken= (existing behavior). */
8+
authtoken?: string;
9+
}
10+
11+
/**
12+
* Terminal auth failure for secured asset downloads: the server kept rejecting the token even
13+
* after a forced refresh. Download loops throw this to abort the whole phase instead of failing
14+
* every remaining asset individually.
15+
*/
16+
export class SecuredAssetAuthError extends Error {
17+
readonly status: number;
18+
19+
constructor(status: number) {
20+
super(
21+
`Secured asset download authentication failed (HTTP ${status}) even after refreshing credentials. ` +
22+
'Please log in again (csdx auth:login) and re-run the export.',
23+
);
24+
this.name = 'SecuredAssetAuthError';
25+
this.status = status;
26+
}
27+
}
28+
29+
/**
30+
* Resolve auth for secured asset binary downloads.
31+
* OAuth → Authorization: Bearer header (getAuthDetails handles proactive expiry refresh).
32+
* Basic → authtoken query param (existing behavior).
33+
*
34+
* Pass `forceRefresh` after a 401: the server rejected a token that is still inside its local
35+
* expiry window (revoked/invalidated), so force a refresh — concurrent callers are deduped by
36+
* authHandler's in-flight refresh promise. No-op for basic auth, which cannot be refreshed.
37+
*/
38+
export async function getSecuredAssetAuth(forceRefresh = false): Promise<SecuredAssetAuth> {
39+
if (forceRefresh && authenticationHandler.isOauthEnabled) {
40+
await authHandler.compareOAuthExpiry(true);
41+
}
42+
await authenticationHandler.getAuthDetails();
43+
if (authenticationHandler.isOauthEnabled) {
44+
return { headers: { authorization: authenticationHandler.accessToken } };
45+
}
46+
const authtoken = configHandler.get('authtoken');
47+
return authtoken ? { authtoken } : {};
48+
}
249

350
export function getArrayFromResponse(data: unknown, arrayKey: string): unknown[] {
451
if (Array.isArray(data)) return data;

packages/contentstack-asset-management/src/utils/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@ export {
55
getArrayFromResponse,
66
getAssetItems,
77
getReadableStreamFromDownloadResponse,
8+
getSecuredAssetAuth,
9+
SecuredAssetAuthError,
810
writeStreamToFile,
911
} from './export-helpers';
12+
export type { SecuredAssetAuth } from './export-helpers';
1013
export { chunkArray, runInBatches } from './concurrent-batch';
1114
export { withRetry, RetryableHttpError, isRetryableStatus, parseRetryAfterMs } from './retry';
1215
export { detectAssetManagementExportFromContentDir } from './detect-asset-management-export';

0 commit comments

Comments
 (0)