Skip to content

Commit

Permalink
fix(platform/host): add secondary origin to allowed origins
Browse files Browse the repository at this point in the history
Previously, configuring a secondary origin replaced the application's origin as allowed origin. This was not correct. Instead, the secondary origin should be an additionally allowed origin.

This feature was introduced for integrating microfrontends into a rich client to bridge messages between clients and host across browser boundaries. However, by replacing the origin, activators could no longer connect to the host.

closes #197

BREAKING CHANGE: Property for configuring a secondary origin has been renamed from `messageOrigin` to `secondaryOrigin`. This breaking change only refers to the host.

To migrate, configure the additional allowed origin via the `secondaryOrigin` property instead of `messageOrigin`, as following:

```ts
await MicrofrontendPlatform.startHost({
  applications: [
    {symbolicName: 'client', manifestUrl: 'https://app/manifest.json', secondaryOrigin: 'https://secondary'},
  ],
});
```
  • Loading branch information
danielwiehl authored and Marcarrian committed Nov 24, 2022
1 parent f3890f0 commit 61cddc0
Show file tree
Hide file tree
Showing 11 changed files with 104 additions and 70 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -154,13 +154,13 @@ a| `string`

See <<objects::manifest,Manifest>> for an overview of the properties of the manifest.

| messageOrigin
| secondaryOrigin
a| `string`
| no
|
| Specifies the origin where message from this application must originate from. Messages of a different origin will be rejected. If not set, the origin is derived from the manifest URL or the base URL as specified in the manifest file.
| Specifies an additional origin (in addition to the origin of the application) from which the application is allowed to connect to the platform.

Setting a different origin may be necessary if, for example, integrating microfrontends into a rich client, allowing an integrator to bridge messages to a remote host.
By default, if not set, the application is allowed to connect from the origin of the manifest URL or the base URL as specified in the manifest file. Setting an additional origin may be necessary if, for example, integrating microfrontends into a rich client, enabling an integrator to bridge messages between clients and host across browser boundaries.

| [[objects::application-config:manifestLoadTimeout]]manifestLoadTimeout
a| `number`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ import {ManifestFixture} from '../testing/manifest-fixture/manifest-fixture';
import {Beans} from '@scion/toolkit/bean-manager';
import {ClientRegistry} from '../host/client-registry/client.registry';
import {ObserveCaptor} from '@scion/toolkit/testing';
import {expectPromise, getLoggerSpy, installLoggerSpies} from '../testing/spec.util.spec';

