diff --git a/.gitignore b/.gitignore index 0334e340d..2068c8c4f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ logs .DS_Store test-results.xml .idea/ +.hlx diff --git a/package-lock.json b/package-lock.json index 22dccbe92..6ee468e7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,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", @@ -6289,6 +6290,15 @@ "node": ">= 0.6.0" } }, + "node_modules/jose": { + "version": "5.9.6", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz", + "integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index fb000f752..b3443f795 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/cli.js b/src/cli.js index 2e76de7ba..64ae1f20d 100755 --- a/src/cli.js +++ b/src/cli.js @@ -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 [options]') diff --git a/src/config/config-utils.js b/src/config/config-utils.js index 7f95cde05..300a44747 100644 --- a/src/config/config-utils.js +++ b/src/config/config-utils.js @@ -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'; @@ -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; } /** diff --git a/src/server/HeadHtmlSupport.js b/src/server/HeadHtmlSupport.js index 975d66a6e..9fd7bde41 100644 --- a/src/server/HeadHtmlSupport.js +++ b/src/server/HeadHtmlSupport.js @@ -125,6 +125,10 @@ export default class HeadHtmlSupport { } } + setSiteToken(siteToken) { + this.siteToken = siteToken; + } + invalidateLocal() { this.localStatus = 0; } diff --git a/src/server/HelixProject.js b/src/server/HelixProject.js index 5818bf028..bf587bb4b 100644 --- a/src/server/HelixProject.js +++ b/src/server/HelixProject.js @@ -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; @@ -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; } diff --git a/src/server/HelixServer.js b/src/server/HelixServer.js index 4ef5ea291..ea4163be6 100644 --- a/src/server/HelixServer.js +++ b/src/server/HelixServer.js @@ -9,6 +9,8 @@ * 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'; @@ -16,6 +18,10 @@ 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 { /** @@ -27,6 +33,7 @@ export class HelixServer extends BaseServer { this._liveReload = null; this._enableLiveReload = false; this._app.use(compression()); + this._autoLogin = true; } withLiveReload(value) { @@ -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 @@ -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); @@ -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}`); @@ -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); diff --git a/src/server/utils.js b/src/server/utils.js index 55b30f59e..a0ea5c780 100644 --- a/src/server/utils.js +++ b/src/server/utils.js @@ -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 = ` -
${textBody}
+ +
${textBody}
+

Click here to login.

