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 = '

no preview links here

'; + assert.strictEqual(utils.findDaPreviewProbePath(html, previewOrigin), '/'); + }); + }); + + describe('injectDaContentAuthScript', () => { + const options = { + previewOrigin: 'https://main--foo--bar.preview.da.live', + probePath: '/.index/photo-abc123.png', + clientId: 'darkalley', + scope: 'AdobeID,openid', + }; + + it('injects the config and auth bootstrap script before ', () => { + const html = 'Test'; + const out = utils.injectDaContentAuthScript(html, options); + assert.ok(out.includes(''; + const out = utils.injectDaContentAuthScript(html, options); + assert.ok(out.includes('