describe('MicrofrontendPlatform', () => {

const disposables = new Set<Disposable>();

beforeEach(async () => {
await MicrofrontendPlatform.destroy();
installLoggerSpies();
});

afterEach(async () => {
Expand Down Expand Up @@ -86,20 +88,57 @@ describe('MicrofrontendPlatform', () => {
expect(clientIdCaptor.getValues()).toEqual([clientId, clientId, clientId]);
});

it('should reject messages from wrong origin', async () => {
it('should reject client connect attempt if the app is not registered', async () => {
await MicrofrontendPlatform.startHost({applications: []}); // no app is registered

const microfrontendFixture = registerFixture(new MicrofrontendFixture());
const script = microfrontendFixture.insertIframe().loadScript('./lib/client/messaging/messaging.script.ts', 'connectToHost', {symbolicName: 'bad-client'});

await expectAsync(script).toBeRejectedWithError(/\[ClientConnectError] Client connect attempt rejected: Unknown client./);
});

it('should reject client connect attempt if the client\'s origin is different to the registered app origin', async () => {
await MicrofrontendPlatform.startHost({
applications: [
{
symbolicName: 'client',
manifestUrl: new ManifestFixture({name: 'Client'}).serve(),
messageOrigin: 'http://wrong-origin.dev',
manifestUrl: new ManifestFixture({name: 'Client', baseUrl: 'http://app-origin'}).serve(),
},
],
});

const microfrontendFixture = registerFixture(new MicrofrontendFixture()).insertIframe();
const connectPromise = microfrontendFixture.loadScript('./lib/client/client-connect.script.ts', 'connectToHost', {symbolicName: 'client'});
await expectAsync(connectPromise).toBeRejectedWithError(/\[MessageClientConnectError] Client connect attempt blocked by the message broker: Wrong origin \[actual='http:\/\/localhost:[\d]+', expected='http:\/\/wrong-origin.dev', app='client'] \[code: 'refused:blocked']/);
const microfrontendFixture = registerFixture(new MicrofrontendFixture());
// Client connects under karma test runner origin (location.origin), but is registered under `http://app-origin`.
const script = microfrontendFixture.insertIframe().loadScript('./lib/client/messaging/messaging.script.ts', 'connectToHost', {symbolicName: 'client'});

await expectAsync(script).toBeRejectedWithError(/\[ClientConnectError] Client connect attempt blocked: Wrong origin./);
});

it('should accept client connect attempt if originating from the secondary origin', async () => {
await MicrofrontendPlatform.startHost({
applications: [
{
symbolicName: 'client',
manifestUrl: new ManifestFixture({name: 'Client', baseUrl: 'http://app-origin'}).serve(),
secondaryOrigin: location.origin
},
],
});

const microfrontendFixture = registerFixture(new MicrofrontendFixture());
// - Client connects under karma test runner origin (location.origin)
// - Base origin is 'app-origin'
// - Application is configured to allow messages from secondary origin, which is karma test runner origin (location.origin)
const script = microfrontendFixture.insertIframe().loadScript('./lib/client/messaging/messaging.script.ts', 'connectToHost', {symbolicName: 'client'});
await expectAsync(script).toBeResolved();
});

it('should reject startup promise if the message broker cannot be discovered', async () => {
const loggerSpy = getLoggerSpy('error');
const startup = MicrofrontendPlatform.connectToHost('client-app', {brokerDiscoverTimeout: 250});
await expectPromise(startup).toReject(/MicrofrontendPlatformStartupError/);

await expect(loggerSpy).toHaveBeenCalledWith('[GatewayError] Message broker not discovered within 250ms. Messages cannot be published or received.');
});

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -628,40 +628,6 @@ describe('Messaging', () => {
expect(replyCaptor.hasErrored()).withContext('hasErrored').toBeFalse();
});

it('should reject a client connect attempt if the app is not registered', async () => {
await MicrofrontendPlatform.startHost({applications: []}); // no app is registered

const microfrontendFixture = registerFixture(new MicrofrontendFixture());
const script = microfrontendFixture.insertIframe().loadScript('./lib/client/messaging/messaging.script.ts', 'connectToHost', {symbolicName: 'bad-client'});

await expectAsync(script).toBeRejectedWithError(/\[MessageClientConnectError] Client connect attempt rejected by the message broker: Unknown client./);
});

it('should reject a client connect attempt if the client\'s origin is different to the registered app origin', async () => {
await MicrofrontendPlatform.startHost({
applications: [
{
symbolicName: 'client',
manifestUrl: new ManifestFixture({name: 'Client', baseUrl: 'http://app-origin'}).serve(),
},
],
});

const microfrontendFixture = registerFixture(new MicrofrontendFixture());
// Client connects under karma test runner origin, but is registered under `http://app-origin`.
const script = microfrontendFixture.insertIframe().loadScript('./lib/client/messaging/messaging.script.ts', 'connectToHost', {symbolicName: 'client'});

await expectAsync(script).toBeRejectedWithError(/\[MessageClientConnectError] Client connect attempt blocked by the message broker: Wrong origin./);
});

it('should reject startup promise if the message broker cannot be discovered', async () => {
const loggerSpy = getLoggerSpy('error');
const startup = MicrofrontendPlatform.connectToHost('client-app', {brokerDiscoverTimeout: 250});
await expectPromise(startup).toReject(/MicrofrontendPlatformStartupError/);

await expect(loggerSpy).toHaveBeenCalledWith('[GatewayError] Message broker not discovered within 250ms. Messages cannot be published or received.');
});

it('should not error with `ClientConnectError` when starting the platform host and if initializers in runlevel 0 take a long time to complete, e.g., to fetch manifests', async () => {
const loggerSpy = getLoggerSpy('error');
const initializerDuration = 1000;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,14 @@ export interface ApplicationConfig {
*/
manifestUrl: string;
/**
* Specifies the origin where message from this application must originate from. Messages of a different origin will be rejected.
* If not set, the origin is derived from the manifest URL or the base URL as specified in the manifest file. Setting a different
* origin may be necessary if, for example, integrating microfrontends into a rich client, allowing an integrator to bridge
* messages to a remote host.
* Specifies an additional origin (in addition to the origin of the application) from which the application is allowed
* to connect to the platform.
*
* By default, if not set, the application is allowed to connect from the origin of the manifest URL or the base URL as
* specified in the manifest file. Setting an additional origin may be necessary if, for example, integrating microfrontends
* into a rich client, enabling an integrator to bridge messages between clients and host across browser boundaries.
*/
messageOrigin?: string;
secondaryOrigin?: string;
/**
* Maximum time (in milliseconds) that the host waits until the manifest for this application is loaded.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,4 +193,24 @@ describe('ApplicationRegistry', () => {
expect(registry.getApplication('app').name).toEqual('app');
expect(registry.getApplication('app').symbolicName).toEqual('app');
});

it('should set the application\'s manifest origin as allowed message origin', async () => {
await registry.registerApplication({symbolicName: 'app', manifestUrl: 'https://app.com/manifest'}, {name: 'App'});
expect(registry.getApplication('app').allowedMessageOrigins).toEqual(new Set().add('https://app.com'));
});

it('should set the application\'s base origin as allowed message origin', async () => {
await registry.registerApplication({symbolicName: 'app', manifestUrl: 'https://app.com/manifest'}, {name: 'App', baseUrl: 'https://primary.app.com'});
expect(registry.getApplication('app').allowedMessageOrigins).toEqual(new Set().add('https://primary.app.com'));
});

it('should add secondary origin to the allowed message origins (1/2)', async () => {
await registry.registerApplication({symbolicName: 'app', manifestUrl: 'https://app.com/manifest', secondaryOrigin: 'https://secondary.app.com'}, {name: 'App'});
expect(registry.getApplication('app').allowedMessageOrigins).toEqual(new Set().add('https://app.com').add('https://secondary.app.com'));
});

it('should add secondary origin to the allowed message origins (2/2)', async () => {
await registry.registerApplication({symbolicName: 'app', manifestUrl: 'https://app.com/manifest', secondaryOrigin: 'https://secondary.app.com'}, {name: 'App', baseUrl: 'https://primary.app.com'});
expect(registry.getApplication('app').allowedMessageOrigins).toEqual(new Set().add('https://primary.app.com').add('https://secondary.app.com'));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
*/

import {Application, Manifest} from '../platform.model';
import {Defined} from '@scion/toolkit/util';
import {Arrays, Defined} from '@scion/toolkit/util';
import {Urls} from '../url.util';
import {MicrofrontendPlatformConfig} from './microfrontend-platform-config';
import {ManifestRegistry} from './manifest-registry/manifest-registry';
Expand Down Expand Up @@ -46,14 +46,15 @@ export class ApplicationRegistry {
throw Error(`[ApplicationRegistrationError] Symbolic name must be unique [symbolicName='${applicationConfig.symbolicName}'].`);
}

const baseUrl = this.computeBaseUrl(applicationConfig, manifest);
this._applications.set(applicationConfig.symbolicName, {
symbolicName: applicationConfig.symbolicName,
name: manifest.name ?? applicationConfig.symbolicName,
baseUrl: this.computeBaseUrl(applicationConfig, manifest),
baseUrl: baseUrl,
manifestUrl: Urls.newUrl(applicationConfig.manifestUrl, Urls.isAbsoluteUrl(applicationConfig.manifestUrl) ? applicationConfig.manifestUrl : window.origin).toString(),
manifestLoadTimeout: applicationConfig.manifestLoadTimeout ?? Beans.get(MicrofrontendPlatformConfig).manifestLoadTimeout,
activatorLoadTimeout: applicationConfig.activatorLoadTimeout ?? Beans.get(MicrofrontendPlatformConfig).activatorLoadTimeout,
messageOrigin: applicationConfig.messageOrigin ?? Urls.newUrl(this.computeBaseUrl(applicationConfig, manifest)).origin,
allowedMessageOrigins: new Set(Arrays.coerce(applicationConfig.secondaryOrigin)).add(Urls.newUrl(baseUrl).origin),
scopeCheckDisabled: Defined.orElse(applicationConfig.scopeCheckDisabled, false),
intentionCheckDisabled: Defined.orElse(applicationConfig.intentionCheckDisabled, false),
intentionRegisterApiDisabled: Defined.orElse(applicationConfig.intentionRegisterApiDisabled, true),
Expand Down Expand Up @@ -136,4 +137,8 @@ export class ApplicationRegistry {
* The version is omitted because not known at the time of registration, but only when first connecting to the host, e.g., in an activator.
*/
export interface ɵApplication extends Omit<Application, 'platformVersion'> { // eslint-disable-line @typescript-eslint/no-empty-interface
/**
* Specifies the origin(s) where message from this application must originate from. Messages of a different origin will be rejected.
*/
allowedMessageOrigins: Set<string>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,6 @@ describe('ClientRegistry', () => {

function newClient(clientId: string, appSymbolicName: string): Client {
const application: Partial<ɵApplication> = {symbolicName: appSymbolicName};
return new ɵClient(clientId, {} as Window, application as ɵApplication, Beans.get(ɵVERSION));
return new ɵClient(clientId, {} as Window, 'origin', application as ɵApplication, Beans.get(ɵVERSION));
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ export interface Client {
*/
readonly window: Window;

/**
* The origin of this client; is one of {@link ɵApplication.allowedMessageOrigins}.
*/
readonly origin: string;

/**
* The application this client belongs to.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export class ɵClient implements Client {

constructor(public readonly id: string,
public readonly window: Window,
public readonly origin: string,
public readonly application: ɵApplication,
version: string) {
this.version = version ?? '0.0.0';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,35 +136,35 @@ export class MessageBroker implements Initializer, PreDestroy {
const replyTo = envelope.message.headers.get(MessageHeaders.ReplyTo);

if (!clientAppName) {
const warning = `Client connect attempt rejected by the message broker: Bad request. [origin='${event.origin}']`;
const warning = `Client connect attempt rejected: Bad request. [origin='${event.origin}']`;
Beans.get(Logger).warn(`[CONNECT] ${warning}`);
sendTopicMessage<ConnackMessage>(clientMessageTarget, {
topic: replyTo,
body: {returnCode: 'refused:bad-request', returnMessage: `[MessageClientConnectError] ${warning}`},
body: {returnCode: 'refused:bad-request', returnMessage: `[ClientConnectError] ${warning}`},
headers: new Map(),
});
return;
}

const application = this._applicationRegistry.getApplication(clientAppName);
if (!application) {
const warning = `Client connect attempt rejected by the message broker: Unknown client. [app='${clientAppName}']`;
const warning = `Client connect attempt rejected: Unknown client. [app='${clientAppName}']`;
Beans.get(Logger).warn(`[CONNECT] ${warning}`);
sendTopicMessage<ConnackMessage>(clientMessageTarget, {
topic: replyTo,
body: {returnCode: 'refused:rejected', returnMessage: `[MessageClientConnectError] ${warning}`},
body: {returnCode: 'refused:rejected', returnMessage: `[ClientConnectError] ${warning}`},
headers: new Map(),
});
return;
}

if (event.origin !== application.messageOrigin) {
const warning = `Client connect attempt blocked by the message broker: Wrong origin [actual='${event.origin}', expected='${application.messageOrigin}', app='${application.symbolicName}']`;
if (!application.allowedMessageOrigins.has(event.origin)) {
const warning = `Client connect attempt blocked: Wrong origin [actual='${event.origin}', expected='${Array.from(application.allowedMessageOrigins)}', app='${application.symbolicName}']`;
Beans.get(Logger).warn(`[CONNECT] ${warning}`);

sendTopicMessage<ConnackMessage>(clientMessageTarget, {
topic: replyTo,
body: {returnCode: 'refused:blocked', returnMessage: `[MessageClientConnectError] ${warning}`},
body: {returnCode: 'refused:blocked', returnMessage: `[ClientConnectError] ${warning}`},
headers: new Map(),
});
return;
Expand All @@ -173,7 +173,7 @@ export class MessageBroker implements Initializer, PreDestroy {
// Check if the client is already connected. If already connected, do nothing. A client can potentially initiate multiple connect requests, for example,
// when not receiving connect confirmation in time.
const currentClient = this._clientRegistry.getByWindow(eventSource);
if (currentClient && currentClient.application.messageOrigin === event.origin && currentClient.application.symbolicName === application.symbolicName) {
if (currentClient && currentClient.origin === event.origin && currentClient.application.symbolicName === application.symbolicName) {
sendTopicMessage<ConnackMessage>(currentClient, {
topic: replyTo,
body: {returnCode: 'accepted', clientId: currentClient.id},
Expand All @@ -182,7 +182,7 @@ export class MessageBroker implements Initializer, PreDestroy {
return;
}

const client = new ɵClient(UUID.randomUUID(), eventSource, application, envelope.message.headers.get(MessageHeaders.Version));
const client = new ɵClient(UUID.randomUUID(), eventSource, event.origin, application, envelope.message.headers.get(MessageHeaders.Version));
this._clientRegistry.registerClient(client);

// Check if the client is compatible with the platform version of the host.
Expand Down Expand Up @@ -668,10 +668,10 @@ function checkOriginTrusted<T extends Message>(): MonoTypeOperatorFunction<Messa
}

// Assert source origin.
if (event.origin !== client.application.messageOrigin) {
if (event.origin !== client.origin) {
if (event.source !== null) {
const sender = new MessageTarget(event);
const error = `[MessagingError] Message rejected: Wrong origin [actual=${event.origin}, expected=${client.application.messageOrigin}, application=${client.application.symbolicName}]`;
const error = `[MessagingError] Message rejected: Wrong origin [actual=${event.origin}, expected=${client.origin}, application=${client.application.symbolicName}]`;
sendDeliveryStatusError(sender, messageId, error);
}
return EMPTY;
Expand Down Expand Up @@ -730,10 +730,10 @@ function sendTopicMessage<T>(target: MessageTarget | Client | TopicSubscription,
const client = subscription.client;
envelope.message.headers.set(client.deprecations.legacyIntentSubscriptionApi ? 'ɵTOPIC_SUBSCRIBER_ID' : MessageHeaders.ɵSubscriberId, target.subscriberId);
envelope.message.params = new TopicMatcher(subscription.topic).match(message.topic).params;
!client.stale && client.window.postMessage(envelope, client.application.messageOrigin);
!client.stale && client.window.postMessage(envelope, client.origin);
}
else {
!target.stale && target.window.postMessage(envelope, target.application.messageOrigin);
!target.stale && target.window.postMessage(envelope, target.origin);
}
}

Expand All @@ -751,7 +751,7 @@ function sendIntentMessage(subscription: IntentSubscription, message: IntentMess
},
};
const client = subscription.client;
!client.stale && client.window.postMessage(envelope, client.application.messageOrigin);
!client.stale && client.window.postMessage(envelope, client.origin);
}

/**
Expand Down
Loading

0 comments on commit 61cddc0

Please sign in to comment.