Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: feat: Allow AEM CLI to obtain site token #2471

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
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 @@
*/
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 writeSiteTokenToDotEnv(siteToken) {
if (!siteToken) {
return;
}

const envFile = fs.openSync('.env', 'a+');
try {
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.ftruncateSync(envFile);
fs.writeFileSync(envFile, env, 'utf8');

Check warning

Code scanning / CodeQL

Network data written to file Medium

Write to file system depends on
Untrusted data
.
} finally {
fs.closeSync(envFile);
}
}

/**
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 { writeSiteTokenToDotEnv } 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 writeSiteTokenToDotEnv(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`)}`,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reminder to switch to the production admin url, before merging.

);
}

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
Loading