SPA using the Token Handler Pattern

SPA using the Token Handler Pattern

Overview

This tutorial and video show how to run a Single Page Application (SPA) that implements Backend for Frontend (BFF) security using the Token Handler Pattern. This provides the business benefits summarized in the Token Handler Overview:

GoalDetails
Best Browser SecurityFollows current best security practices for browser based apps, with no tokens in the browser and only SameSite=strict cookies used to call APIs
Great User ExperienceAll UI actions, including OpenID Connect redirects, are initiated in the browser so that the app has best control over usability
Modern Developer ExperienceThe UI is developed solely in a client based technology stack such as React, without the need to use cookies to call a web back end
Simple SPA CodeThe OpenID Connect and API code in the SPA is mostly one liners, with less code impact than Javascript or website security libraries
Deploy AnywhereThe SPA is built to static content that does not need securing and could be deployed to a Content Delivery Network (CDN)

Components

The example will run an SPA, API and Identity Server on a development computer, where the difficult security work is managed by plugging in components provided by Curity. An automated deployment is provided, representing the following architecture:

Token Handler

There are more moving parts to deploy when using this design pattern, due to the separation of web and API concerns. An additional OAuth Agent is used to do the OpenID Connect flow on behalf of the SPA, and an OAuth Proxy is used to deal with cookie related concerns during API calls.

Get the Code

First download the GitHub repository which consists of three small components:

Code Layout

ComponentDescription
Single Page ApplicationA simple SPA that interacts with an OAuth Agent and OAuth Proxy
Web HostThe web host serves static content and contains very little code
Example APIA simple Node.js API that validates requests using JWT access tokens

The code example demonstrates a coding design pattern where almost all of the OAuth work is done by API components provided by Curity or a similar provider. The SPA is coded in Typescript and React but the code is very simple, so it will be straightforward to port the code to any other web technology of your choice.

SPA Code

The following sections will first show how to run the SPA on a development computer and then describe the behavior in the OAuthClient and ApiClient classes, which deal with authentication and data access.

Run the SPA

This section will build and run both the simple components contained in the GitHub repository and also these token handler components provided by Curity:

ComponentDescription
OAuth AgentThe main utility API component, which deals with OAuth requests, OAuth responses and the issuing of secure cookies
OAuth ProxyA small component that runs in a reverse proxy hosted in front of APIs, to decrypt cookies and forward tokens

In addition the Curity Identity Server will be deployed, though the token handler design pattern will work with any standards based Authorization Server.

SPA Behavior

The simple SPA will first present an unauthenticated view:

Unauthenticated View

When Sign In is clicked, the SPA will perform OpenID Connect in an API driven manner, resulting in a secure solution where navigation actions such as multi-tab browsing also work reliably:

Authenticated View

Prerequisites

First ensure that the following components are installed:

You will also need a license file for the Curity Identity Server. If you do not have one you can get a free community license from the Curity Developer Portal by signing in with your GitHub account.

Configure URLs

The following URLs will be used for the integrated system, and the initial token handler setup uses HTTP URLs to reduce infrastructure on a development computer:

ComponentBase URL
Single Page Apphttp://www.example.com
OAuth Agenthttp://api.example.com:8080/oauth-agent
Example APIhttp://api.example.com:8080/api
Curity Identity Serverhttp://login.example.com:8443

For URLs to work you will need to add the following entries to the local computer's hosts file:

127.0.0.1  web.example.com api.example.com login.example.com
:1 localhost

Build Components

Run the following command to build code into Docker images. This will also download and build token handler components and store files in a resources subdirectory of the SPA project:

./build.sh

Deploy Components

Next copy the license.json file into the root folder of the SPA project and then run the following script, which will deploy all components in a Docker Compose network:

./deploy.sh

Run the SPA

You can now open a browser at http://www.example.com and sign in using a preconfigured user account of demouser / Password1 that is included in the deployment. Once authenticated, test other operations including multi-tab browsing, API calls, or navigating back to the app from an email link.

OpenID Connect Integration

The SPA implements security via simple REST requests to the following OAuth Agent endpoints, which result in SameSite=strict cookies being issued:

OperationDescription
POST /login/startPre-processing and download of the authorization request URL
POST /login/endComplete a login from the authorization response returned to the browser
GET /userInfoGet information about the user that the SPA needs to display
GET /claimsReturn claims from the ID token to the SPA, containing authentication related information
POST /refreshAsk the API to rewrite cookies when the SPA receives a 401 from microservices
POST /logoutPre-processing and download of the end session request URL

The SPA's OAuthClient class calls these endpoints using simple one liners:

public async handlePageLoad(pageUrl: string): Promise<any> {

    const request = JSON.stringify({
        pageUrl,
    });

    const response = await this.fetch('POST', 'login/end', request);
    if (response && response.csrf) {
        this.antiForgeryToken = response.csrf;
    }

    return response;
}

public async startLogin(): Promise<string> {

    const data = await this.fetch('POST', 'login/start', null)
    return data.authorizationRequestUrl;
}

public async getUserInfo(): Promise<any> {

    return await this.fetch('GET', 'userInfo', null);
}

public async refresh(): Promise<void> {

    await this.fetch('POST', 'refresh', null);
}

public async logout(): Promise<string> {

    const data = await this.fetch('POST', 'logout', null);
    this._antiForgeryToken = null;
    return data.url;
}

When the SPA wants to perform an OpenID Connect redirect it makes an API request to get the request URL. The SPA is then able to perform its own actions before and after the redirect, so that usability is good. This might include saving the app location and page state before the user logs in, then restoring it upon completion.

