Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
108 changes: 108 additions & 0 deletions packages/browser-injectables/src/da-content-auth.js
Original file line number Diff line number Diff line change
@@ -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();
}
});
}());
47 changes: 41 additions & 6 deletions src/content/da-auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`;

Expand Down Expand Up @@ -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) => {
Expand All @@ -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(`<!DOCTYPE html><html><head><title>Logging in...</title></head><body>
<script>
Expand All @@ -93,7 +103,7 @@ function waitForToken() {
const dest = token
? '/token?access_token=' + encodeURIComponent(token) + (expiresIn ? '&expires_in=' + encodeURIComponent(expiresIn) : '')
: '/token?error=' + encodeURIComponent(error || 'unknown');
const loggedInUrl = 'https://tools.aem.live/cli/logged-in';
const loggedInUrl = ${JSON.stringify(finalRedirectUrl || 'https://tools.aem.live/cli/logged-in')};
if (!token) {
fetch(dest);
document.body.innerHTML = '<h2>Login failed.</h2>';
Expand All @@ -102,7 +112,7 @@ function waitForToken() {
document.body.appendChild(errP);
} else {
fetch(dest)
.then(() => { window.location.href = loggedInUrl; })
.then(() => { ${finalizeJs} })
.catch(() => {
document.body.innerHTML = '<h2>Login failed.</h2><p>Could not complete login.</p>';
});
Expand Down Expand Up @@ -176,6 +186,31 @@ async function login(log, projectDir) {

// ─── Public API ──────────────────────────────────────────────────────────────

/**
* Starts the IMS login flow for the `aem up` dev server: unlike {@link getValidToken},
* this doesn't open a browser itself — the caller already has one open (the page that
* needs auth). Returns the IMS authorize URL to redirect that page to; the browser
* comes back to `finalRedirectUrl` with `#access_token=...` once login completes.
*
* The `darkalley` IMS client only allows `http://localhost:9898/callback` as a
* redirect_uri (arbitrary localhost ports/paths are rejected), so the round trip
* always passes through the fixed callback server before returning to the caller.
*
* @param {string} finalRedirectUrl page to send the browser back to after login
* @returns {string} the IMS authorize URL to redirect the browser to
*/
export function startDaLoginRedirect(finalRedirectUrl) {
const params = new URLSearchParams({
response_type: 'token',
client_id: CLIENT_ID,
scope: SCOPE,
redirect_uri: REDIRECT_URI,
});
// fire-and-forget: the callback server delivers the browser to finalRedirectUrl itself
waitForToken(finalRedirectUrl).catch(() => {});
return `${IMS_ORIGIN}/ims/authorize/v2?${params}`;
}

/**
* Returns a valid da.live access token. Triggers browser login if needed.
*
Expand Down
65 changes: 65 additions & 0 deletions src/server/HelixServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/
import crypto from 'crypto';
import express from 'express';
import { rateLimit } from 'express-rate-limit';
import { promisify } from 'util';
import path from 'path';
import { lstat, readFile } from 'fs/promises';
Expand All @@ -22,9 +23,20 @@ import LiveReload from './LiveReload.js';
import { saveSiteTokenToFile } from '../config/config-utils.js';
import { CONTENT_DIR } from '../content/content-shared.js';
import { transformContentMetadataHtml } from '../content/content-metadata-html.js';
import { DA_IMS_CLIENT_ID, DA_IMS_SCOPE, startDaLoginRedirect } from '../content/da-auth.js';

const LOGIN_ROUTE = '/.aem/cli/login';
const LOGIN_ACK_ROUTE = '/.aem/cli/login/ack';
const DA_LOGIN_ROUTE = '/.aem/cli/da-login';

// Local dev-server only, but both routes trigger real side effects (a live server
// bind on :9898, an outbound redirect to IMS) — cap abuse from a runaway page/script.
const daContentAuthRateLimit = rateLimit({
windowMs: 60_000,
limit: 20,
standardHeaders: true,
legacyHeaders: false,
});

// HTML folder candidate extensions, in lookup order.
// First entry takes precedence when multiple candidates exist on disk.
Expand Down Expand Up @@ -140,6 +152,34 @@ export class HelixServer extends BaseServer {
res.status(302).set('location', loginUrl).send('');
}

/**
* Kicks off the IMS login flow for a page that needs the da.live preview cookie
* (see {@link utils.injectDaContentAuthScript}). Redirects to IMS; the browser comes
* back to the `return` url (validated same-origin, to avoid leaking the token via an
* open redirect) with `#access_token=...` once the fixed :9898 callback catches it.
*/
handleDaLogin(req, res) {
if (!req.query.return) {
res.status(400).send('Invalid or missing return url.');
return;
}
const expectedOrigin = `${req.protocol}://${req.get('host')}`;
let target;
try {
const parsed = new URL(req.query.return, expectedOrigin);
if (parsed.origin !== expectedOrigin) {
throw new Error('cross-origin return url');
}
target = parsed.href;
} catch (e) {
res.status(400).send('Invalid or missing return url.');
return;
}
this.log.debug(`Starting da.live login, returning to ${target} when done.`);
const authUrl = startDaLoginRedirect(target);
res.status(302).set('location', authUrl).send('');
}

async handleLoginAck(req, res) {
const CACHE_CONTROL = 'no-store, private, must-revalidate';
const CORS_HEADERS = {
Expand Down Expand Up @@ -466,6 +506,17 @@ export class HelixServer extends BaseServer {
? `${contentFilePath.slice(0, -'.plain.html'.length)}.html`
: contentFilePath;
let htmlContent = await readFile(servedFilePath, 'utf-8');
htmlContent = utils.rewriteDaContentImageUrls(
htmlContent,
this._project.org,
this._project.site,
);
const previewOrigin = this._project.org && this._project.site
? `https://main--${this._project.site}--${this._project.org}.preview.da.live`
: null;
// Content may already reference the preview host directly (not just via
// the content.da.live rewrite above), so gate on presence, not on rewrite.
const needsDaContentAuth = !!previewOrigin && htmlContent.includes(previewOrigin);
if (isPlainFallback) {
if (liveReload) {
liveReload.registerFile(ctx.requestId, servedFilePath);
Expand Down Expand Up @@ -510,6 +561,14 @@ export class HelixServer extends BaseServer {
htmlContent = utils.injectMeta(htmlContent, {
'hlx:proxyUrl': proxyPageUrl.href,
});
if (needsDaContentAuth) {
htmlContent = utils.injectDaContentAuthScript(htmlContent, {
previewOrigin,
probePath: utils.findDaPreviewProbePath(htmlContent, previewOrigin),
clientId: DA_IMS_CLIENT_ID,
scope: DA_IMS_SCOPE,
});
}
if (liveReload) {
htmlContent = utils.injectLiveReloadScript(htmlContent, this);
liveReload.registerFile(ctx.requestId, contentFilePath);
Expand Down Expand Up @@ -613,6 +672,12 @@ export class HelixServer extends BaseServer {
this.app.get(LOGIN_ACK_ROUTE, asyncHandler(this.handleLoginAck.bind(this)));
this.app.post(LOGIN_ACK_ROUTE, express.json(), asyncHandler(this.handleLoginAck.bind(this)));
this.app.options(LOGIN_ACK_ROUTE, asyncHandler(this.handleLoginAck.bind(this)));
this.app.get(
'/__internal__/da-content-auth.js',
daContentAuthRateLimit,
(req, res) => utils.serveDaContentAuthScript(res),
);
this.app.get(DA_LOGIN_ROUTE, daContentAuthRateLimit, this.handleDaLogin.bind(this));

// Add HTML folder handler before the general proxy handler
if (this._htmlFolder) {
Expand Down
Loading
Loading