@adobe/aem-cli 16.20.8 → 16.20.10

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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## [16.20.10](https://github.com/adobe/helix-cli/compare/v16.20.9...v16.20.10) (2026-07-08)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **server:** auth to da.live preview host for content.da.live images ([#2754](https://github.com/adobe/helix-cli/issues/2754)) ([c3e4b53](https://github.com/adobe/helix-cli/commit/c3e4b53c2d8a8844a7917e3aff0c856ea7d1c631)), closes [#2752](https://github.com/adobe/helix-cli/issues/2752)
7
+
8
+ ## [16.20.9](https://github.com/adobe/helix-cli/compare/v16.20.8...v16.20.9) (2026-07-07)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * inject hlx:proxyUrl meta for pages served from local content/ ([#2753](https://github.com/adobe/helix-cli/issues/2753)) ([271bfcf](https://github.com/adobe/helix-cli/commit/271bfcfa01316944ce5b4e26e767d59a189b78bc))
14
+
1
15
  ## [16.20.8](https://github.com/adobe/helix-cli/compare/v16.20.7...v16.20.8) (2026-07-06)
2
16
 
3
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/aem-cli",
3
- "version": "16.20.8",
3
+ "version": "16.20.10",
4
4
  "description": "AEM CLI",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -58,6 +58,7 @@
58
58
  "diff": "9.0.0",
59
59
  "dotenv": "17.4.2",
60
60
  "express": "5.2.1",
61
+ "express-rate-limit": "8.5.2",
61
62
  "faye-websocket": "0.11.4",
62
63
  "fs-extra": "11.3.5",
63
64
  "glob": "13.0.6",
