Skip to content
Merged
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
8 changes: 4 additions & 4 deletions lib/stencil-start.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,16 +91,15 @@ class StencilStart {
'Makeswift preview requires an access token. Add accessToken to your .stencil file.'.red,
);
}
const previewSecret = makeswiftPreview ? this._previewSessionClient.createSecret() : null;
await this.startLocalServer(cliOptions, updatedStencilConfig);
if (makeswiftPreview) {
await this.startMakeswiftPreview({
rendererPort: this.getRendererPort(browserSyncPort),
storeUrl: previewStoreUrl,
accessToken: updatedStencilConfig.accessToken,
secret: previewSecret,
secret: this._previewSessionClient.createSecret(),
});
}
await this.startLocalServer(cliOptions, updatedStencilConfig, this._previewSession?.secret);
this._logger.log(this.getStartUpInfo(updatedStencilConfig));
await this.startBrowserSync(cliOptions, browserSyncPort);
}
Expand Down Expand Up @@ -275,8 +274,9 @@ class StencilStart {
* @param {Object} stencilConfig
* @return {Promise<any>}
*/
async startLocalServer(cliOptions, stencilConfig) {
async startLocalServer(cliOptions, stencilConfig, previewSecret) {
return Server.create({
previewSecret,
dotStencilFile: stencilConfig,
variationIndex: this._themeConfigManager.variationIndex || 0,
useCache: cliOptions.cache,
Expand Down
8 changes: 7 additions & 1 deletion lib/stencil-start.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ describe('StencilStart unit tests', () => {
expect(browserSync.init).toHaveBeenCalled();
});

it('should register the session on the store host and keep it in memory', async () => {
it('should register the session and require its secret on the renderer', async () => {
const { previewSessionClient, instance } = await runStart();
expect(previewSessionClient.register).toHaveBeenCalledWith({
storeUrl: 'https://www.example.com',
Expand All @@ -293,6 +293,11 @@ describe('StencilStart unit tests', () => {
secret: previewSecret,
...registeredSession,
});
expect(instance.startLocalServer).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
previewSecret,
);
});

it('should stop the tunnel and continue without preview when registration fails', async () => {
Expand All @@ -306,6 +311,7 @@ describe('StencilStart unit tests', () => {
const { stop } = await tunnelManager.start.mock.results[0].value;
expect(stop).toHaveBeenCalled();
expect(instance.previewSession).toBeNull();
expect(instance.startLocalServer.mock.calls[0][2]).toBeUndefined();
expect(output).toContain('Makeswift preview is not available. Could not register');
expect(browserSync.init).toHaveBeenCalled();
});
Expand Down
8 changes: 7 additions & 1 deletion server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import 'colors';

const getDirname = dirname(fileURLToPath(import.meta.url));

function buildManifest(srcManifest, options) {
export function buildManifest(srcManifest, options) {
const resManifest = _.cloneDeep(srcManifest);
const pluginsByName = resManifest.register.plugins;
const parsedSecureUrl = new URL(options.dotStencilFile.storeUrl); // The url to a secure page (prompted as login page)
Expand Down Expand Up @@ -42,6 +42,12 @@ function buildManifest(srcManifest, options) {
(pluginsArr, opts, plugin) => [...pluginsArr, { plugin, options: opts }],
[],
);
if (options.previewSecret) {
resManifest.register.plugins.unshift({
plugin: './plugins/preview-auth/preview-auth.module.js',
options: { secret: options.previewSecret },
});
}
return resManifest;
}
async function create(options) {
Expand Down
24 changes: 24 additions & 0 deletions server/index.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { buildManifest } from './index.js';
import * as manifest from './manifest.js';

describe('buildManifest', () => {
const dotStencilFile = {
storeUrl: 'https://store-abc123.mybigcommerce.com',
normalStoreUrl: 'https://www.example.com',
port: 3000,
};
const PREVIEW_AUTH = './plugins/preview-auth/preview-auth.module.js';
const plugins = (options) =>
buildManifest(manifest.get('/'), { dotStencilFile, ...options }).register.plugins;

it('should register PreviewAuth first, with the preview secret', () => {
expect(plugins({ previewSecret: 'secret' })[0]).toEqual({
plugin: PREVIEW_AUTH,
options: { secret: 'secret' },
});
});

it('should not load PreviewAuth when there is no preview session', () => {
expect(plugins({}).map(({ plugin }) => plugin)).not.toContain(PREVIEW_AUTH);
});
});
38 changes: 38 additions & 0 deletions server/plugins/preview-auth/preview-auth.module.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { createHash, timingSafeEqual } from 'node:crypto';
import Boom from '@hapi/boom';

const PREVIEW_SECRET_HEADER = 'x-stencil-preview-secret';

// Hash both values so the comparison takes the same time for any input length.
function secretsMatch(expected, supplied) {
const digest = (value) => createHash('sha256').update(value).digest();
return timingSafeEqual(digest(expected), digest(supplied));
}

/**
* Loaded only in Makeswift preview mode.
*/
function register(server, { secret }) {
if (typeof secret !== 'string' || !secret) {
throw new Error('PreviewAuth requires a non-empty secret');
}
server.ext('onRequest', (request, h) => {
const supplied = request.headers[PREVIEW_SECRET_HEADER];
if (typeof supplied !== 'string') {
return Boom.unauthorized();
}
if (!secretsMatch(secret, supplied)) {
return Boom.forbidden();
}
delete request.headers[PREVIEW_SECRET_HEADER];
return h.continue;
});
}
export const name = 'PreviewAuth';
export const version = '0.0.1';
export { register };
export default {
register,
name,
version,
};
46 changes: 46 additions & 0 deletions server/plugins/preview-auth/preview-auth.module.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { jest } from '@jest/globals';
import * as Hapi from '@hapi/hapi';
import previewAuth from './preview-auth.module.js';

describe('PreviewAuth', () => {
const secret = 'a'.repeat(64);
const server = new Hapi.Server();
const handler = jest.fn(() => 'OK');

beforeAll(async () => {
await server.register({ plugin: previewAuth, options: { secret } });
server.route({ method: '*', path: '/{path*}', handler });
});
afterEach(() => handler.mockClear());

it('should return 401 and not run the route when the secret is missing', async () => {
const response = await server.inject({ url: '/internalapi/carts' });
expect(response.statusCode).toEqual(401);
expect(handler).not.toHaveBeenCalled();
});

it('should return 403 and not run the route when the secret is wrong', async () => {
const response = await server.inject({
url: '/test',
headers: { 'x-stencil-preview-secret': 'wrong' },
});
expect(response.statusCode).toEqual(403);
expect(handler).not.toHaveBeenCalled();
});

it('should run the route without the header when the secret is correct', async () => {
const response = await server.inject({
url: '/test',
headers: { 'x-stencil-preview-secret': secret },
});
expect(response.statusCode).toEqual(200);
const [request] = handler.mock.calls[0];
expect(request.headers).not.toHaveProperty('x-stencil-preview-secret');
});

it('should fail to register without a secret', async () => {
await expect(
new Hapi.Server().register({ plugin: previewAuth, options: { secret: '' } }),
).rejects.toThrow('PreviewAuth requires a non-empty secret');
});
});
Loading