/images/resources/tutorials/writing-clients/spa/spa-using-token-handler.jpg

SPA using Token Handler

On this page

Note

The example uses the Curity Identity Server, but you can run the code against any standards-based authorization server.

This code example explains the code you need to write in your single page application (SPA), to integrate with the token handler. You then get a Backend for Frontend (BFF) solution developed and supported by security experts at Curity. The example SPA uses standards-based OpenID Connect, after which HTTP-only SameSite=strict Secure cookies are issued to the browser. The SPA then calls APIs directly, using a cookie to transport its access token.

Components

The example provides an end-to-end solution with a number of components. The difficult security is implemented by plugging in token handler components provided by Curity.

Token Handler

Deploy the Code Example

First, clone the GitHub repository at the top of this page and ensure that your local computer meets the prerequisites explained in its deployment instructions. A Docker Compose based deployment is used, which can be run in two main ways, depending on the authorization server you are using. The deployment is explained in depth in the Token Handler Deployment Example tutorial.

External Authorization Server

The example SPA can use any standards-based authorization server. An example is provided, using Keycloak. Run the example with the following commands:

bash
123
export DEPLOYMENT='external'
./build.sh
./deploy.sh

The SPA then uses an OAuth 2.0 and OpenID Connect based flow that includes PKCE. The OAuth Agent acts as a confidential client for the SPA and uses a client secret when getting tokens from the token endpoint.

Curity Identity Server

You can use the Curity Identity Server as your authorization server, by running the following commands. The Create a Token Handler tutorial explains how the OAuth Agent can then use stronger security options.

bash
123
export DEPLOYMENT='curity'
./build.sh
./deploy.sh

Run the SPA

Once the system is deployed and backend components are ready, you can browse to the SPA at http://www.product.example and exercise all lifecycle operations. Initially, the SPA does not yet have a cookie so presents an unauthenticated view that prompts the user to sign in:

Unauthenticated View

The SPA then implements a code flow, where you can login using a pre-shipped user account of demouser / Password1. The SPA then presents an authenticated view from which the SPA can call APIs:

Authenticated View

The code example is a simple app coded in React that only renders text to explain the main behaviors. Any SPA developer could migrate the SPA to their preferred frontend technology stack.

Code Setup

When integrating with the OAuth Agent, you first need to understand some setup aspects.

Cross Origin Requests

During development, the SPA makes same-site cross-origin requests to token handler components. Unless you send custom headers, OAuth Agent requests are considered safe for CORS and do not require pre-flight requests. When interacting with its BFF, the SPA must consent to calling a different origin and sending its cookie credential there:

javascript
1234567891011121314151617181920
const oauthAgentBaseUrl = 'https://bff.product.example/oauthagent/example';
function callOAuthAgent(method, path, headers, content) {
const init = {
mode: 'cors',
credentials: 'include',
method: method,
headers: headers,
};
if (content != null) {
init['body'] = content;
}
const response = await window.fetch(`${oauthAgentBaseUrl}${path}`, init);
if (response.ok) {
return await response.json();
}
}

Token Handler JS Assistant

When your SPA calls the OAuth Agent, we provide a small helper library called the Token Handler JS Assistant, which you install using the following command:

bash
1
npm install @curity/token-handler-js-assistant

The Token Handler JS Assistant provides an OAuthClient object with typed inputs and outputs to OAuth Agent endpoints. The library is very lightweight and its usage is optional. If you prefer, you could instead implement its logic using your own wrapper object, for example to use a different fetch library.

Session Endpoint

When your SPA loads, its JavaScript code cannot determine whether the SPA has any HTTP-only cookies. Instead the SPA can ask the OAuth Agent for the user authentication status by making a GET request to the /session endpoint. This returns a JSON response with a payload represented by the following TypeScript interface:

typescript
1234
export interface SessionResponse {
readonly isLoggedIn: boolean;
readonly idTokenClaims?: any;
}

Page Load Performance

In some cases, you might be able to avoid calling the session endpoint on every page load. For example, you might prefer to just call APIs and handle any missing or expired cookie responses. Or you might call the endpoint when an SPA first loads, then set a boolean isLoggedIn value in local storage so that you avoid calling the session endpoint when opening new browser tabs.

Implementing a Code Flow

The SPA implements logins by running an OpenID Connect Code Flow. This requires some code to send an authorization request using the system browser, then process the authorization response so that tokens are issued.