Page Loads

The Single Page App is restarted in the browser whenever any of the following actions take place:

  • User initially navigates to the app
  • The response to an authorization request is received
  • Authenticated user refreshes the page
  • Authenticated user opens a new browser tab or window
  • The response to an end session request is received

In each case the SPA sends the OAuth Agent's End Login endpoint its current URL and asks it to process an authorization response if required. This means the SPA itself knows nothing about OpenID Connect, and the implementation can even be changed without redeploying the SPA. The API then returns a response containing login state the SPA needs.

{
    isLoggedIn: true,
    handled: true,
    csrf: '9ntG18Us3XOQeN6pqPDq5V30egfIBZrEBMGolwko2OP3h8Ul02hkrQW4StbOFVx3'
}

The fields returned are summarized in the following table:

FieldUsage
isLoggedInThe SPA can use this to show or hide UI elements, such as Login or Logout menu items
handledThis is set to true when a login has just completed, and the SPA can then perform post login actions
csrfThis token can be sent as a request header during data changing API requests

API Calls

The SPA's ApiClient class sends the secure cookie by setting the withCredentials field to true. On data changing requests the SPA also sends the CSRF token as a request header:

private async fetchImpl(method: string, path: string): Promise<any> {

    const url = `${this.apiBaseUrl}/${path}`;
    const options = {
        url,
        method: method,
        headers: {
            accept: 'application/json',
            'content-type': 'application/json',
        },

        withCredentials: true,
    };

    if (this.oauthClient.antiForgeryToken) {
        options.headers['x-example-csrf'] = this.oauthClient.antiForgeryToken;
    }

    const response = await axios.request(options);
    if (response.data) {
        return response.data;
    }

    return null;
}

When API requests from the SPA reach the reverse proxy, the secure cookie will be decrypted and its access token forwarded to APIs. During an authenticated user session the access token will expire occasionally, so the SPA deals with expiry conditions via the following steps:

  • If a 401 response is received from an API call
  • Then try to refresh the access token, which will rewrite the secure cookie
  • Then retry the API call with the updated secure cookie
  • If the refresh request fails then redirect the user to sign in again
private async _fetch(method: string, path: string): Promise<any> {

    try {

        return await this._doFetchImpl(method, path);

    } catch (e) {

        if (!this._isApi401Error(e)) {
            throw ErrorHandler.handleFetchError('Business API', e);
        }

        await this._oauthClient.refresh();
        try {

            return await this._doFetchImpl(method, path);

        } catch (e) {

            throw ErrorHandler.handleFetchError('Business API', e);
        }
    }
}

Web Content Delivery

The demo app's static content is served in a standard SPA manner via a simple web host. This involves code to write Web Security Headers, then to serve static web content. A real world SPA would need to write additional security headers and possibly perform other tasks as part of serving its content:

app.use((request: express.Request, response: express.Response, next: express.NextFunction) => {

    let policy = "default-src 'none';";
    policy += " script-src 'self';";
    policy += ` connect-src 'self' api.example.com;`;
    policy += " img-src 'self';";
    policy += " style-src 'self' cdn.jsdelivr.net;";
    policy += " object-src 'none'";
    response.setHeader('content-security-policy', policy);
    next();
});

app.use(express.static('./content'));

The code example separates Web and API concerns so that the web backend is very light. This enables the SPA to be deployed anywhere, such as to a Content Delivery Network (CDN), while deeper concerns such as OpenID Connect security are managed by APIs.

Use Financial-grade Security

Once the SPA is using the token handler pattern, it is possible to use financial-grade website security on behalf of the SPA, such as these state-of-the-art features:

In this section we will demonstrate an optional extended setup, where the SPA is pointed to an extended OAuth Agent coded in Kotlin.

Update Prerequisites

First ensure that these additional components are installed:

Also ensure that your license.json file has access to financial grade features of the Curity Identity Server, which will be the case if you have a trial license.

Rebuild Components

Run the build script with an additional parameter to specify a more advanced scenario. This script will also create OpenSSL certificates needed for the advanced security to work.

./build.sh financial

Trust SSL Certificates

Locate the development root certificate authority at resources/financial/certs/example.ca.pem and add it to the system trust store, eg the macOS keychain, so that it is trusted by your browser.

Redeploy Components

Next run the deployment script with an additional parameter, which will deploy the Kotlin token handler instead of the Node.js version:

./deploy.sh financial

Later, when you have finished with the SPA, you can free all Docker resources by running this script:

./teardown.sh financial

Run the SPA

All components now use HTTPS URLs, so sign in to the SPA using https://www.example.com and the test user demouser / Password1. If you run browser tools you will now see that PAR and JARM are used to protect the OpenID Connect request and response messages.

PAR request

Further Information

SPA code is very simple when using the token handler pattern, but the end-to-end setup requires other components to be deployed to support the SPA. See the below resources for further details on how these supporting components work:

ArticleDescription
Code Example DeploymentHow the deployment from this tutorial works, and can be adapted to your own deployment pipeline
Standard OAuth AgentDetails on the standard OAuth Agent and its OpenID Connect behavior
Financial-grade OAuth AgentDetails on the financial-grade OAuth Agent and its advanced OpenID Connect behavior

Video Tutorial

The following video provides a visual walkthrough of how to run the code example and also describes some key points about the token handler components:

Conclusion

The example SPA implements OpenID Connect and then uses only SameSite=strict cookies to call APIs. By using the Token Handler Pattern this can be done with very simple code. You then have the best security options, while maintaining all of the benefits of an SPA architecture.