Skip to content

Commit

Permalink
feat: Allow AEM CLI to obtain site token
Browse files Browse the repository at this point in the history
  • Loading branch information
andreituicu committed Dec 22, 2024
1 parent 8a6ee27 commit a8fa98d
Show file tree
Hide file tree
Showing 8 changed files with 562 additions and 4 deletions.
38 changes: 37 additions & 1 deletion src/config/config-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
* governing permissions and limitations under the License.
*/
import chalk from 'chalk-template';
import fs from 'fs';
import semver from 'semver';
import GitUtils from '../git-utils.js';
import pkgJson from '../package.cjs';
Expand All @@ -21,14 +22,49 @@ import pkgJson from '../package.cjs';
*/
export async function validateDotEnv(dir = process.cwd()) {
if (await GitUtils.isIgnored(dir, '.env')) {
return;
return true;
}
process.stdout.write(chalk`
{yellowBright Warning:} Your {cyan '.env'} file is currently not ignored by git.
This is typically not good because it might contain secrets
which should never be stored in the git repository.
`);
return false;
}

/**
* Writes the site token to the .env file.
* Checks if the .env file is ignored by git and adds it to the .gitignore file if necessary.
*
* @param {string} siteToken
*/
export async function writeSiteTokenToEnv(siteToken) {
if (!siteToken) {
return;
}

const envFile = '.env';
if (!fs.existsSync('.env')) {
// make sure .env exists, so we can check if it is ignored by git
fs.writeFileSync(envFile, '', 'utf8');

Check failure

Code scanning / CodeQL

Potential file system race condition High

The file may have changed since it
was checked
.
}

if (!(await validateDotEnv(process.cwd()))) {
fs.appendFileSync('.gitignore', '\n.env\n', 'utf8');
process.stdout.write(chalk`
{redBright Warning:} Added your {cyan '.env'} file to .gitignore, because it now contains your site token.
Please make sure the site token is not stored in the git repository.
`);
}

let env = fs.readFileSync(envFile, 'utf8');
if (env.includes('AEM_SITE_TOKEN')) {
env = env.replace(/AEM_SITE_TOKEN=.*/, `AEM_SITE_TOKEN=${siteToken}`);
} else {
env += `\nAEM_SITE_TOKEN=${siteToken}\n`;
}

fs.writeFileSync(envFile, env, 'utf8');

Check failure

Code scanning / CodeQL

Potential file system race condition High

The file may have changed since it
was checked
.

Check warning

Code scanning / CodeQL

Network data written to file Medium

Write to file system depends on
Untrusted data
.
}

