Skip to content

Commit

Permalink
feat: Allow AEM CLI to obtain site token (#2471)
Browse files Browse the repository at this point in the history
  • Loading branch information
andreituicu authored Jan 13, 2025
1 parent 9949ae3 commit a937bc7
Show file tree
Hide file tree
Showing 15 changed files with 691 additions and 6 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ logs
.DS_Store
test-results.xml
.idea/
.hlx
10 changes: 10 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 @@ -61,6 +61,7 @@
"ignore": "7.0.0",
"ini": "5.0.0",
"isomorphic-git": "1.29.0",
"jose": "5.9.6",
"livereload-js": "4.0.2",
"node-fetch": "3.3.2",
"open": "10.1.0",
Expand Down
2 changes: 1 addition & 1 deletion src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export default class CLI {
Object.values(this._commands)
.forEach((cmd) => argv.command(cmd));

logArgs(argv)
await logArgs(argv)
.strictCommands(true)
.scriptName('aem')
.usage('Usage: $0 <command> [options]')
Expand Down
75 changes: 74 additions & 1 deletion src/config/config-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@
* governing permissions and limitations under the License.
*/
import chalk from 'chalk-template';
import fs from 'fs/promises';
import fse from 'fs-extra';
import os from 'os';
import path from 'path';
import semver from 'semver';
import { decodeJwt } from 'jose';
import GitUtils from '../git-utils.js';
import pkgJson from '../package.cjs';

Expand All @@ -21,14 +26,82 @@ 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;
}

const hlxFolder = '.hlx';
const tokenFileName = '.hlx-token';
const tokenFilePath = path.join(hlxFolder, tokenFileName);

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

/*
don't allow writing arbitrary data to the file system.
validate and write only valid site tokens to the file
*/
if (siteToken.startsWith('hlxtst_')) {
try {
decodeJwt(siteToken.substring(7));
} catch (e) {
process.stdout.write(chalk`
{redBright Error:} The provided site token is not a valid JWT, it will not be written to your .hlx-token file.
`);
return;
}
} else {
process.stdout.write(chalk`
{redBright Error:} The provided site token is not a recognised token format, it will not be written to your .hlx-token file.
`);
return;
}

await fs.mkdir(hlxFolder, { recursive: true });

try {
await fs.writeFile(tokenFilePath, JSON.stringify({ siteToken }, null, 2), 'utf8');
} finally {
if (!(await GitUtils.isIgnored(process.cwd(), tokenFilePath))) {
await fs.appendFile('.gitignore', `${os.EOL}${tokenFileName}${os.EOL}`, 'utf8');
process.stdout.write(chalk`
{redBright Warning:} Added your {cyan '.hlx-token'} file to .gitignore, because it now contains your token.
Please make sure the token is not stored in the git repository.
`);
}
}
}

export async function getSiteTokenFromFile() {
if (!(await fse.pathExists(tokenFilePath))) {
return null;
}

try {
const tokenInfo = JSON.parse(await fs.readFile(tokenFilePath, 'utf8'));
return tokenInfo.siteToken;
} catch (e) {
process.stdout.write(chalk`
{redBright Error:} The site token could not be read from the {cyan '.hlx-token'} file.
`);
process.stdout.write(`${e.stack}\n`);
}

return null;
}

/**
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
28 changes: 28 additions & 0 deletions src/server/HelixProject.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,26 @@ export class HelixProject extends BaseProject {
}

withSiteToken(value) {
this.siteToken = value;
this._server.withSiteToken(value);
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 +85,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
102 changes: 101 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 { saveSiteTokenToFile } 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,7 @@ export class HelixServer extends BaseServer {
this._liveReload = null;
this._enableLiveReload = false;
this._app.use(compression());
this._autoLogin = true;
}

withLiveReload(value) {
Expand All @@ -39,6 +46,91 @@ 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);
await saveSiteTokenToFile(siteToken);
this.log.info('Site token received and saved to 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 +189,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 +203,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 +220,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
Loading

0 comments on commit a937bc7

Please sign in to comment.