SPA using the Token Handler Pattern

SPA using the Token Handler Pattern

Code Examples / spa-integration

Overview

This tutorial explains the SPA code needed to implement a Backend for Frontend (BFF) solution, with the best separation of web and API concerns, in order to satisfy the following goals.

  • Recommended Browser Security
  • Simple SPA Code
  • Deploy Anywhere
  • Good User Experience

For background see the Token Handler Pattern article, or run a fully integrated solution by following the SPA with Token Handler Tutorial.

Technology Neutral

This example demonstrates a coding design pattern where almost all of the OAuth work is done by API components provided by Curity. The SPA is implemented in Typescript and React but its code is very simple. It is therefore straightforward to port the code to any other web technology of your choice.

Get the Code

Get the code via the following commands and open the folder in a code editor:

git clone https://github.com/curityio/web-oauth-via-bff
cd web-oauth-via-bff/code/spa

The two most important source files from a security viewpoint are the OAuthClient and ApiClient classes, which deal with authentication and data access:

SPA Code

OAuth Integration

The OAuth 2 / OpenID Connect integration is done by making simple REST requests to the following API 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
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, as in the following code snippet:

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;
}

The UI receives OAuth Request URLs from the API, but is able to control when redirects occur, and to perform its own actions before and after, 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 API’s End Login endpoint its current URL and asks it to process an OAuth response if required. This means the SPA itself knows nothing about the OAuth details, which 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 _doFetchImpl(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 here:

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'));

That is, the code example separates Web and API concerns:

  • The web backend is very light so that the SPA can be deployed anywhere
  • Deeper concerns such as OpenID Connect security are managed by APIs

Conclusion

The example SPA implements OpenID Connect and then uses only SameSite=strict cookies to call APIs. By using the Token Handler Patterm this can be done with only simple code. Although the browser code is simple, the end-to-end setup requires other components to support the SPA. The Token Handler End-to-End Tutorial enables readers to run a complete solution locally.

Keep up with our latest articles and how-tos RSS feeds.