Start Login Endpoint

When the SPA determines that the user is not authenticated, it should prompt the user to trigger authentication. For example, the SPA might present a login required view with a Sign In button. When clicked, the SPA should make a POST request to the /login/start endpoint to get the authorization request URL.

In the following code, the SPA updates its location to perform a full window redirect. It is also possible to open a popup window at the authorization request URL. If required, you can include an options object when calling startLogin that contains additional OpenID Connect request parameters.

javascript
12
const response = await oauthClient.startLogin();
location.href = response.authorizationUrl;

An example authorization request URL is shown below. At this point the OAuth Agent also writes a temporary HTTP-only cookie containing state needed for the authorization flow.

text
12345678
https://login.example.com/oauth/v2/oauth-authorize?
client_id=spa-client&
response_type=code&
state=CbEHlnQm1mXzSGetLGyz71NNqO1JVRnc&
redirect_uri=https://www.product.example/callback&
scope=openid profile&
code_challenge=SW-exv5SvCB9XBrWWNaL6BjFxKPCSmrsh8H01S3j7y0
&code_challenge_method\u003dS256"

End Login Endpoint

After a successful login, the authorization server issues a third-party SSO cookie to the browser and returns an authorization response to the SPA's redirect URI, at a location like https://www.product.example/callback. On success, the authorization response contains an authorization code.

To complete the login, the SPA must make a POST request to the /login/end endpoint. This is done by sending the SPA's query string to the OAuth Agent. On success, the SPA again receives a session response object, containing isLoggedIn and idTokenClaims fields.

javascript
12
const url = new URL(location.href);
const sessionResponse = oauthClient.endLogin({ searchParams: url.searchParams });

The OAuth Agent processes the authorization response and makes standard checks like ensuring that the response state matches the request state generated earlier. The authorization code, a PKCE code verifier and a client credential are then sent to the authorization server's token endpoint. The OAuth Agent validates the ID token and then encrypts tokens into first-party HTTP-only SameSite=strict Secure cookies, that cannot be accessed from JavaScript code, and returns them to the browser.

Page Load Function

When you implement logins using a full window redirect, processing the authorization response is part of your page load logic. The Token Handler JS Assistant provides a helper method that you can call if you prefer. This method calls /login/end when it detects that the current URL is an authorization response, or /session otherwise:

javascript
1
const sessionResponse = oauthClient.pageLoad(location.href);

Pre-Login and Post-Login Logic

In some cases you may need to maintain state such as an application path before and after redirecting the user. If required, you can store state in session storage before triggering the authorization redirect:

javascript
123
sessionStorage.setItem('application-path', new URL(location.href).pathname);
const startLogin = await oauthClient.startLogin();
location.href = startLogin.authorizationUrl;

You can restore application state after processing an authorization response. You can detect whether a login response has been handled by inspecting whether the SPA is located at its callback path. Then, navigate back to the pre-login location to remove OAuth parameters from the browser's address bar.

javascript
1234
const sessionResponse = oauthClient.pageLoad(location.href);
if (new URL(location.href).pathname === '/callback') {
history.replaceState({}, document.title, sessionStorage.getValue('application-path'));
}

In this manner the SPA can implement deep linking. For example, a user can bookmark a location in your SPA. If the user returns to that location in a new browser session, when they are not yet authenticated, the SPA can trigger a login and then return to the correct location.

Using ID Token Claims

After login, or on page load, the SPA can, if required, use ID token claims from its session response. These claims inform the SPA how and when the user authenticated. You can optionally issue OpenID Connect userinfo to the ID token, such as the user's given_name and family_name. When required, the SPA can extract these values from the session response and render them.

Calling APIs

Once the user is authenticated, the SPA can make direct calls to its APIs, or to the authorization server's userinfo endpoint. The SPA sends its proxy cookie as an API message credential, where the cookie is used to transport an access token. The cookie content is encrypted so that a user cannot retrieve the access token from the HTTP cookie header.

You must route to all API endpoints using the token handler base URL, since the browser will not send the SPA's same site cookies to other domains. When the cookie reaches your API gateway, the OAuth Proxy plugin decrypts it and forwards the JWT access token to your APIs. You call APIs in the same way as you call the OAuth Agent:

