Skip to content

Commit

Permalink
feat(arcgis-rest-auth): add postMessage auth support
Browse files Browse the repository at this point in the history
Adds static and instance methods to UserSession to support postMessage style authentication

AFFECTS PACKAGES:
@esri/arcgis-rest-auth
  • Loading branch information
dbouwman committed Oct 13, 2020
1 parent 2201bd7 commit a6b8a17
Show file tree
Hide file tree
Showing 3 changed files with 303 additions and 8 deletions.
95 changes: 95 additions & 0 deletions docs/src/guides/embedded-apps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
---
title: Passing Authentication to iFramed apps
navTitle: Embedded Authentication
description: Learn how to pass authentication into iframe embedded applications.
order: 50
group: 2-authentication
---

**Note** This is a new api feature, and currently there are *no* platform applications that support the authentication flows discussed in this guide. However, this guide exists to help other platform app teams add this functionality to their apps.

# Authentication with Embedded Applications

Sometimes an application will need to embed another application using an `<iframe>`. If both applications are backed by items that are publicly accessible, things will just work.

But, if the "Host" application is not public and the embedded application is not public, we then run into the question of how to pass authentication from the "Host" to the embedded application.

### Cross Origin Embedding
Cross-Origin embedding occurs when the "host" app and the "embedded" application are served from different locations. This is only supported for ArcGIS Platform apps that support embedding.


For example, you can build a custom app, hosted at `http://myapp.com` and iframe in a "platform app" that supports embedding. However, you can not embed your custom app into a storymap, and expect the storymap to pass authentication to your app. This is done for security reasons.


## Using `postMessage`
The browser's `postMessage` api is designed to allow communication between various "frames" in a page, and it is how this works internally. You can read more about [postMessage](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage) at the Mozilla Developer Network.

# Authentication Flow
We will walk through the flows at a high-level

## 1 Host App uses oAuth

The application acting as the host, should use oAuth to autentication the user, and *before* rendering the iframe with the embedded application it must call `session.enablePostMessageAuth(validOrigins)`. This sets up an event handler that will process the requests from the embedded applications.

The `validOrigins` argument is an array of "orgins" your app expects to get auth requests from. **NOTE** This should be a constrained list of just the domains this particular application will actually be embedding.

```js
// register your own app to create a unique clientId
const clientId = "abc123"

UserSession.beginOAuth2({
clientId,
redirectUri: 'https://yourapp.com/authenticate.html'
})
.then(session => {
// for this example we will only send auth to embeds hosted on storymaps.arcgis.com
const validOrigins = ['https://storymaps.arcgis.com'];
session.enablePostMessageAuth(validOrigins);
})
```

#### 2 Host App adds params to embed url
Let's suppose the host app is embedding `https://storymaps.arcgis.com/stories/15a9b9991fff47ad84f4618a28b01afd`. To tell the embedded app that it should request authentication from the parent we need to add two url parameters:

- `embed=iframe` - tells the app it's embedded in an iframe. This allows the app to make ui changes like hiding headers etc
- `parentOrigin=https://myapp.com` - this tells the app what 'origin' to expect messages from, and also to ignore other origins. **note** this should be uri encoded

```js
const originalUrl = 'https://storymaps.arcgis.com/stories/15a9b9991fff47ad84f4618a28b01afd';
const embedUrl = `${originalurl}?embed=true&parentOrigin=${encodeURIComponent(window.location.origin)}`;
// then use embedUrl in your component that renders the <iframe>
```

#### 3 Embed App boots and Requests Auth
In the embedded application, early in it's boot sequence it should read the query string parameters and make the determintation that it is running inside an iframe, and that it can request authentication information from the parent.

```js
// Parse up any url params
let params = new URLSearchParams(document.location.search.substring(1));
const embedStyle = params.get('embed');
const parentOrigin = params.get('parentOrigin');
if (embedStyle === 'iframe' && parentOrigin) {
UserSession.fromParent(parentOrigin)
.then((session) => {
// session is a UserSession instance, populated from the parent app
// the embeded app should exchange this token for one specific to the application
})
.catch((ex) => {
// if the origin of the embedded app is not in the parent's validOrigin array
// this will throw with a message "Rejected authentication request."
})
}
```

#### 4 Parent App Transitions Away
If the parent app has the ability to transition to another route (i.e. an Angular, Ember, React etc app with a router) then **before** the transition occurs, the postMessage event listener must be disposed of. Unfortuately we can't automate this inside ArcGIS Rest Js as it will require using the life-cycle hooks of your application framework. The example below uses the `disconnectedCallback` that is part of the Stenci.js component life-cycle.