/**
Expand Down
4 changes: 4 additions & 0 deletions src/server/HeadHtmlSupport.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,10 @@ export default class HeadHtmlSupport {
}
}

setSiteToken(siteToken) {
this.siteToken = siteToken;
}

invalidateLocal() {
this.localStatus = 0;
}
Expand Down
27 changes: 27 additions & 0 deletions src/server/HelixProject.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,21 @@ export class HelixProject extends BaseProject {
return this;
}

withSite(site) {
this._site = site;
return this;
}

withOrg(org) {
this._org = org;
return this;
}

withSiteLoginUrl(value) {
this._siteLoginUrl = value;
return this;
}

withProxyUrl(value) {
this._proxyUrl = value;
return this;
Expand Down Expand Up @@ -69,6 +84,18 @@ export class HelixProject extends BaseProject {
return this._server._liveReload;
}

get org() {
return this._org;
}

get site() {
return this._site;
}

get siteLoginUrl() {
return this._siteLoginUrl;
}

get file404html() {
return this._file404html;
}
Expand Down
105 changes: 104 additions & 1 deletion src/server/HelixServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,19 @@
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import crypto from 'crypto';
import express from 'express';
import { promisify } from 'util';
import path from 'path';
import compression from 'compression';
import utils from './utils.js';
import RequestContext from './RequestContext.js';
import { asyncHandler, BaseServer } from './BaseServer.js';
import LiveReload from './LiveReload.js';
import { writeSiteTokenToEnv } from '../config/config-utils.js';

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

export class HelixServer extends BaseServer {
/**
Expand All @@ -27,6 +33,8 @@ export class HelixServer extends BaseServer {
this._liveReload = null;
this._enableLiveReload = false;
this._app.use(compression());
this._autoLogin = true;
this._saveSiteTokenToDotEnv = true;
}

withLiveReload(value) {
Expand All @@ -39,6 +47,93 @@ export class HelixServer extends BaseServer {
return this;
}

async handleLogin(req, res) {
// disable autologin if login was called at least once
this._autoLogin = false;
// clear any previous login errors
delete this.loginError;

if (!this._project.siteLoginUrl) {
res.status(404).send('Login not supported. Could not extract site and org information.');
return;
}

this.log.info(`Starting login process for : ${this._project.org}/${this._project.site}. Redirecting...`);
this._loginState = crypto.randomUUID();
const loginUrl = `${this._project.siteLoginUrl}&state=${this._loginState}`;
res.status(302).set('location', loginUrl).send('');
}

async handleLoginAck(req, res) {
const CACHE_CONTROL = 'no-store, private, must-revalidate';
const CORS_HEADERS = {
'access-control-allow-methods': 'POST, OPTIONS',
'access-control-allow-headers': 'content-type',
};

const { origin } = req.headers;
if (['https://admin.hlx.page', 'https://admin-ci.hlx.page'].includes(origin)) {
CORS_HEADERS['access-control-allow-origin'] = origin;
}

if (req.method === 'OPTIONS') {
res.status(200).set(CORS_HEADERS).send('');
return;
}

if (req.method === 'POST') {
const { state, siteToken } = req.body;
try {
if (!this._loginState || this._loginState !== state) {
this.loginError = { message: 'Login Failed: We received an invalid state.' };
this.log.warn('State mismatch. Discarding site token.');
res.status(400)
.set(CORS_HEADERS)
.set('cache-control', CACHE_CONTROL)
.send('Invalid state');
return;
}

if (!siteToken) {
this.loginError = { message: 'Login Failed: Missing site token.' };
res.status(400)
.set('cache-control', CACHE_CONTROL)
.set(CORS_HEADERS)
.send('Missing site token');
return;
}

this.withSiteToken(siteToken);
this._project.headHtml.setSiteToken(siteToken);
if (this._saveSiteTokenToDotEnv) {
await writeSiteTokenToEnv(siteToken);
}
this.log.info('Site token received and saved to .env file.');

res.status(200)
.set('cache-control', CACHE_CONTROL)
.set(CORS_HEADERS)
.send('Login successful.');
return;
} finally {
delete this._loginState;
}
}

if (this.loginError) {
res.status(400)
.set('cache-control', CACHE_CONTROL)
.send(this.loginError.message);
delete this.loginError;
return;
}

res.status(302)
.set('cache-control', CACHE_CONTROL)
.set('location', '/')
.send('');
}

/**
* Proxy Mode route handler
* @param {Express.Request} req request
Expand Down Expand Up @@ -97,8 +192,8 @@ export class HelixServer extends BaseServer {
}
}

// use proxy
try {
// use proxy
const url = new URL(ctx.url, proxyUrl);
for (const [key, value] of proxyUrl.searchParams.entries()) {
url.searchParams.append(key, value);
Expand All @@ -111,6 +206,8 @@ export class HelixServer extends BaseServer {
cacheDirectory: this._project.cacheDirectory,
file404html: this._project.file404html,
siteToken: this._siteToken,
loginPath: LOGIN_ROUTE,
autoLogin: this._autoLogin,
});
} catch (err) {
log.error(`${pfx}failed to proxy AEM request ${ctx.path}: ${err.message}`);
Expand All @@ -126,6 +223,12 @@ export class HelixServer extends BaseServer {
this._liveReload = new LiveReload(this.log);
await this._liveReload.init(this.app, this._server);
}

this.app.get(LOGIN_ROUTE, asyncHandler(this.handleLogin.bind(this)));
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)));

const handler = asyncHandler(this.handleProxyModeRequest.bind(this));
this.app.get('*', handler);
this.app.post('*', handler);
Expand Down
17 changes: 15 additions & 2 deletions src/server/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -355,11 +355,24 @@ window.LiveReloadOptions = {
.send(textBody);
return;
}
if (ret.status === 401) {
if (ret.status === 401 || ret.status === 403) {
const reqHeaders = req.headers;
if (opts.autoLogin && opts.loginPath
&& reqHeaders?.['sec-fetch-dest'] === 'document'
&& reqHeaders?.['sec-fetch-mode'] === 'navigate'
) {
// try to automatically login
res.set('location', opts.loginPath).status(302).send();
return;
}

let textBody = await ret.text();
textBody = `<html>
<head><meta property="hlx:proxyUrl" content="${url}"></head>
<body><pre>${textBody}</pre></body>
<body>
<pre>${textBody}</pre>
<p>Click <b><a href="${opts.loginPath}">here</a></b> to login.</p>
</body>
</html>
`;
respHeaders['content-type'] = 'text/html';
Expand Down
26 changes: 26 additions & 0 deletions src/up.cmd.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,17 @@ export default class UpCommand extends AbstractServerCommand {
.replace(/\{\{repo\}\}/, this._gitUrl.repo);
}
this._project.withProxyUrl(this._url);
const { site, org } = this.extractSiteAndOrg(this._url);
if (site && org) {
this._project
.withSite(site)
.withOrg(org)
.withSiteLoginUrl(
// TODO switch to production URL
`https://admin-ci.hlx.page/login/${org}/${site}/main?client_id=aem-cli&redirect_uri=${encodeURIComponent(`http://localhost:${this._httpPort}/.aem/cli/login/ack`)}`,
);
}

await this.initServerOptions();

try {
Expand All @@ -113,6 +124,21 @@ export default class UpCommand extends AbstractServerCommand {
});
}

// eslint-disable-next-line class-methods-use-this
extractSiteAndOrg(url) {
const { hostname } = new URL(url);
const parts = hostname.split('.');
const errorResult = { site: null, org: null };
if (parts.length < 3) {
return errorResult;
}
if (!['live', 'page'].includes(parts[2]) || !['hlx', 'aem'].includes(parts[1])) {
return errorResult;
}
const [, site, org] = parts[0].split('--');
return { site, org };
}

async verifyUrl(gitUrl, ref) {
// check if the site is on helix5
// https://admin.hlx.page/sidekick/adobe/www-aem-live/main/config.json
Expand Down
Loading

0 comments on commit a8fa98d

Please sign in to comment.