SPA using Backend for Frontend

SPA using Backend for Frontend

Code Examples / spa-integration

Overview

This tutorial explains the SPA code needed to implement a Backend for Frontend solution, in order to build a web app that meets the following goals:

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

The code needed in the SPA is very simple due to backend components provided by Curity. For further details on the end-to-end solution, see the Backend for Frontend Tutorial.

Technology Neutral

This example demonstrates a coding design pattern where most of the work is performed by APIs. 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 Backend for Frontend 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 BFF 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 BFF 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 BFF 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 a Backend for Frontend API 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 Backend for Frontend Tutorial provides further details.

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