@@ -0,0 +1,108 @@
1
+ /*
2
+ * Copyright 2026 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ /**
14
+ * da.live content-image auth bootstrap.
15
+ * Only injected into pages that reference a *.preview.da.live host, since that host
16
+ * requires an IMS-authenticated cookie. First checks whether the browser already has
17
+ * a valid (non-expired) cookie for that host; only if not does it load IMS and either
18
+ * forward the access token to /gimme_cookie, or send the browser through the CLI's own
19
+ * /.aem/cli/da-login redirect (imslib's signIn() ignores this page's origin as the
20
+ * redirect_uri — the darkalley IMS client only allows its fixed :9898 callback).
21
+ * This script is self-contained and browser-compatible (no module system).
22
+ */
23
+ (function iife() {
24
+ var cfg = window.DaContentAuthConfig;
25
+ if (!cfg || !cfg.previewOrigin || !cfg.clientId) {
26
+ return;
27
+ }
28
+
29
+ // sendCookie() only ever runs after we've determined the browser had no valid
30
+ // preview cookie, meaning any preview-host images on this page already fired
31
+ // (and failed) before the cookie existed. Reload once so they're refetched with
32
+ // the new cookie attached — without this, the page looks broken until the user
33
+ // manually reloads.
34
+ function sendCookie(token) {
35
+ window.fetch(`${cfg.previewOrigin}/gimme_cookie`, {
36
+ method: 'GET',
37
+ credentials: 'include',
38
+ headers: { Authorization: `Bearer ${token}` },
39
+ }).then(function onResponse(res) {
40
+ if (res.ok) {
41
+ window.location.reload();
42
+ }
43
+ }).catch(function onError() {
44
+ // non-fatal: images will fail to load, page still renders
45
+ });
46
+ }
47
+
48
+ function redirectToLogin() {
49
+ var returnUrl = window.location.href.split('#')[0];
50
+ window.location.href = `/.aem/cli/da-login?return=${encodeURIComponent(returnUrl)}`;
51
+ }
52
+
53
+ function bootstrapIms() {
54
+ window.adobeid = {
55
+ client_id: cfg.clientId,
56
+ scope: cfg.scope,
57
+ environment: 'prod',
58
+ autoValidateToken: true,
59
+ useLocalStorage: true,
60
+ onReady: function onReady() {
61
+ var accessToken = window.adobeIMS.getAccessToken();
62
+ if (accessToken) {
63
+ sendCookie(accessToken.token);
64
+ } else {
65
+ redirectToLogin();
66
+ }
67
+ },
68
+ onError: function onError() {
69
+ // non-fatal: images will fail to load, page still renders
70
+ },
71
+ };
72
+ var script = document.createElement('script');
73
+ script.src = 'https://auth.services.adobe.com/imslib/imslib.min.js';
74
+ document.head.appendChild(script);
75
+ }
76
+
77
+ // A credentialed request to an actual gated asset fails (401/403) whenever there's
78
+ // no cookie yet or the existing one has expired — the server re-checks it every
79
+ // request, so this alone covers both cases without us tracking expiry ourselves.
80
+ // Probing the site root wouldn't work: it's often served regardless of auth, only
81
+ // the assets themselves are gated.
82
+ function hasValidCookie() {
83
+ return window.fetch(`${cfg.previewOrigin}${cfg.probePath || '/'}`, {
84
+ method: 'HEAD',
85
+ credentials: 'include',
86
+ cache: 'no-store',
87
+ }).then(function onResponse(res) {
88
+ return res.ok;
89
+ }).catch(function onError() {
90
+ return false;
91
+ });
92
+ }
93
+
94
+ // Returning from the /.aem/cli/da-login round trip: the access token is in the URL fragment.
95
+ var hashParams = new URLSearchParams(window.location.hash.replace(/^#/, ''));
96
+ var tokenFromRedirect = hashParams.get('access_token');
97
+ if (tokenFromRedirect) {
98
+ window.history.replaceState(null, '', window.location.pathname + window.location.search);
99
+ sendCookie(tokenFromRedirect);
100
+ return;
101
+ }
102
+
103
+ hasValidCookie().then(function onChecked(valid) {
104
+ if (!valid) {
105
+ bootstrapIms();
106
+ }
107
+ });
108
+ }());
@@ -16,8 +16,11 @@ import open from 'open';
16
16
  import { ensureGitIgnored } from './content-git.js';
17
17
 
18
18
  const IMS_ORIGIN = 'https://ims-na1.adobelogin.com';
19
- const CLIENT_ID = 'darkalley';
20
- const SCOPE = 'ab.manage,AdobeID,gnav,openid,org.read,read_organizations,session,aem.frontend.all,additional_info.ownerOrg,additional_info.projectedProductContext,account_cluster.read';
19
+ /** Shared with da-live's own IMS client (see da-live/scripts/scripts.js). */
20
+ export const DA_IMS_CLIENT_ID = 'darkalley';
21
+ export const DA_IMS_SCOPE = 'ab.manage,AdobeID,gnav,openid,org.read,read_organizations,session,aem.frontend.all,additional_info.ownerOrg,additional_info.projectedProductContext,account_cluster.read';
22
+ const CLIENT_ID = DA_IMS_CLIENT_ID;
23
+ const SCOPE = DA_IMS_SCOPE;
21
24
  const CALLBACK_PORT = 9898;
22
25
  const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}/callback`;
23
26
 
@@ -70,11 +73,15 @@ function isTokenExpired(stored) {
70
73
  * IMS redirects to http://localhost:{port}/callback#access_token=TOKEN
71
74
  * The fragment never reaches the server, so /callback serves a tiny HTML page
72
75
  * that reads the fragment via JS and forwards the token to /token, then
73
- * redirects the browser to https://tools.aem.live/cli/logged-in on success.
76
+ * redirects the browser on success — to `finalRedirectUrl` (with the token appended
77
+ * as a URL fragment) when given, otherwise to https://tools.aem.live/cli/logged-in.
74
78
  *
79
+ * @param {string} [finalRedirectUrl] where to send the browser after login,
80
+ * used by the `aem up` dev-server login flow to return to the page that
81
+ * triggered it. Omitted for the CLI's own `content clone`/`content push` login.
75
82
  * @returns {Promise<{token: string, expiresIn: number|null}>}
76
83
  */
77
- function waitForToken() {
84
+ function waitForToken(finalRedirectUrl) {
78
85
  return new Promise((resolve, reject) => {
79
86
  let timeout;
80
87
  const server = http.createServer((req, res) => {
@@ -83,6 +90,9 @@ function waitForToken() {
83
90
  // Step 1: IMS lands here with the token in the fragment.
84
91
  // Serve a page that extracts it and calls /token.
85
92
  if (url.pathname === '/callback') {
93
+ const finalizeJs = finalRedirectUrl
94
+ ? "window.location.href = loggedInUrl + '#access_token=' + encodeURIComponent(token) + (expiresIn ? '&expires_in=' + encodeURIComponent(expiresIn) : '');"
95
+ : 'window.location.href = loggedInUrl;';
86
96
  res.writeHead(200, { 'Content-Type': 'text/html' });
87
97
  res.end(`<!DOCTYPE html><html><head><title>Logging in...</title></head><body>
88
98
  <script>
@@ -93,7 +103,7 @@ function waitForToken() {
93
103
  const dest = token
94
104
  ? '/token?access_token=' + encodeURIComponent(token) + (expiresIn ? '&expires_in=' + encodeURIComponent(expiresIn) : '')
95
105
  : '/token?error=' + encodeURIComponent(error || 'unknown');
96
- const loggedInUrl = 'https://tools.aem.live/cli/logged-in';
106
+ const loggedInUrl = ${JSON.stringify(finalRedirectUrl || 'https://tools.aem.live/cli/logged-in')};
97
107
  if (!token) {
98
108
  fetch(dest);
99
109
  document.body.innerHTML = '<h2>Login failed.</h2>';
@@ -102,7 +112,7 @@ function waitForToken() {
102
112
  document.body.appendChild(errP);
103
113
  } else {
104
114
  fetch(dest)
105
- .then(() => { window.location.href = loggedInUrl; })
115
+ .then(() => { ${finalizeJs} })
106
116
  .catch(() => {
107
117
  document.body.innerHTML = '<h2>Login failed.</h2><p>Could not complete login.</p>';
108
118
  });
@@ -176,6 +186,31 @@ async function login(log, projectDir) {
176
186
 
177
187
  // ─── Public API ──────────────────────────────────────────────────────────────
178
188
 
189
+ /**
190
+ * Starts the IMS login flow for the `aem up` dev server: unlike {@link getValidToken},
191
+ * this doesn't open a browser itself — the caller already has one open (the page that
192
+ * needs auth). Returns the IMS authorize URL to redirect that page to; the browser
193
+ * comes back to `finalRedirectUrl` with `#access_token=...` once login completes.
194
+ *
195
+ * The `darkalley` IMS client only allows `http://localhost:9898/callback` as a
196
+ * redirect_uri (arbitrary localhost ports/paths are rejected), so the round trip
197
+ * always passes through the fixed callback server before returning to the caller.
198
+ *
199
+ * @param {string} finalRedirectUrl page to send the browser back to after login
200
+ * @returns {string} the IMS authorize URL to redirect the browser to
201
+ */
202
+ export function startDaLoginRedirect(finalRedirectUrl) {
203
+ const params = new URLSearchParams({
204
+ response_type: 'token',
205
+ client_id: CLIENT_ID,
206
+ scope: SCOPE,
207
+ redirect_uri: REDIRECT_URI,
208
+ });
209
+ // fire-and-forget: the callback server delivers the browser to finalRedirectUrl itself
210
+ waitForToken(finalRedirectUrl).catch(() => {});
211
+ return `${IMS_ORIGIN}/ims/authorize/v2?${params}`;
212
+ }
213
+
179
214
  /**
180
215
  * Returns a valid da.live access token. Triggers browser login if needed.
181
216
  *
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import crypto from 'crypto';
13
13
  import express from 'express';
14
+ import { rateLimit } from 'express-rate-limit';
14
15
  import { promisify } from 'util';
15
16
  import path from 'path';
16
17
  import { lstat, readFile } from 'fs/promises';
@@ -22,9 +23,20 @@ import LiveReload from './LiveReload.js';
22
23
  import { saveSiteTokenToFile } from '../config/config-utils.js';
23
24
  import { CONTENT_DIR } from '../content/content-shared.js';
24
25
  import { transformContentMetadataHtml } from '../content/content-metadata-html.js';
26
+ import { DA_IMS_CLIENT_ID, DA_IMS_SCOPE, startDaLoginRedirect } from '../content/da-auth.js';
25
27
 
26
28
  const LOGIN_ROUTE = '/.aem/cli/login';
27
29
  const LOGIN_ACK_ROUTE = '/.aem/cli/login/ack';
30
+ const DA_LOGIN_ROUTE = '/.aem/cli/da-login';
31
+
32
+ // Local dev-server only, but both routes trigger real side effects (a live server
33
+ // bind on :9898, an outbound redirect to IMS) — cap abuse from a runaway page/script.
34
+ const daContentAuthRateLimit = rateLimit({
35
+ windowMs: 60_000,
36
+ limit: 20,
37
+ standardHeaders: true,
38
+ legacyHeaders: false,
39
+ });
28
40
 
29
41
  // HTML folder candidate extensions, in lookup order.
30
42
  // First entry takes precedence when multiple candidates exist on disk.
@@ -140,6 +152,34 @@ export class HelixServer extends BaseServer {
140
152
  res.status(302).set('location', loginUrl).send('');
141
153
  }
142
154
 
155
+ /**
156
+ * Kicks off the IMS login flow for a page that needs the da.live preview cookie
157
+ * (see {@link utils.injectDaContentAuthScript}). Redirects to IMS; the browser comes
158
+ * back to the `return` url (validated same-origin, to avoid leaking the token via an
159
+ * open redirect) with `#access_token=...` once the fixed :9898 callback catches it.
160
+ */
161
+ handleDaLogin(req, res) {
162
+ if (!req.query.return) {
163
+ res.status(400).send('Invalid or missing return url.');
164
+ return;
165
+ }
166
+ const expectedOrigin = `${req.protocol}://${req.get('host')}`;
167
+ let target;
168
+ try {
169
+ const parsed = new URL(req.query.return, expectedOrigin);
170
+ if (parsed.origin !== expectedOrigin) {
171
+ throw new Error('cross-origin return url');
172
+ }
173
+ target = parsed.href;
174
+ } catch (e) {
175
+ res.status(400).send('Invalid or missing return url.');
176
+ return;
177
+ }
178
+ this.log.debug(`Starting da.live login, returning to ${target} when done.`);
179
+ const authUrl = startDaLoginRedirect(target);
180
+ res.status(302).set('location', authUrl).send('');
181
+ }
182
+
143
183
  async handleLoginAck(req, res) {
144
184
  const CACHE_CONTROL = 'no-store, private, must-revalidate';
145
185
  const CORS_HEADERS = {
@@ -466,6 +506,17 @@ export class HelixServer extends BaseServer {
466
506
  ? `${contentFilePath.slice(0, -'.plain.html'.length)}.html`
467
507
  : contentFilePath;
468
508
  let htmlContent = await readFile(servedFilePath, 'utf-8');
509
+ htmlContent = utils.rewriteDaContentImageUrls(
510
+ htmlContent,
511
+ this._project.org,
512
+ this._project.site,
513
+ );
514
+ const previewOrigin = this._project.org && this._project.site
515
+ ? `https://main--${this._project.site}--${this._project.org}.preview.da.live`
516
+ : null;
517
+ // Content may already reference the preview host directly (not just via
518
+ // the content.da.live rewrite above), so gate on presence, not on rewrite.
519
+ const needsDaContentAuth = !!previewOrigin && htmlContent.includes(previewOrigin);
469
520
  if (isPlainFallback) {
470
521
  if (liveReload) {
471
522
  liveReload.registerFile(ctx.requestId, servedFilePath);
@@ -503,6 +554,21 @@ export class HelixServer extends BaseServer {
503
554
  htmlContent = htmlContent.replace(/<\/head>/i, `${metaTagsHtml}</head>`);
504
555
  }
505
556
  }
557
+ const proxyPageUrl = new URL(ctx.url, proxyUrl);
558
+ for (const [key, value] of proxyUrl.searchParams.entries()) {
559
+ proxyPageUrl.searchParams.append(key, value);
560
+ }
561
+ htmlContent = utils.injectMeta(htmlContent, {
562
+ 'hlx:proxyUrl': proxyPageUrl.href,
563
+ });
564
+ if (needsDaContentAuth) {
565
+ htmlContent = utils.injectDaContentAuthScript(htmlContent, {
566
+ previewOrigin,
567
+ probePath: utils.findDaPreviewProbePath(htmlContent, previewOrigin),
568
+ clientId: DA_IMS_CLIENT_ID,
569
+ scope: DA_IMS_SCOPE,
570
+ });
571
+ }
506
572
  if (liveReload) {
507
573
  htmlContent = utils.injectLiveReloadScript(htmlContent, this);
508
574
  liveReload.registerFile(ctx.requestId, contentFilePath);
@@ -606,6 +672,12 @@ export class HelixServer extends BaseServer {
606
672
  this.app.get(LOGIN_ACK_ROUTE, asyncHandler(this.handleLoginAck.bind(this)));
607
673
  this.app.post(LOGIN_ACK_ROUTE, express.json(), asyncHandler(this.handleLoginAck.bind(this)));
608
674
  this.app.options(LOGIN_ACK_ROUTE, asyncHandler(this.handleLoginAck.bind(this)));
675
+ this.app.get(
676
+ '/__internal__/da-content-auth.js',
677
+ daContentAuthRateLimit,
678
+ (req, res) => utils.serveDaContentAuthScript(res),
679
+ );
680
+ this.app.get(DA_LOGIN_ROUTE, daContentAuthRateLimit, this.handleDaLogin.bind(this));
609
681
 
610
682
  // Add HTML folder handler before the general proxy handler
611
683
  if (this._htmlFolder) {
@@ -31,6 +31,10 @@ const CONSOLE_INTERCEPTOR = readFileSync(
31
31
  path.join(__dirname, '../../packages/browser-injectables/src/console-interceptor.js'),
32
32
  'utf-8',
33
33
  );
34
+ const DA_CONTENT_AUTH_SCRIPT = readFileSync(
35
+ path.join(__dirname, '../../packages/browser-injectables/src/da-content-auth.js'),
36
+ 'utf-8',
37
+ );
34
38
 
35
39
  const utils = {
36
40
  status2level(status, debug3xx) {
@@ -791,6 +795,76 @@ window.LiveReloadOptions = {
791
795
  return `<html><head>${fullHead}</head><body><header></header><main>${content}</main><footer></footer></body></html>`;
792
796
  },
793
797
 
798
+ /**
799
+ * Rewrites da.live content-store image URLs (`https://content.da.live/${org}/${site}/...`)
800
+ * to the site's preview domain, since content.da.live is not publicly reachable
801
+ * for rendering images during local dev.
802
+ * @param {string} html html content
803
+ * @param {string} org da.live org
804
+ * @param {string} site da.live site
805
+ * @returns {string} rewritten html
806
+ */
807
+ rewriteDaContentImageUrls(html, org, site) {
808
+ if (!org || !site) {
809
+ return html;
810
+ }
811
+ const from = `https://content.da.live/${org}/${site}/`;
812
+ const to = `https://main--${site}--${org}.preview.da.live/`;
813
+ return html.split(from).join(to);
814
+ },
815
+
816
+ /**
817
+ * Finds one concrete asset path served from `previewOrigin` in the page (e.g. an
818
+ * image src), so the browser can probe *that* to check for a valid auth cookie —
819
+ * the site's root document is often served regardless of auth, only assets are
820
+ * gated, so probing `/` would give a false "already authorized" reading.
821
+ * @param {string} html html content
822
+ * @param {string} previewOrigin e.g. `https://main--site--org.preview.da.live`
823
+ * @returns {string} an absolute path (starting with `/`), defaults to `/` if none found
824
+ */
825
+ findDaPreviewProbePath(html, previewOrigin) {
826
+ const escaped = previewOrigin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
827
+ const match = html.match(new RegExp(`${escaped}(/[^"'\\s)]*)`));
828
+ return match ? match[1] : '/';
829
+ },
830
+
831
+ /**
832
+ * Injects the IMS-auth bootstrap that forwards an access token to `/gimme_cookie`
833
+ * on the given preview origin, so images served from that origin (after
834
+ * {@link rewriteDaContentImageUrls}) are authorized. Redirects the browser to the
835
+ * IMS login screen if there is no existing session.
836
+ * @param {string} html full HTML document (must contain a `</head>`)
837
+ * @param {{ previewOrigin: string, probePath: string, clientId: string, scope: string }} options
838
+ * @returns {string} html with the auth bootstrap injected, unchanged if no `</head>` found
839
+ */
840
+ injectDaContentAuthScript(html, {
841
+ previewOrigin, probePath, clientId, scope,
842
+ }) {
843
+ const match = html.match(/<\/head>/i);
844
+ if (!match) {
845
+ return html;
846
+ }
847
+ const { index } = match;
848
+ const nonceMatch = html.match(/nonce="([a-zA-Z0-9+/=]+)"/);
849
+ const nonce = nonceMatch ? ` nonce="${nonceMatch[1]}"` : '';
850
+ const config = JSON.stringify({
851
+ previewOrigin, probePath, clientId, scope,
852
+ });
853
+ const script = `<script${nonce}>window.DaContentAuthConfig=${config};</script>`
854
+ + `<script${nonce} src="/__internal__/da-content-auth.js"></script>`;
855
+ return `${html.substring(0, index)}${script}${html.substring(index)}`;
856
+ },
857
+
858
+ /**
859
+ * Serves the da-content-auth bootstrap as a static JS file (referenced by
860
+ * {@link injectDaContentAuthScript}).
861
+ * @param {Express.Response} res response
862
+ */
863
+ serveDaContentAuthScript(res) {
864
+ res.set('content-type', 'application/javascript');
865
+ res.send(DA_CONTENT_AUTH_SCRIPT);
866
+ },
867
+
794
868
  /**
795
869
  * Extracts the innerHTML of the <main> element from a full HTML document.
796
870
  * Returns the original content unchanged if no <main> is found.