```js
// Use your framework's hooks...
disconnectedCallback () {
// stop listening for requests from children
state.session.disablePostMessageAuth();
}
```


36 changes: 28 additions & 8 deletions packages/arcgis-rest-auth/src/UserSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,15 @@ export class UserSession implements IAuthenticationManager {
* @param parentOrigin origin of the parent frame. Passed into the embedded application as `parentOrigin` query param
* @browserOnly
*/
public static fromParent (parentOrigin:string): Promise<any> {
public static fromParent (
parentOrigin:string,
win?: any
): Promise<any> {

/* istanbul ignore next: must pass in a mockwindow for tests so we can't cover the other branch */
if (!win && window) {
win = window;
}
// Declar handler outside of promise scope so we can detach it
let handler: (event: any) => void;
// return a promise...
Expand All @@ -461,14 +469,16 @@ export class UserSession implements IAuthenticationManager {
} catch (err) {
return reject(err);
}
} else {
return reject(new Error('Rejected authentication request.'));
}
};
// add listener
window.addEventListener('message', handler, false);
window.parent.postMessage({type: 'arcgis:auth:requestCredential'}, parentOrigin);
win.addEventListener('message', handler, false);
win.parent.postMessage({type: 'arcgis:auth:requestCredential'}, parentOrigin);
})
.then((session) => {
window.removeEventListener('message', handler, false);
win.removeEventListener('message', handler, false);
return session;
});
}
Expand All @@ -483,6 +493,8 @@ export class UserSession implements IAuthenticationManager {
}
if (event.data.type === 'arcgis:auth:rejected') {
throw new Error(event.data.message);
} else {
throw new Error('Unknown message type.');
}
}

