Skip to content

Commit a937bc7

Browse files
authored
feat: Allow AEM CLI to obtain site token (#2471)
1 parent 9949ae3 commit a937bc7

File tree

15 files changed

+691
-6
lines changed

15 files changed

+691
-6
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ logs
88
.DS_Store
99
test-results.xml
1010
.idea/
11+
.hlx

package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
"ignore": "7.0.0",
6262
"ini": "5.0.0",
6363
"isomorphic-git": "1.29.0",
64+
"jose": "5.9.6",
6465
"livereload-js": "4.0.2",
6566
"node-fetch": "3.3.2",
6667
"open": "10.1.0",

src/cli.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ export default class CLI {
121121
Object.values(this._commands)
122122
.forEach((cmd) => argv.command(cmd));
123123

124-
logArgs(argv)
124+
await logArgs(argv)
125125
.strictCommands(true)
126126
.scriptName('aem')
127127
.usage('Usage: $0 <command> [options]')

src/config/config-utils.js

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212
import chalk from 'chalk-template';
13+
import fs from 'fs/promises';
14+
import fse from 'fs-extra';
15+
import os from 'os';
16+
import path from 'path';
1317
import semver from 'semver';
18+
import { decodeJwt } from 'jose';
1419
import GitUtils from '../git-utils.js';
1520
import pkgJson from '../package.cjs';
1621

@@ -21,14 +26,82 @@ import pkgJson from '../package.cjs';
2126
*/
2227
export async function validateDotEnv(dir = process.cwd()) {
2328
if (await GitUtils.isIgnored(dir, '.env')) {
24-
return;
29+
return true;
2530
}
2631
process.stdout.write(chalk`
2732
{yellowBright Warning:} Your {cyan '.env'} file is currently not ignored by git.
2833
This is typically not good because it might contain secrets
2934
which should never be stored in the git repository.
35+
`);
36+
return false;
37+
}
38+
39+
const hlxFolder = '.hlx';
40+
const tokenFileName = '.hlx-token';
41+
const tokenFilePath = path.join(hlxFolder, tokenFileName);
42+
43+
/**
44+
* Writes the site token to the .hlx/.hlx-token file.
45+
* Checks if the .hlx file is ignored by git and adds it to the .gitignore file if necessary.
46+
*
47+
* @param {string} siteToken
48+
*/
49+
export async function saveSiteTokenToFile(siteToken) {
50+
if (!siteToken) {
51+
return;
52+
}
53+
54+
/*
55+
don't allow writing arbitrary data to the file system.
56+
validate and write only valid site tokens to the file
57+
*/
58+
if (siteToken.startsWith('hlxtst_')) {
59+
try {
60+
decodeJwt(siteToken.substring(7));
61+
} catch (e) {
62+
process.stdout.write(chalk`
63+
{redBright Error:} The provided site token is not a valid JWT, it will not be written to your .hlx-token file.
64+
`);
65+
return;
66+
}
67+
} else {
68+
process.stdout.write(chalk`
69+
{redBright Error:} The provided site token is not a recognised token format, it will not be written to your .hlx-token file.
70+
`);
71+
return;
72+
}
73+
74+
await fs.mkdir(hlxFolder, { recursive: true });
75+
76+
try {
77+
await fs.writeFile(tokenFilePath, JSON.stringify({ siteToken }, null, 2), 'utf8');
78+
} finally {
79+
if (!(await GitUtils.isIgnored(process.cwd(), tokenFilePath))) {
80+
await fs.appendFile('.gitignore', `${os.EOL}${tokenFileName}${os.EOL}`, 'utf8');
81+
process.stdout.write(chalk`
82+
{redBright Warning:} Added your {cyan '.hlx-token'} file to .gitignore, because it now contains your token.
83+
Please make sure the token is not stored in the git repository.
84+
`);
85+
}
86+
}
87+
}
88+
89+
export async function getSiteTokenFromFile() {
90+
if (!(await fse.pathExists(tokenFilePath))) {
91+
return null;
92+
}
3093

94+
try {
95+
const tokenInfo = JSON.parse(await fs.readFile(tokenFilePath, 'utf8'));
96+
return tokenInfo.siteToken;
97+
} catch (e) {
98+
process.stdout.write(chalk`
99+
{redBright Error:} The site token could not be read from the {cyan '.hlx-token'} file.
31100
`);
101+
process.stdout.write(`${e.stack}\n`);
102+
}
103+
104+
return null;
32105
}
33106

34107
/**

src/server/HeadHtmlSupport.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,10 @@ export default class HeadHtmlSupport {
125125
}
126126
}
127127

128+
setSiteToken(siteToken) {
129+
this.siteToken = siteToken;
130+
}
131+
128132
invalidateLocal() {
129133
this.localStatus = 0;
130134
}

src/server/HelixProject.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,26 @@ export class HelixProject extends BaseProject {
3333
}
3434

3535
withSiteToken(value) {
36+
this.siteToken = value;
3637
this._server.withSiteToken(value);
3738
return this;
3839
}
3940

41+
withSite(site) {
42+
this._site = site;
43+
return this;
44+
}
45+
46+
withOrg(org) {
47+
this._org = org;
48+
return this;
49+
}
50+
51+
withSiteLoginUrl(value) {
52+
this._siteLoginUrl = value;
53+
return this;
54+
}
55+
4056
withProxyUrl(value) {
4157
this._proxyUrl = value;
4258
return this;
@@ -69,6 +85,18 @@ export class HelixProject extends BaseProject {
6985
return this._server._liveReload;
7086
}
7187

88+
get org() {
89+
return this._org;
90+
}
91+
92+
get site() {
93+
return this._site;
94+
}
95+
96+
get siteLoginUrl() {
97+
return this._siteLoginUrl;
98+
}
99+
72100
get file404html() {
73101
return this._file404html;
74102
}

src/server/HelixServer.js

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,19 @@
99
* OF ANY KIND, either express or implied. See the License for the specific language
1010
* governing permissions and limitations under the License.
1111
*/
12+
import crypto from 'crypto';
13+
import express from 'express';
1214
import { promisify } from 'util';
1315
import path from 'path';
1416
import compression from 'compression';
1517
import utils from './utils.js';
1618
import RequestContext from './RequestContext.js';
1719
import { asyncHandler, BaseServer } from './BaseServer.js';
1820
import LiveReload from './LiveReload.js';
21+
import { saveSiteTokenToFile } from '../config/config-utils.js';
22+
23+
const LOGIN_ROUTE = '/.aem/cli/login';
24+
const LOGIN_ACK_ROUTE = '/.aem/cli/login/ack';
1925

2026
export class HelixServer extends BaseServer {
2127
/**
@@ -27,6 +33,7 @@ export class HelixServer extends BaseServer {
2733
this._liveReload = null;
2834
this._enableLiveReload = false;
2935
this._app.use(compression());
36+
this._autoLogin = true;
3037
}
3138

3239
withLiveReload(value) {
@@ -39,6 +46,91 @@ export class HelixServer extends BaseServer {
3946
return this;
4047
}
4148

49+
async handleLogin(req, res) {
50+
// disable autologin if login was called at least once
51+
this._autoLogin = false;
52+
// clear any previous login errors
53+
delete this.loginError;
54+
55+
if (!this._project.siteLoginUrl) {
56+
res.status(404).send('Login not supported. Could not extract site and org information.');
57+
return;
58+
}
59+
60+
this.log.info(`Starting login process for : ${this._project.org}/${this._project.site}. Redirecting...`);
61+
this._loginState = crypto.randomUUID();
62+
const loginUrl = `${this._project.siteLoginUrl}&state=${this._loginState}`;
63+
res.status(302).set('location', loginUrl).send('');
64+
}
65+
66+
async handleLoginAck(req, res) {
67+
const CACHE_CONTROL = 'no-store, private, must-revalidate';
68+
const CORS_HEADERS = {
69+
'access-control-allow-methods': 'POST, OPTIONS',
70+
'access-control-allow-headers': 'content-type',
71+
};
72+
73+
const { origin } = req.headers;
74+
if (['https://admin.hlx.page', 'https://admin-ci.hlx.page'].includes(origin)) {
75+
CORS_HEADERS['access-control-allow-origin'] = origin;
76+
}
77+
78+
if (req.method === 'OPTIONS') {
79+
res.status(200).set(CORS_HEADERS).send('');
80+
return;
81+
}
82+
83+
if (req.method === 'POST') {
84+
const { state, siteToken } = req.body;
85+
try {
86+
if (!this._loginState || this._loginState !== state) {
87+
this.loginError = { message: 'Login Failed: We received an invalid state.' };
88+
this.log.warn('State mismatch. Discarding site token.');
89+
res.status(400)
90+
.set(CORS_HEADERS)
91+
.set('cache-control', CACHE_CONTROL)
92+
.send('Invalid state');
93+
return;
94+
}
95+
96+
if (!siteToken) {
97+
this.loginError = { message: 'Login Failed: Missing site token.' };
98+
res.status(400)
99+
.set('cache-control', CACHE_CONTROL)
100+
.set(CORS_HEADERS)
101+
.send('Missing site token');
102+
return;
103+
}
104+
105+
this.withSiteToken(siteToken);
106+
this._project.headHtml.setSiteToken(siteToken);
107+
await saveSiteTokenToFile(siteToken);
108+
this.log.info('Site token received and saved to file.');
109+
110+
res.status(200)
111+
.set('cache-control', CACHE_CONTROL)
112+
.set(CORS_HEADERS)
113+
.send('Login successful.');
114+
return;
115+
} finally {
116+
delete this._loginState;
117+
}
118+
}
119+
120+
if (this.loginError) {
121+
res.status(400)
122+
.set('cache-control', CACHE_CONTROL)
123+
.send(this.loginError.message);
124+
delete this.loginError;
125+
return;
126+
}
127+
128+
res.status(302)
129+
.set('cache-control', CACHE_CONTROL)
130+
.set('location', '/')
131+
.send('');
132+
}
133+
42134
/**
43135
* Proxy Mode route handler
44136
* @param {Express.Request} req request
@@ -97,8 +189,8 @@ export class HelixServer extends BaseServer {
97189
}
98190
}
99191

100-
// use proxy
101192
try {
193+
// use proxy
102194
const url = new URL(ctx.url, proxyUrl);
103195
for (const [key, value] of proxyUrl.searchParams.entries()) {
104196
url.searchParams.append(key, value);
@@ -111,6 +203,8 @@ export class HelixServer extends BaseServer {
111203
cacheDirectory: this._project.cacheDirectory,
112204
file404html: this._project.file404html,
113205
siteToken: this._siteToken,
206+
loginPath: LOGIN_ROUTE,
207+
autoLogin: this._autoLogin,
114208
});
115209
} catch (err) {
116210
log.error(`${pfx}failed to proxy AEM request ${ctx.path}: ${err.message}`);
@@ -126,6 +220,12 @@ export class HelixServer extends BaseServer {
126220
this._liveReload = new LiveReload(this.log);
127221
await this._liveReload.init(this.app, this._server);
128222
}
223+
224+
this.app.get(LOGIN_ROUTE, asyncHandler(this.handleLogin.bind(this)));
225+
this.app.get(LOGIN_ACK_ROUTE, asyncHandler(this.handleLoginAck.bind(this)));
226+
this.app.post(LOGIN_ACK_ROUTE, express.json(), asyncHandler(this.handleLoginAck.bind(this)));
227+
this.app.options(LOGIN_ACK_ROUTE, asyncHandler(this.handleLoginAck.bind(this)));
228+
129229
const handler = asyncHandler(this.handleProxyModeRequest.bind(this));
130230
this.app.get('*', handler);
131231
this.app.post('*', handler);

src/server/utils.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -355,11 +355,24 @@ window.LiveReloadOptions = {
355355
.send(textBody);
356356
return;
357357
}
358-
if (ret.status === 401) {
358+
if (ret.status === 401 || ret.status === 403) {
359+
const reqHeaders = req.headers;
360+
if (opts.autoLogin && opts.loginPath
361+
&& reqHeaders?.['sec-fetch-dest'] === 'document'
362+
&& reqHeaders?.['sec-fetch-mode'] === 'navigate'
363+
) {
364+
// try to automatically login
365+
res.set('location', opts.loginPath).status(302).send();
366+
return;
367+
}
368+
359369
let textBody = await ret.text();
360370
textBody = `<html>
361371
<head><meta property="hlx:proxyUrl" content="${url}"></head>
362-
<body><pre>${textBody}</pre></body>
372+
<body>
373+
<pre>${textBody}</pre>
374+
<p>Click <b><a href="${opts.loginPath}">here</a></b> to login.</p>
375+
</body>
363376
</html>
364377
`;
365378
respHeaders['content-type'] = 'text/html';

0 commit comments

Comments
 (0)