diff --git a/docs/adoc/microfrontend-platform-developer-guide/chapters/configuration/configuration.adoc b/docs/adoc/microfrontend-platform-developer-guide/chapters/configuration/configuration.adoc index b58c6b9c..18070478 100644 --- a/docs/adoc/microfrontend-platform-developer-guide/chapters/configuration/configuration.adoc +++ b/docs/adoc/microfrontend-platform-developer-guide/chapters/configuration/configuration.adoc @@ -154,13 +154,13 @@ a| `string` See <> 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` diff --git a/projects/scion/microfrontend-platform/src/lib/client/client-connect.spec.ts b/projects/scion/microfrontend-platform/src/lib/client/client-connect.spec.ts index d3c03501..845ae99d 100644 --- a/projects/scion/microfrontend-platform/src/lib/client/client-connect.spec.ts +++ b/projects/scion/microfrontend-platform/src/lib/client/client-connect.spec.ts @@ -14,6 +14,7 @@ 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', () => { @@ -21,6 +22,7 @@ describe('MicrofrontendPlatform', () => { beforeEach(async () => { await MicrofrontendPlatform.destroy(); + installLoggerSpies(); }); afterEach(async () => { @@ -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.'); }); /** diff --git a/projects/scion/microfrontend-platform/src/lib/client/messaging/messaging.spec.ts b/projects/scion/microfrontend-platform/src/lib/client/messaging/messaging.spec.ts index 671968e0..6cdf5476 100644 --- a/projects/scion/microfrontend-platform/src/lib/client/messaging/messaging.spec.ts +++ b/projects/scion/microfrontend-platform/src/lib/client/messaging/messaging.spec.ts @@ -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; diff --git a/projects/scion/microfrontend-platform/src/lib/host/application-config.ts b/projects/scion/microfrontend-platform/src/lib/host/application-config.ts index 519665cb..ab3ca44c 100644 --- a/projects/scion/microfrontend-platform/src/lib/host/application-config.ts +++ b/projects/scion/microfrontend-platform/src/lib/host/application-config.ts @@ -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. * diff --git a/projects/scion/microfrontend-platform/src/lib/host/application-registry.spec.ts b/projects/scion/microfrontend-platform/src/lib/host/application-registry.spec.ts index 554600fe..e2bc58bc 100644 --- a/projects/scion/microfrontend-platform/src/lib/host/application-registry.spec.ts +++ b/projects/scion/microfrontend-platform/src/lib/host/application-registry.spec.ts @@ -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')); + }); }); diff --git a/projects/scion/microfrontend-platform/src/lib/host/application-registry.ts b/projects/scion/microfrontend-platform/src/lib/host/application-registry.ts index cba253a3..4c4da15f 100644 --- a/projects/scion/microfrontend-platform/src/lib/host/application-registry.ts +++ b/projects/scion/microfrontend-platform/src/lib/host/application-registry.ts @@ -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'; @@ -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), @@ -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 { // 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; } diff --git a/projects/scion/microfrontend-platform/src/lib/host/client-registry/client.registry.spec.ts b/projects/scion/microfrontend-platform/src/lib/host/client-registry/client.registry.spec.ts index ce16af20..6c6601a7 100644 --- a/projects/scion/microfrontend-platform/src/lib/host/client-registry/client.registry.spec.ts +++ b/projects/scion/microfrontend-platform/src/lib/host/client-registry/client.registry.spec.ts @@ -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)); } }); diff --git a/projects/scion/microfrontend-platform/src/lib/host/client-registry/client.ts b/projects/scion/microfrontend-platform/src/lib/host/client-registry/client.ts index be1e14d5..5bee89ab 100644 --- a/projects/scion/microfrontend-platform/src/lib/host/client-registry/client.ts +++ b/projects/scion/microfrontend-platform/src/lib/host/client-registry/client.ts @@ -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. */ diff --git "a/projects/scion/microfrontend-platform/src/lib/host/client-registry/\311\265client.ts" "b/projects/scion/microfrontend-platform/src/lib/host/client-registry/\311\265client.ts" index 296d6e08..db640d96 100644 --- "a/projects/scion/microfrontend-platform/src/lib/host/client-registry/\311\265client.ts" +++ "b/projects/scion/microfrontend-platform/src/lib/host/client-registry/\311\265client.ts" @@ -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'; diff --git a/projects/scion/microfrontend-platform/src/lib/host/message-broker/message-broker.ts b/projects/scion/microfrontend-platform/src/lib/host/message-broker/message-broker.ts index 6d8f16b0..d3b44f5a 100644 --- a/projects/scion/microfrontend-platform/src/lib/host/message-broker/message-broker.ts +++ b/projects/scion/microfrontend-platform/src/lib/host/message-broker/message-broker.ts @@ -136,11 +136,11 @@ 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(clientMessageTarget, { topic: replyTo, - body: {returnCode: 'refused:bad-request', returnMessage: `[MessageClientConnectError] ${warning}`}, + body: {returnCode: 'refused:bad-request', returnMessage: `[ClientConnectError] ${warning}`}, headers: new Map(), }); return; @@ -148,23 +148,23 @@ export class MessageBroker implements Initializer, PreDestroy { 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(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(clientMessageTarget, { topic: replyTo, - body: {returnCode: 'refused:blocked', returnMessage: `[MessageClientConnectError] ${warning}`}, + body: {returnCode: 'refused:blocked', returnMessage: `[ClientConnectError] ${warning}`}, headers: new Map(), }); return; @@ -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(currentClient, { topic: replyTo, body: {returnCode: 'accepted', clientId: currentClient.id}, @@ -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. @@ -668,10 +668,10 @@ function checkOriginTrusted(): MonoTypeOperatorFunction(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); } } @@ -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); } /** diff --git a/projects/scion/microfrontend-platform/src/lib/platform.model.ts b/projects/scion/microfrontend-platform/src/lib/platform.model.ts index 6029994d..3f3bf1ad 100644 --- a/projects/scion/microfrontend-platform/src/lib/platform.model.ts +++ b/projects/scion/microfrontend-platform/src/lib/platform.model.ts @@ -69,10 +69,6 @@ export interface Application { * URL to the application root. */ baseUrl: string; - /** - * Specifies the origin where message from this application must originate from. Messages of a different origin will be rejected. - */ - messageOrigin: string; /** * URL to the manifest of this application. */