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

SPA using Token Handler

On this page

Note

By default, the example SPA 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

You can run the code example on a development computer if you follow the Token Handler Deployment Example. That tutorial explains the role and configuration settings for each component. It also shows how API gateway plugins implement the cookie security in front of your APIs, so that you avoid the need for a cookie issuing web backend.

Code Setup

When integrating with the OAuth Agent, you first need to understand some setup aspects. First, clone the GitHub repository at the top of this page and view the SPA code in your preferred editor.

Token Handler Requests

During development, the SPA makes requests to token handler endpoints, which implement cross site request forgery protections. The SPA must provide credentials: include to allow the browser to send its HttpOnly SameSite=strict Secure cookies. It must also send the token-handler-version=1 custom header to enforce CORS protections:

javascript
123456789101112131415161718192021
const oauthAgentBaseUrl = 'https://bff.product.example/oauthagent/example';
function callOAuthAgent(method, path, headers, content) {
headers['token-handler-version'] = '1';
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

We provide a small helper library called the Token Handler JS Assistant, that simplifies calls from the SPA to the OAuth Agent. You can you install the library with 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 user is authenticated. 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
12345
export interface SessionResponse {
readonly isLoggedIn: boolean;
readonly idTokenClaims?: any;
readonly accessTokenExpiresIn?: number;
}

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
123456789101112131415161718192021
const apiBaseUrl = 'https://bff.product.example/api';
function callApi(method, path, headers, content) {
headers['token-handler-version'] = '1';
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.

You can optionally implement background token refresh, to reduce the number of 401s the SPA receives from APIs. Both the SessionResponse and RefreshResponse objects include an accessTokenExpiresIn field representing the current number of seconds until the access token expires. The following example code shows how you might implement background token refresh:

javascript
12345678910111213
public async handlePageLoad(pageUrl: string): Promise<SessionResponse> {
const sessionResponse = await this.oauthAgentClient.onPageLoad(pageUrl);
if (response.accessTokenExpiresIn > 10) {
setTimeout(this.refresh, response.accessTokenExpiresIn - 10);
}
return sessionResponse;
}
public async refresh(): Promise<void> {
const response = await this.oauthAgentClient.refresh();
setTimeout(this.refresh, response.accessTokenExpiresIn - 10);
}

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 an 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