javascript
1234567891011121314151617181920
const apiBaseUrl = 'https://bff.product.example/api';
function callApi(method, path, headers, content) {
const init = {
mode: 'cors',
credentials: 'include',
method: method,
headers: headers,
};
if (content != null) {
init['body'] = content;
}
const response = await window.fetch(`${apiBaseUrl}${path}`, init);
if (response.ok) {
return await response.json();
}
}

Token Refresh

After a while, the access token expires and the API returns an error response with a HTTP 401 status code. The SPA can then implement token refresh to rewrite cookies with new tokens, so that the access token is no longer expired. The SPA can then retry the API request.

The SPA implements token refresh by making an empty POST request to the OAuth Agent's /refresh endpoint. The OAuth Agent then decrypts the session cookie to get the refresh token and sends it to the token endpoint of the authorization server, along with the client credential. On success, the OAuth Agent rewrites cookies to contain any new tokens returned from the token endpoint.

Token refresh is managed client side, so that the SPA is in full control of its own reliability. For example, if the SPA renders a tree of views that call APIs concurrently, the SPA can synchronize token refresh, by queueing up a collection of promises, but making the refresh call only once, then resolving all promises. This prevents race conditions when one-time use refresh tokens are used.

Session Expiry

Eventually, the refresh token expires and the call to POST /refresh returns an error response with an HTTP 401 status. The SPA must interpret this as a session expired error and trigger re-authentication, such as by navigating to a login required view.

When the SPA calls APIs, we recommend implementing some shared logic of the following form, to manage token refresh, retrying API requests and handling session expiry. This resiliently handles all of the main reasons for cookie and tokens being rejected.

javascript
12345678910111213141516171819202122232425262728293031323334353637
private async makeApiRequest(method, path, headers, content) {
try {
return await callApi(method, path, headers, content);
} catch (e1) {
if (e1.status !== 401) {
throw e1;
}
try {
await oauthAgent.refresh();
} catch (e2) {
if (e2.status == 401) {
throw new SessionExpiredError();
}
}
try {
return await callApi(method, path, headers, content);
} catch (e3) {
if (e3.status !== 401) {
throw e3;
}
throw new SessionExpiredError();
}
}
}

Logout

The SPA can enable users to logout explicitly by running an OpenID Connect RP Initiated Logout. This is done by making a POST request to the OAuth Agent's /logout endpoint. The OAuth Agent expires the browser's OAuth Agent cookies and also returns an end-session request URI that the SPA can redirect to, in order to expire SSO cookies from the authorization server.

javascript
12
const response = oauthClient.logout(location.href);
location.href = response.logoutUrl;

An example end-session request URL is shown below. The SPA typically provides a post_logout_redirect_uri to inform the authorization server of a location to return to within the app. In this example, the SPA returns to its login required view after logout.

text
123
https://login.example.com/oauth/v2/oauth-session/logout?
client_id=spa-client&
post_logout_redirect_uri=https://www.product.example/loginrequired

Error Responses

The SPA must handle error responses from the OAuth Agent, the OAuth Proxy and its own APIs. The OAuth Agent returns errors as a response object containing error_code and detailed_error fields. The latter is only returned when the Expose Detailed Error Messages is activated in the admin UI for the Applications Profile. The Token Handler JS Assistant returns a OAuthAgentRemoteError object containing these fields, which the SPA can display in an error view.

OAuth Proxy errors may also occur, if for example the proxy is configured incorrectly or fails to decrypt cookies for some reason. OAuth Proxy errors are returned as a generic error with an HTTP status code. Error details for each specific proxy implementation are explained in the OAuth Proxy Tutorials.

We recommend rehearsing various types of errors during development, to ensure that your SPA deals resiliently with errors from all of its backend components. When you need to troubleshoot backend components, the Token Handler Deployment Example explains how you can look up logs for the OAuth Agent and OAuth Proxy components.

Conclusion

The token handler enables you to harden your SPA's security. You do so by deploying backend for frontend components that deal with the OAuth and cookie security. Your SPA then uses only lightweight API requests to backend components. By following our code example you can get integrated quickly and reliably, and then use production-like cookie security for all future SPA development.

Join our Newsletter

Get the latest on identity management, API Security and authentication straight to your inbox.

Start Free Trial

Try the Curity Identity Server for Free. Get up and running in 10 minutes.

Start Free Trial