+ `; respHeaders['content-type'] = 'text/html'; diff --git a/src/up.cmd.js b/src/up.cmd.js index f55110357..b5941a37b 100644 --- a/src/up.cmd.js +++ b/src/up.cmd.js @@ -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.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 { @@ -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 diff --git a/src/up.js b/src/up.js index 46b06ae48..f3842f0a6 100644 --- a/src/up.js +++ b/src/up.js @@ -11,6 +11,7 @@ */ import path from 'path'; import { getOrCreateLogger } from './log-common.js'; +import { getSiteTokenFromFile } from './config/config-utils.js'; export default function up() { let executor; @@ -121,7 +122,7 @@ export default function up() { .withOpen(path.basename(argv.$0) === 'aem' ? argv.open : false) .withTLS(argv.tlsKey, argv.tlsCert) .withLiveReload(argv.livereload) - .withSiteToken(argv.siteToken) + .withSiteToken(argv.siteToken || await getSiteTokenFromFile()) .withUrl(argv.url) .withPrintIndex(argv.printIndex) .withAllowInsecure(argv.allowInsecure) diff --git a/test/config-utils.test.js b/test/config-utils.test.js new file mode 100644 index 000000000..2ee6c6c71 --- /dev/null +++ b/test/config-utils.test.js @@ -0,0 +1,58 @@ +/* + * Copyright 2025 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. + */ + +/* eslint-env mocha */ +import assert from 'assert'; +import fs from 'fs/promises'; +import fse from 'fs-extra'; +import { UnsecuredJWT } from 'jose'; + +import path from 'path'; +import { getSiteTokenFromFile, saveSiteTokenToFile } from '../src/config/config-utils.js'; + +describe('.hlx-token', () => { + afterEach(async () => { + await fs.rm(path.resolve(__rootdir, '.hlx'), { force: true, recursive: true }); + }); + + it('saves token to file', async () => { + const mockToken = `hlxtst_${new UnsecuredJWT({ email: 'test@example.com' }).encode()}`; + await saveSiteTokenToFile(mockToken); + + assert.strictEqual(await getSiteTokenFromFile(), mockToken); + }); + + it('does not save invalid token to file', async () => { + await saveSiteTokenToFile('invalid-token'); + assert.ok(!(await fse.pathExists(path.resolve(__rootdir, '.hlx', '.hlx-token')))); + }); + + it('does not save invalid non JWT TST token to file', async () => { + await saveSiteTokenToFile('hlxtst_invalid-token'); + assert.ok(!(await fse.pathExists(path.resolve(__rootdir, '.hlx', '.hlx-token')))); + }); + + it('does not save null token to file', async () => { + await saveSiteTokenToFile(null); + assert.ok(!(await fse.pathExists(path.resolve(__rootdir, '.hlx', '.hlx-token')))); + }); + + it('does not throw if token file does not exist, returns null', async () => { + assert.strictEqual(await getSiteTokenFromFile(), null); + }); + + it('invalid file format does not throw, returns null', async () => { + await fs.mkdir(path.resolve(__rootdir, '.hlx')); + await fs.writeFile(path.resolve(__rootdir, '.hlx', '.hlx-token'), 'this-is-a-token'); + assert.strictEqual(await getSiteTokenFromFile(), null); + }); +}); diff --git a/test/server.test.js b/test/server.test.js index e734b8031..fc1faf88b 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -14,7 +14,9 @@ /* eslint-disable no-underscore-dangle */ import os from 'os'; import assert from 'assert'; +import fs from 'fs/promises'; import fse from 'fs-extra'; +import { UnsecuredJWT } from 'jose'; import path from 'path'; import { h1NoCache } from '@adobe/fetch'; import * as http from 'node:http'; @@ -23,6 +25,7 @@ import { Nock, assertHttp, createTestRoot, setupProject, rawGet, } from './utils.js'; import { getFetch } from '../src/fetch-utils.js'; +import { getSiteTokenFromFile } from '../src/config/config-utils.js'; describe('Helix Server', () => { let nock; @@ -440,4 +443,236 @@ describe('Helix Server', () => { await project.stop(); } }); + + it('starts login', async () => { + const cwd = await setupProject(path.join(__rootdir, 'test', 'fixtures', 'project'), testRoot); + const project = new HelixProject() + .withCwd(cwd) + .withHttpPort(3000) + .withProxyUrl('http://main--foo--bar.aem.page') + .withSiteLoginUrl('https://admin.hlx.page/login/bar/foo/main?client_id=aem-cli&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2F.aem%2Fcli%2Flogin%2Fack'); + + await project.init(); + project.log.level = 'silly'; + + try { + await project.start(); + const resp = await getFetch()(`http://127.0.0.1:${project.server.port}/.aem/cli/login`, { + cache: 'no-store', + redirect: 'manual', + }); + assert.strictEqual(resp.status, 302); + assert.ok( + resp.headers.get('location').startsWith('https://admin.hlx.page/login/bar/foo/main?client_id=aem-cli&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2F.aem%2Fcli%2Flogin%2Fack&state='), + ); + } finally { + await project.stop(); + } + }); + + it('starts auto login when receiving 401 during navigation', async () => { + const cwd = await setupProject(path.join(__rootdir, 'test', 'fixtures', 'project'), testRoot); + const project = new HelixProject() + .withCwd(cwd) + .withHttpPort(3000) + .withProxyUrl('http://main--foo--bar.aem.page') + .withSiteLoginUrl('https://admin.hlx.page/login/bar/foo/main?client_id=aem-cli&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2F.aem%2Fcli%2Flogin%2Fack'); + + await project.init(); + project.log.level = 'silly'; + + nock('http://main--foo--bar.aem.page').get('/').reply(401, 'Unauthorized'); + + try { + await project.start(); + const resp = await getFetch()(`http://127.0.0.1:${project.server.port}/`, { + cache: 'no-store', + redirect: 'manual', + // emulate browser navigation + headers: { + 'sec-fetch-mode': 'navigate', + 'sec-fetch-dest': 'document', + }, + }); + assert.strictEqual(resp.status, 302); + assert.strictEqual(resp.headers.get('location'), '/.aem/cli/login'); + } finally { + await project.stop(); + } + }); + + it('receives site token, saves it and uses it', async () => { + const siteToken = `hlxtst_${new UnsecuredJWT({ email: 'test@example.com' }).encode()}`; + + const cwd = await setupProject(path.join(__rootdir, 'test', 'fixtures', 'project'), testRoot); + const project = new HelixProject() + .withCwd(cwd) + .withHttpPort(3000) + .withProxyUrl('http://main--foo--bar.aem.page') + .withSiteLoginUrl('https://admin.hlx.page/login/bar/foo/main?client_id=aem-cli&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2F.aem%2Fcli%2Flogin%2Fack'); + + await project.init(); + project.log.level = 'silly'; + + nock('http://main--foo--bar.aem.page') + .get('/') + .reply(function fn() { + assert.strictEqual(this.req.headers.authorization, `token ${siteToken}`); + return [200, 'hello', { 'content-type': 'text/html' }]; + }) + .get('/head.html') + .reply(function fn() { + assert.strictEqual(this.req.headers.authorization, `token ${siteToken}`); + return [200, '