This is a prototype to explore the idea of using a Service Worker for intercepting fetch
requests and rerouting them through an iframe with an alternative origin (Origin Isolation). By leveraging an iframe on an alternative origin, we are able to store the tokens using the standard Web Storage API while also keeping them completely inaccessible to the main application.
There are 3 main entities involved in the front-end architecture:
- Main app: Standard JavaScript app (e.g.
https://app.example.com
) - Service Worker: Registered by the Main App (accessible on
https://app.example.com
) - Iframe: Alternative origin
frame
(e.g.https://proxy.example.com
) with a document that contains just private token store and request forwarding script
It's worth noting that this is heavily inspired by AppAuthHelper. This project is intended to provide a solution that integrates the functionality of AppAuthHelper with an application that uses the ForgeRock JavaScript SDK.
There's no real change to the structure or design of the main application. This app can use the SDK and make HTTP requests in any way that "emits" the fetch
event. Most standard HTTP libraries will do this.
The below is the most simple or reduced implementation. The actual implementation will be more abstracted and automated, but the below makes it more understandable:
-
Register the Service Worker.
// Registers the Service Worker to origin of Main App: `https://app.example.com` const registerServiceWorker = async () => { if ('serviceWorker' in navigator) { try { navigator.serviceWorker.register('sw.js', { type: 'module' }); } catch (error) { console.error(`SW registration failed with ${error}`); } } }; registerServiceWorker();
-
Include or inject the alt. origin
frame
in the HTML pointing to the token store & request forwarding document. This alt. origin cannot be a third-party domain; meaning, the alt. origin can only differ by subdomain, not root domain.<!-- Calls a separately running server on a **different** domain --> <iframe id="identityProxyFrame" src="http://proxy.example.com" style="display: none;"></iframe>
-
Configure the SDK as usual, with the addition of a custom token store object.
Config.set({ // Using SDK configuration ... tokenStore: { get(clientId) { // The iframe has no API for getting tokens out // Currently, we need to return an empty object so SDK methods don't crash return Promise.resolve({}); }, remove(clientId) { const proxyChannel = new MessageChannel(); // Sends message to iframe to delete tokens return new Promise((resolve, reject) => { identityProxyFrame.contentWindow.postMessage( { type: 'REMOVE_TOKENS', clientId }, '*', [proxyChannel.port2] ); proxyChannel.port1.onmessage = (event) => { resolve(event.data); }; }); }, set(clientId, tokens) { const proxyChannel = new MessageChannel(); // Sends the token to the iframe for storage return new Promise((resolve, reject) => { identityProxyFrame.contentWindow.postMessage( { type: 'SET_TOKENS', clientId, tokens }, '*', [proxyChannel.port2] ); proxyChannel.port1.onmessage = (event) => { resolve(event.data); }; }); }, }, });
Using this technique prevents the SDK from using its own storage and integrates the private token store from the alt. origin
frame
. This essentially converts the storage methods into a messaging scheme for communicating with theframe
.
The purpose of the Service Worker (SW) is to intercept fetch
request from the main app and convert the request into a postMessage
that is, eventually, passed to the alternative origin frame
that makes the request to the protected Resource Server. The service worker also passes a reference to a message channel for use when the fetch
request receives a response.
This SW just installs, activates and then listens for the fetch
event. It will only intercept a fetch
request that contains domain that have been configured for interception. When a fetch
is received with a domain to be intercepted, it creates a MessageChannel
pair: one for sending a message and one for receiving a message. The channel for sending eventually gets passed to the frame
.
The original request gets converted into a passable message. When ready, it posts a message to the main app, with a few things:
- The message type
- The prepared request
- The message channel for sending a message back
The purpose of this frame
on an alternative origin is to store tokens on an inaccessible location separate from the Main App's origin. This frame
is responsible for a few things:
- Listen for "token" events
- Store, manage tokens
- Listen for "fetch" request event
- Attach Access Token to requests
- Complete request to resource server
- Use the message channel to send response back to SW
- Enables Centralized Login
- Stores tokens in alt. origin
frame
- Make a real, protected request to the
/userinfo
endpoint - Make a cross domain, request to a mock data endpoint
- Logout/revoke tokens
This implementation uses a "plugin" like architecture, rather than building the feature directly in the SDK. This is not necessary as much of it could be written into the SDK, but this reduces the work necessary to achieve the functionality as well as reduces risk.
This is to reduce complexity. If the only security measure is related to token storage, then this fulfils this requirement. But, if we don't want the Main App or SW to even receive the token through the response, we could have the frame
remove the token from the response before forwarding it to the SW.
Due to the tokens being inaccessible from the Main App, the intention would be to provide an API for asking the frame
questions about the tokens, without actually providing the actual token values. Question like, "Do I have tokens?" or "Are the tokens valid?", could be provided.
Due to the SDK expecting to be able to directly manage tokens, there are some thrown errors in certain flows. To overcome this, the get
method of the token store object needs to return an empty object. The only known side-effect of this is when calling TokenManager.getTokens()
, forceRenew
is required as it needs to ignore the fact that get
returns an object without error.
Service Workers are unfortunately disabled when in Firefox's Private Mode, which breaks this solution. Can we provide some kind of fallback? At this time, I don't know. Bug ticket for Firefox here (from 6 years ago): https://bugzilla.mozilla.org/show_bug.cgi?id=1320796.
Since this is a prototype, error and edge cases are not covered and should not be expected. Only narrow, happy path is covered.
Refresh tokens could be added as they would provide a seamless and simple way of refreshing the Access Token without involving the Main App or its SW. The frame
could easily and securely store the Refresh Token and use it to request a new Access Token when a time threshold is met or a 401 is returned. If both the Refresh and Access Token are invalid, then the frame
would respond to the Main App with the need to request a new set of tokens through the Authorization Code Flow.
cd app
and then runpython -m SimpleHTTPServer 8000
. This will run the server for your app- In separate terminal window,
cd proxy
and runpython -m SimpleHTTPServer 9000
. This will be the server for your proxy. - Access the app on
http://localhost:8000
and everything is hardcoded to my ID Clout tenant - Click login, and you should be redirected to Platform Login; OAuth tokens can be seen in the localStorage under the alternative origin:
http://localhost:9000
- Once you return back to the app, you can click on Fetch Real User; user info will be logged to console.
- You can also click Logout – there will be some errors, but the tokens are removed locally and revoked on server