Expand Down Expand Up @@ -891,18 +903,26 @@ export class UserSession implements IAuthenticationManager {
*
* @param validChildOrigins Array of origins that are allowed to request authentication from the host app
*/
public enablePostMessageAuth (validChildOrigins: string[]): any {
public enablePostMessageAuth (validChildOrigins: string[], win?:any ): any {
/* istanbul ignore next: must pass in a mockwindow for tests so we can't cover the other branch */
if (!win && window) {
win = window;
}
this.hostHandler = this.createPostMessageHandler(validChildOrigins);
window.addEventListener('message',this.hostHandler , false);
win.addEventListener('message',this.hostHandler , false);
}

/**
* For a "Host" app that has embedded other platform apps via iframes, when the host needs
* to transition routes, it should call `UserSession.disablePostMessageAuth()` to remove
* the event listener and prevent memory leaks
*/
public disablePostMessageAuth () {
window.removeEventListener('message', this.hostHandler, false);
public disablePostMessageAuth (win?: any) {
/* istanbul ignore next: must pass in a mockwindow for tests so we can't cover the other branch */
if (!win && window) {
win = window;
}
win.removeEventListener('message', this.hostHandler, false);
}

/**
Expand Down
180 changes: 180 additions & 0 deletions packages/arcgis-rest-auth/test/UserSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1091,6 +1091,186 @@ describe("UserSession", () => {
});
});

describe('postmessage auth :: ', () => {
let MockWindow = {
addEventListener: () => {},
removeEventListener: () => {},
parent: {
postMessage: () => {}
}
};

const cred = {
expires: TOMORROW.getTime(),
server: "https://www.arcgis.com",
ssl: false,
token: "token",
userId: "jsmith"
};

it('.disablePostMessageAuth removes event listener', () => {
const removeSpy = spyOn(MockWindow, 'removeEventListener');
const session = UserSession.fromCredential(cred)
session.disablePostMessageAuth(MockWindow);
expect(removeSpy.calls.count()).toBe(1, 'should call removeEventListener');
});
it('.enablePostMessageAuth adds event listener', () => {
const addSpy = spyOn(MockWindow, 'addEventListener');
const session = UserSession.fromCredential(cred);
session.enablePostMessageAuth(['https://storymaps.arcgis.com'], MockWindow);
expect(addSpy.calls.count()).toBe(1, 'should call addEventListener');
});

it('.enablePostMessage handler returns credential to origin in list', () => {
// ok, this gets kinda gnarly...

// create a mock window object
// that will hold the passed in event handler so we can fire it manually
const Win = {
_fn: (evt:any) => {},
addEventListener: function (evt:any, fn:any) {
// enablePostMessageAuth passes in the handler, which is what we're actually testing
Win._fn = fn;
},
removeEventListener: function () {},
}
// Create the session
const session = UserSession.fromCredential(cred);
// enable postMessageAuth allowing storymaps.arcgis.com to recieve creds
session.enablePostMessageAuth(['https://storymaps.arcgis.com'], Win);
// create an event object, with a matching origin
// an a source.postMessage fn that we can spy on
const evt = {
origin: 'https://storymaps.arcgis.com',
source: {
postMessage: function (msg: any, origin: string) {}
}
}
// create the spy
const sourceSpy = spyOn(evt.source, 'postMessage');
// Now, fire the handler, simulating what happens when a postMessage event comes
// from an embedded iframe
Win._fn(evt);
// Expectations...
expect(sourceSpy.calls.count()).toBe(1, 'souce.postMessage should be called in handler');
const args = sourceSpy.calls.argsFor(0);
expect(args[0].type).toBe('arcgis:auth:credential', 'should send credential type');
expect(args[0].credential.userId).toBe('jsmith', 'should send credential');
// now the case where it's not a valid origin
evt.origin = 'https://evil.com';
Win._fn(evt);
expect(sourceSpy.calls.count()).toBe(2, 'souce.postMessage should be called in handler');
const args2 = sourceSpy.calls.argsFor(1);
expect(args2[0].type).toBe('arcgis:auth:rejected', 'should send reject');
});

it('.fromParent happy path', () => {
// create a mock window that will fire the handler
const Win = {
_fn: (evt:any) => {},
addEventListener: function (evt:any, fn:any) {
Win._fn = fn;
},
removeEventListener: function () {},
parent: {
postMessage: function (msg: any, origin:string) {
Win._fn({origin: 'https://origin.com', data: {type: 'arcgis:auth:credential', credential: cred }});
}
}
}

return UserSession.fromParent('https://origin.com', Win)
.then((session) => {
expect(session.username).toBe('jsmith', 'should use the cred wired throu the mock window');
});
});

it('.fromParent rejects if not parentOrigin', () => {
// create a mock window that will fire the handler
const Win = {
_fn: (evt:any) => {},
addEventListener: function (evt:any, fn:any) {
Win._fn = fn;
},
removeEventListener: function () {},
parent: {
postMessage: function (msg: any, origin:string) {
Win._fn({origin: 'https://notorigin.com', data: {type: 'arcgis:auth:credential', credential: cred }});
}
}
}

return UserSession.fromParent('https://origin.com', Win)
.catch((err) => {
expect(err.message).toBe('Rejected authentication request.', 'Should reject');
})
});

it('.fromParent rejects if invlid cred', () => {
// create a mock window that will fire the handler
const Win = {
_fn: (evt:any) => {},
addEventListener: function (evt:any, fn:any) {
Win._fn = fn;
},
removeEventListener: function () {},
parent: {
postMessage: function (msg: any, origin:string) {
Win._fn({origin: 'https://origin.com', data: {type: 'arcgis:auth:credential', credential: {foo:"bar"} }});
}
}
}

return UserSession.fromParent('https://origin.com', Win)
.catch((err) => {
expect(err.message).toBe("Cannot read property 'includes' of undefined", 'Should reject');
})
});

it('.fromParent rejects if auth rejected', () => {
// create a mock window that will fire the handler
const Win = {
_fn: (evt:any) => {},
addEventListener: function (evt:any, fn:any) {
Win._fn = fn;
},
removeEventListener: function () {},
parent: {
postMessage: function (msg: any, origin:string) {
Win._fn({origin: 'https://origin.com', data: {type: 'arcgis:auth:rejected', message: 'Rejected authentication request.'}});
}
}
}

return UserSession.fromParent('https://origin.com', Win)
.catch((err) => {
expect(err.message).toBe("Rejected authentication request.", 'Should reject');
})
});

it('.fromParent rejects if auth unknown message', () => {
// create a mock window that will fire the handler
const Win = {
_fn: (evt:any) => {},
addEventListener: function (evt:any, fn:any) {
Win._fn = fn;
},
removeEventListener: function () {},
parent: {
postMessage: function (msg: any, origin:string) {
Win._fn({origin: 'https://origin.com', data: {type: 'arcgis:auth:other'}});
}
}
}

return UserSession.fromParent('https://origin.com', Win)
.catch((err) => {
expect(err.message).toBe("Unknown message type.", 'Should reject');
})
});

});

it("should throw an unknown error if the url has no error or access_token", () => {
const MockWindow = {
location: {
Expand Down

0 comments on commit a6b8a17

Please sign in to comment.