diff --git a/package-lock.json b/package-lock.json
index 6b3fbe4cb..16174c00e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -24,6 +24,7 @@
"diff": "9.0.0",
"dotenv": "17.4.2",
"express": "5.2.1",
+ "express-rate-limit": "8.5.2",
"faye-websocket": "0.11.4",
"fs-extra": "11.3.5",
"glob": "13.0.6",
@@ -4428,6 +4429,24 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/express-rate-limit": {
+ "version": "8.5.2",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
+ "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
+ "license": "MIT",
+ "dependencies": {
+ "ip-address": "^10.2.0"
+ },
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/express-rate-limit"
+ },
+ "peerDependencies": {
+ "express": ">= 4.11"
+ }
+ },
"node_modules/express/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
diff --git a/package.json b/package.json
index 38a1f4bce..b2e920426 100644
--- a/package.json
+++ b/package.json
@@ -58,6 +58,7 @@
"diff": "9.0.0",
"dotenv": "17.4.2",
"express": "5.2.1",
+ "express-rate-limit": "8.5.2",
"faye-websocket": "0.11.4",
"fs-extra": "11.3.5",
"glob": "13.0.6",
diff --git a/packages/browser-injectables/src/da-content-auth.js b/packages/browser-injectables/src/da-content-auth.js
new file mode 100644
index 000000000..1ce5df9a4
--- /dev/null
+++ b/packages/browser-injectables/src/da-content-auth.js
@@ -0,0 +1,108 @@
+/*
+ * Copyright 2026 Adobe. All rights reserved.
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License. You may obtain a copy
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
+ * OF ANY KIND, either express or implied. See the License for the specific language
+ * governing permissions and limitations under the License.
+ */
+
+/**
+ * da.live content-image auth bootstrap.
+ * Only injected into pages that reference a *.preview.da.live host, since that host
+ * requires an IMS-authenticated cookie. First checks whether the browser already has
+ * a valid (non-expired) cookie for that host; only if not does it load IMS and either
+ * forward the access token to /gimme_cookie, or send the browser through the CLI's own
+ * /.aem/cli/da-login redirect (imslib's signIn() ignores this page's origin as the
+ * redirect_uri — the darkalley IMS client only allows its fixed :9898 callback).
+ * This script is self-contained and browser-compatible (no module system).
+ */
+(function iife() {
+ var cfg = window.DaContentAuthConfig;
+ if (!cfg || !cfg.previewOrigin || !cfg.clientId) {
+ return;
+ }
+
+ // sendCookie() only ever runs after we've determined the browser had no valid
+ // preview cookie, meaning any preview-host images on this page already fired
+ // (and failed) before the cookie existed. Reload once so they're refetched with
+ // the new cookie attached — without this, the page looks broken until the user
+ // manually reloads.
+ function sendCookie(token) {
+ window.fetch(`${cfg.previewOrigin}/gimme_cookie`, {
+ method: 'GET',
+ credentials: 'include',
+ headers: { Authorization: `Bearer ${token}` },
+ }).then(function onResponse(res) {
+ if (res.ok) {
+ window.location.reload();
+ }
+ }).catch(function onError() {
+ // non-fatal: images will fail to load, page still renders
+ });
+ }
+
+ function redirectToLogin() {
+ var returnUrl = window.location.href.split('#')[0];
+ window.location.href = `/.aem/cli/da-login?return=${encodeURIComponent(returnUrl)}`;
+ }
+
+ function bootstrapIms() {
+ window.adobeid = {
+ client_id: cfg.clientId,
+ scope: cfg.scope,
+ environment: 'prod',
+ autoValidateToken: true,
+ useLocalStorage: true,
+ onReady: function onReady() {
+ var accessToken = window.adobeIMS.getAccessToken();
+ if (accessToken) {
+ sendCookie(accessToken.token);
+ } else {
+ redirectToLogin();
+ }
+ },
+ onError: function onError() {
+ // non-fatal: images will fail to load, page still renders
+ },
+ };
+ var script = document.createElement('script');
+ script.src = 'https://auth.services.adobe.com/imslib/imslib.min.js';
+ document.head.appendChild(script);
+ }
+
+ // A credentialed request to an actual gated asset fails (401/403) whenever there's
+ // no cookie yet or the existing one has expired — the server re-checks it every
+ // request, so this alone covers both cases without us tracking expiry ourselves.
+ // Probing the site root wouldn't work: it's often served regardless of auth, only
+ // the assets themselves are gated.
+ function hasValidCookie() {
+ return window.fetch(`${cfg.previewOrigin}${cfg.probePath || '/'}`, {
+ method: 'HEAD',
+ credentials: 'include',
+ cache: 'no-store',
+ }).then(function onResponse(res) {
+ return res.ok;
+ }).catch(function onError() {
+ return false;
+ });
+ }
+
+ // Returning from the /.aem/cli/da-login round trip: the access token is in the URL fragment.
+ var hashParams = new URLSearchParams(window.location.hash.replace(/^#/, ''));
+ var tokenFromRedirect = hashParams.get('access_token');
+ if (tokenFromRedirect) {
+ window.history.replaceState(null, '', window.location.pathname + window.location.search);
+ sendCookie(tokenFromRedirect);
+ return;
+ }
+
+ hasValidCookie().then(function onChecked(valid) {
+ if (!valid) {
+ bootstrapIms();
+ }
+ });
+}());
diff --git a/src/content/da-auth.js b/src/content/da-auth.js
index 10fdf4d02..83b304431 100644
--- a/src/content/da-auth.js
+++ b/src/content/da-auth.js
@@ -16,8 +16,11 @@ import open from 'open';
import { ensureGitIgnored } from './content-git.js';
const IMS_ORIGIN = 'https://ims-na1.adobelogin.com';
-const CLIENT_ID = 'darkalley';
-const SCOPE = 'ab.manage,AdobeID,gnav,openid,org.read,read_organizations,session,aem.frontend.all,additional_info.ownerOrg,additional_info.projectedProductContext,account_cluster.read';
+/** Shared with da-live's own IMS client (see da-live/scripts/scripts.js). */
+export const DA_IMS_CLIENT_ID = 'darkalley';
+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';
+const CLIENT_ID = DA_IMS_CLIENT_ID;
+const SCOPE = DA_IMS_SCOPE;
const CALLBACK_PORT = 9898;
const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}/callback`;
@@ -70,11 +73,15 @@ function isTokenExpired(stored) {
* IMS redirects to http://localhost:{port}/callback#access_token=TOKEN
* The fragment never reaches the server, so /callback serves a tiny HTML page
* that reads the fragment via JS and forwards the token to /token, then
- * redirects the browser to https://tools.aem.live/cli/logged-in on success.
+ * redirects the browser on success — to `finalRedirectUrl` (with the token appended
+ * as a URL fragment) when given, otherwise to https://tools.aem.live/cli/logged-in.
*
+ * @param {string} [finalRedirectUrl] where to send the browser after login,
+ * used by the `aem up` dev-server login flow to return to the page that
+ * triggered it. Omitted for the CLI's own `content clone`/`content push` login.
* @returns {Promise<{token: string, expiresIn: number|null}>}
*/
-function waitForToken() {
+function waitForToken(finalRedirectUrl) {
return new Promise((resolve, reject) => {
let timeout;
const server = http.createServer((req, res) => {
@@ -83,6 +90,9 @@ function waitForToken() {
// Step 1: IMS lands here with the token in the fragment.
// Serve a page that extracts it and calls /token.
if (url.pathname === '/callback') {
+ const finalizeJs = finalRedirectUrl
+ ? "window.location.href = loggedInUrl + '#access_token=' + encodeURIComponent(token) + (expiresIn ? '&expires_in=' + encodeURIComponent(expiresIn) : '');"
+ : 'window.location.href = loggedInUrl;';
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
Logging in...
`
+ + ``;
+ return `${html.substring(0, index)}${script}${html.substring(index)}`;
+ },
+
+ /**
+ * Serves the da-content-auth bootstrap as a static JS file (referenced by
+ * {@link injectDaContentAuthScript}).
+ * @param {Express.Response} res response
+ */
+ serveDaContentAuthScript(res) {
+ res.set('content-type', 'application/javascript');
+ res.send(DA_CONTENT_AUTH_SCRIPT);
+ },
+
/**
* Extracts the innerHTML of the element from a full HTML document.
* Returns the original content unchanged if no is found.
diff --git a/test/content/da-auth.test.js b/test/content/da-auth.test.js
index 3fb2a0254..e4fd640ac 100644
--- a/test/content/da-auth.test.js
+++ b/test/content/da-auth.test.js
@@ -208,3 +208,23 @@ describe('getValidToken', () => {
assert.strictEqual(readPath, path.join(TEST_PROJECT_DIR, '.hlx', '.da-token.json'));
});
});
+
+describe('startDaLoginRedirect', () => {
+ it('returns the fixed :9898 callback as redirect_uri, regardless of the return url\'s own port', async () => {
+ // Mock http so no real socket is bound (the fire-and-forget callback server
+ // this starts is never asked for a token in this test).
+ const { startDaLoginRedirect } = await esmock('../../src/content/da-auth.js', {
+ 'node:http': {
+ default: {
+ createServer: () => ({ listen: () => {}, close: () => {}, on: () => {} }),
+ },
+ },
+ });
+ const authUrl = startDaLoginRedirect('http://127.0.0.1:54321/index.html');
+ const parsed = new URL(authUrl);
+ assert.strictEqual(parsed.origin, 'https://ims-na1.adobelogin.com');
+ assert.strictEqual(parsed.searchParams.get('response_type'), 'token');
+ assert.strictEqual(parsed.searchParams.get('client_id'), 'darkalley');
+ assert.strictEqual(parsed.searchParams.get('redirect_uri'), 'http://localhost:9898/callback');
+ });
+});
diff --git a/test/server-utils.test.js b/test/server-utils.test.js
index 9b9ef2f6c..5e2e8f70d 100644
--- a/test/server-utils.test.js
+++ b/test/server-utils.test.js
@@ -348,4 +348,104 @@ describe('Utils Test', () => {
assert.strictEqual(utils.extractMainContent(html), ' \n ');
});
});
+
+ describe('rewriteDaContentImageUrls', () => {
+ it('rewrites content.da.live image src to the site preview domain', () => {
+ const html = '';
+ assert.strictEqual(
+ utils.rewriteDaContentImageUrls(html, 'bar', 'foo'),
+ '',
+ );
+ });
+
+ it('rewrites multiple occurrences', () => {
+ const html = ''
+ + '';
+ assert.strictEqual(
+ utils.rewriteDaContentImageUrls(html, 'bar', 'foo'),
+ ''
+ + '',
+ );
+ });
+
+ it('does not rewrite urls for a different org/site', () => {
+ const html = '';
+ assert.strictEqual(
+ utils.rewriteDaContentImageUrls(html, 'bar', 'foo'),
+ html,
+ );
+ });
+
+ it('returns html unchanged when org or site is missing', () => {
+ const html = '';
+ assert.strictEqual(utils.rewriteDaContentImageUrls(html, '', 'foo'), html);
+ assert.strictEqual(utils.rewriteDaContentImageUrls(html, 'bar', ''), html);
+ });
+ });
+
+ describe('findDaPreviewProbePath', () => {
+ const previewOrigin = 'https://main--foo--bar.preview.da.live';
+
+ it('finds the path of an asset served from the preview origin', () => {
+ const html = ``;
+ assert.strictEqual(utils.findDaPreviewProbePath(html, previewOrigin), '/.index/photo-abc123.png');
+ });
+
+ it('stops at the closing quote', () => {
+ const html = ``;
+ assert.strictEqual(utils.findDaPreviewProbePath(html, previewOrigin), '/.index/photo.png');
+ });
+
+ it('defaults to / when no asset path is found', () => {
+ const html = '