Challenges Introduced By Browsers For Third-Party SPA OAuth 2 Clients

Summary: Single-page application OAuth2 clients without a backend have to use CORS, and can no longer rely on cookies.

In my previous article, [Building an SSO Client for Your REST APIs with OIDC](https://community.forgerock.com/t/building-an-sso-client-for-your-rest-apis/90), I described how single-page applications (or SPAs) can be built into "the ideal relying party" for a REST API ecosystem. While most of the advice from that article remains accurate and consistent with the best current practices, there are some additional challenges specific to SPA clients that have to be overcome when they operate as a third party. A third-party client is one that is hosted in a different domain than the APIs it calls. This is a very common scenario for OAuth 2 clients. For users of the ForgeRock Identity Cloud, the need to host your SPAs in a different domain from the AS will be especially common.

CORS

One challenge for third-party SPA clients has to do with the way they make network calls. SPAs make heavy use of XMLHttpRequest or "XHR" calls for network interaction. They are fundamental to SPA design. When a SPA makes an XHR call to an endpoint on separate domain than the one it is hosted on, that call is subject to Cross-Origin Resource Sharing or CORS restrictions. Third-party SPA OAuth 2.0 clients must necessarily make XHR requests to the authorization server's token endpoint, as well as to the various resource server endpoints. For this to be possible, the AS and the RS endpoints all have to be configured to allow the specific details of the request (including things such as the SPA origin, the headers, the method, etc.). Fortunately, the option for servers to allow CORS requests for these types of clients is well-documented and widely-supported. ForgeRock Access Management, ForgeRock Identity Management and ForgeRock Identity Gateway all support CORS. When using the ForgeRock Identity Cloud, CORS settings are configurable in the Identity Cloud Admin UI.

Cookies

Besides CORS, the biggest challenge that cross-domain SPA clients have to overcome is the browser's restrictions on cookie usage. Cookies from one domain have always been unavailable for other domains to read directly—this is known as the Same Origin Policy. However, historically, applications in one domain could embed resources (like iframes and scripts), which reside on other domains, and expect that those requests would include whatever cookies have been set for that domain. When used this way, the cookies included in those requests are known as third-party cookies.

A popular pattern for SPA clients is based on third-party cookies—calling the authorization endpoint using the prompt=none parameter within a hidden iframe. This pattern lets a SPA client complete a "silent" grant, so long as a valid session cookie for the authorization server is included in that request. The authorization server session cookie is considered a third-party cookie when used this way. Silent grants are often used to implement these important features for SPA clients:

  • Efficiently obtain the initial access and id tokens when the SPA loads, without full-page redirection.
  • Automatically renew expired access tokens.
  • Periodically poll the authorization server for changes to the session state, checking for logout and resetting idle timeout.

These features work together to provide the SPA user a desirable single sign-on experience. I described all of this in more detail in my previous article on SSO and SPA clients.

Unfortunately for SPA OAuth 2 client developers, the assumption that third-party cookies will be included in the silent grant request is no longer always true. There are two changes to cookie behavior that have emerged in recent years that make this unreliable: SameSite flags and Intelligent Tracking Prevention. These changes address different issues (application security and end-user privacy, respectively), but both work to achieve the same goal: preventing third-party cookies from being included in cross-domain requests.

The SameSite flag on cookies is an instruction from the originating server to inform browsers about the kinds of protections they should take to prevent Cross-Site Request Forgery attacks. If this cookie flag is not specified by the server, the browser is free to choose whatever behavior it deems appropriate for cross-site requests. Newer browsers often choose to use "Lax" by default, which means that the third-party cookie won't be included. OAuth 2 authorization servers that intend to support third-party SPA clients should set their cookies with "SameSite=None"; doing so instructs the browser to include the cookies at all times, and not to try to protect the user from CSRF attacks (presumably the authorization server is using other measures to do so). This (like CORS) is a setting that OAuth 2 authorization server administrators are capable of implementing. In the ForgeRock Identity Cloud, the SameSite=none flag is set for all session cookies.

Important Note: The SameSite=None flag can only be set when using HTTPS and the "Secure" cookie flag. This means you cannot support third-party silent grants over plain HTTP (which is a good thing for security, but may be a "gotcha" for developers).

Intelligent Tracking Preventing (or ITP) presents a harder problem to deal with. This is a privacy feature of some browsers (initially Safari, increasingly adopted by Firefox and others). It is intended to empower users to prevent major media companies from tracking their behavior across numerous websites. It does this by requiring explicit consent from the user, following some interaction with elements embedded by those media companies (e.g. a "Like" button). While this feature is great for end-user privacy, it does create some unique challenges for SPA OAuth 2 clients. There is no sensible equivalent to a "Like" button for an OAuth 2 authorization server to provide for its third-party clients; therefore, there is no easy way to get consent from the user to perform silent grants.

Important Note: Chrome does not yet have support for ITP or the Storage Access API. However, using Chrome in "Incognito" mode does have the option to "Block third-party cookies". Starting with Chrome 83, this feature is enabled by default within Incognito mode. There is nothing a SPA developer can do to get third-party cookies to work in this environment.

Ultimately, this means that unlike the other challenges, there is no server setting available to address this. Silent grants which rely on third-party cookies will be blocked by this browser behavior. So, what can be done about it?

Writing your application

"Knowing is half the battle!" -- G.I. Joe

It is critical that SPA developers write their applications so that they handle silent grant failures gracefully. Before third-party cookie blocking, it was safe to assume that when a silent grant fails, it fails because the user isn't authenticated at the authorization server. With third-party cookie blocking "in the wild", that assumption is no longer true. This mistaken assumption could easily cause users with this enabled to have a very poor experience when using the SPA- looping redirections, immediate logouts, or other intolerable behaviors. Fortunately, there are some techniques for writing SPAs that preserve a relatively friendly experience for those users with blocking enabled. Below are approaches that can be taken for the three major use-cases impacted by this.

Initial token acquisition

The first approach comes nearly for free: when the SPA tries to silently gather the initial access and id tokens, that grant will fail for ITP users. This failure should prompt the SPA to perform a full-page redirection to the authorization endpoint. In the case that the user already has an active session at the authorization server, this is a fairly trivial consequence; the session cookie will be included (since it's no longer a third-party interaction) and so the user will be immediately redirected back to the SPA's redirect URI. Ultimately, for the end-user, it is just a bit slower for the SPA to load (since it may have to do so twice) while the URL flickers between values. If you use AppAuthHelper in your SPA, it will attempt to perform a silent grant and gracefully revert to a full-page authorization code grant if the silent grant fails.

Silent token renewal

In order to handle access token renewal, there is a straightforward (but somewhat controversial) alternative option available. The refresh token grant is designed specifically to support non-interactive token renewal. There are a few reasons why this hasn't been the default approach used by SPAs in the past. Originally, implicit grants were recommended for SPA clients, and implicit grants do not support refresh tokens (so silent grants with prompt=none were the only option). However, using an implicit grant is no longer the considered the best current practice for SPAs; instead, the authorization code grant with PKCE is recommended. Since the authorization code grant supports refresh tokens, this is a viable option to consider. The resistance to using this option within a SPA client is stated by the OAuth 2.0 for Browser-Based Apps draft section on Refresh Tokens:

Refresh tokens provide a way for applications to obtain a new access token when the initial access token expires. With public clients, the risk of a leaked refresh token is greater than leaked access tokens, since an attacker may be able to continue using the stolen refresh token to obtain new access tokens potentially without being detectable by the authorization server.

Browser-based applications provide an attacker with several opportunities by which a refresh token can be leaked, just as with access tokens. As such, these applications are considered a higher risk for handling refresh tokens.

The draft continues on with recommendations for the careful treatment of refresh tokens in this context, particularly by the authorization server. So long as the recommendations are all followed, using a refresh token grant for access token renewal in your SPA is a relatively-safe alternative to the prompt=none silent authorization grant approach. Changing to use this method would impact all users, not just those that have ITP enabled.

AppAuthHelper makes using refresh tokens easy - simply specify renewStrategy: "refreshToken" as part of your initialization parameters.

The biggest downside to this approach (versus the prompt=none silent grant) is that refresh tokens are usually not bound to the session at the authorization server. So a user that is logged out at the AS can continue using a SPA that relies on refresh tokens, even as the access tokens expire. This leads to the next challenge...

Session status change notification and single logout

The last and most difficult change to make for SPA clients is with regard to session management. The assumption that silent grants can be successfully made is the basis for the OIDC Session Management specification on Session Status Change Notification. It is also built into the ForgeRock OIDC Session Check library as the standards-based option. Given third-party cookie blocking, some additional work has to be done for this to work properly.

The most important detail to acknowledge is that with cookie blocking enabled, there is no standard way to silently check the AS session while SPA is being used. Your options are to either check the session only when the application loads (i.e. during a full page refresh) or to use a non-standard technique (see below). If you are restricted to a standards-based approach, the details for exactly how and when to do this while presenting the best possible user experience are tricky. Fortunately, with proper planning (and maybe using ForgeRock front-end libraries) it can be done. Here's how:

  1. Determine if the user is loading the application after having just returned from the authorization server (versus relying on cached tokens in the browser).
  2. As soon as the application has access tokens, it should attempt a silent grant to check the session at the AS.
  3. If (1) is true and (2) fails, then we have detected third-party cookie blocking within the browser. Disable further attempts at session checking.
  4. If (1) is false and (2) fails, clear the cached access and refresh tokens from the browser. Do NOT use a cached id token to perform an RP-initiated logout. Initiate a new top-level authorization code grant. If the user is still logged in at the authorization server, they will be immediately redirected back and then will be in situation (3).

If the application hits (3) and detects that third-party cookies are blocked, the best thing to do is to inform the user about the situation. The application can warn the user that because of their browser settings, they will have to logout of the SPA separately from the AS. The application may also explain that the AS session could experience an idle timeout despite actively using the SPA. This puts control of the situation back to the user, and lets them take the appropriate actions. The SPA should be in a usable state otherwise.

Example implementation using AppAuthHelper and OIDC Session Check

Sometimes code provides the clearest explanation for the steps necessary to account for all of this. This code uses the ForgeRock frontend libraries AppAuthHelper and OIDC Session Check to provide a fully standards-based approach. This is taken from the example OAuth 2 project for Single-Page Apps:

AppAuthHelper.init({
    clientId: "appAuthClient",
    authorizationEndpoint: "https://default.iam.example.com/am/oauth2/authorize",
    tokenEndpoint: "https://default.iam.example.com/am/oauth2/access_token",
    revocationEndpoint: "https://default.iam.example.com/am/oauth2/token/revoke",
    endSessionEndpoint: "https://default.iam.example.com/am/oauth2/connect/endSession",
    resourceServers: {
        "https://default.iam.example.com/am/oauth2/userinfo": "profile openid",
        "https://default.iam.example.com/openidm": "fr:idm:*"
    },
    // Have to use the refreshToken strategy in a third-party cookie context
    renewStrategy: "refreshToken",
    tokensAvailableHandler: function (claims, id_token, interactively_logged_in) {

        var sessionCheck = new SessionCheck({
            clientId: "appAuthClient",
            opUrl: "https://default.iam.example.com/am/oauth2/authorize",
            responseType: "none",
            idToken: id_token,
            initialSessionSuccessHandler: function () {
                // load the main SPA code once we have successfully verified our session
                var mainScript = document.createElement("script");
                mainScript.setAttribute("src", "app.js");
                document.getElementsByTagName("body")[0].appendChild(mainScript);
            },
            invalidSessionHandler: function (reason, request_check_count) {
                // If we just logged in interactively, and yet the session check failed on the first try,
                // it must be because third-party cookies are blocked. Give up on session checking in this case.
                if (interactively_logged_in && request_check_count === 1) {
                    // You could consider warning the user more directly that they won't
                    // have "single log-out" behavior due to their browser settings.
                    console.log("SESSION CHECKING UNAVAILABLE - LIKELY BLOCKED BY BROWSER");
                    sessionCheck.destroy();

                    // load the main SPA anyway, despite being unable to maintain the session.
                    var mainScript = document.createElement("script");
                    mainScript.setAttribute("src", "app.js");
                    document.getElementsByTagName("body")[0].appendChild(mainScript);
                } else {
                    // The session we started with appears to no longer be active. We should delete
                    // the tokens we got from that session, but there is no need to try to end the
                    // session too, since it is apparently already invalid. Make a fresh start at getting
                    // more tokens interactively. Maybe the reason the session check failed was due to
                    // third-party cookie blocking. If so, we will come back here immediately and then hit
                    // the above condition instead.
                    AppAuthHelper.logout({
                        revoke_tokens: true,
                        end_session: false
                    }).then(function () {
                        AppAuthHelper.getTokens();
                    });
                }
            }
        });
        // check the validity of the session immediately
        sessionCheck.triggerSessionCheck();

        // Also check with every captured event
        document.addEventListener("click", function () {
            sessionCheck.triggerSessionCheck();
        });
        document.addEventListener("keypress", function () {
            sessionCheck.triggerSessionCheck();
        });
    }
})
.then(function () {
    // In this application, we want tokens immediately, before any user interaction is attempted
    AppAuthHelper.getTokens();
});

Non-standard options available to trusted clients

The discussion above is in regard to SPAs that are hosted under a separate domain from the authorization server; this simple distinction is enough to classify them as "third-party" as far as browsers are concerned. However, there is an important additional nuance to take into account in order to say that a given client should really be considered a third-party—trust.

If a client application is created by the same organization that owns the authorization server, then it may be considered "trusted". This means that it may be able to do things such as use implied consent for the scopes it requests, rather than having to get explicit permission from the user. The reason this is acceptable is because the authorization server trusts this client to not abuse the authority it has been given over the user's resources. For the same reason, the authorization server can choose to enable non-standard features that rely on having this implied authority. These types of features may let the SPA developer overcome some of the limitations imposed by the browser on third-party clients.

Any time a non-standard feature is built into an application, it necessarily ties that application to the system which provides it. Doing so should only be considered when it is impossible to use the standard approaches, and then with care to isolate the use of the non-standard feature so that it can be re-factored later.

As an example of this, consider the ForgeRock Access Manager (AM) endpoint for Validating Sessions Using REST. This endpoint takes the AM session token and returns it regardless of whether or not the session is valid. If it is valid, it resets the idle timeout and returns some basic information about the session. Note that this request is only possible if the caller has the AM session token; this is a highly-sensitive value that represents the user's full session. Any action that can be taken by the user can be taken by the bearer of this token. For this reason, only clients that are fully trusted by the authorization server should have access to this value. It's important to note that this method does not rely on cookies; instead, it passes the session token as a custom header. This is why third-party cookie restrictions do not prevent it from working.

A trusted SPA client can choose to use this method for session status checking and single logout instead of the standard "silent grant" approach. To do so, AM must be configured to include the session token as an extra claim within the id_token (but only for trusted clients). This can be done using an OIDC Claims Script. Here is a sample modification of the default Groovy OIDC claims script which adds the session token. It will only add the sso_token claim for clients that have requested it (using the sso_token scope), and is only available for those clients that been configured with the allow_sso_token=true custom property:

def getSSOTokenForTrustedClients = {
  if (session && clientProperties.get("customProperties").get("allow_sso_token") == "true") {
    return session.getTokenID().toString();
  } else {
    return null;
  }
}

claimAttributes = [
        "email": userProfileClaimResolver.curry("mail"),
        // others removed for brevity...
        "sso_token":  { claim, identity -> [ "sso_token" : getSSOTokenForTrustedClients() ] }
]

scopeClaimsMap = [
        "email": [ "email" ],
        // others removed for brevity...
        "sso_token": [ "sso_token" ]
]

Note - for this script to work, the Java class `com.iplanet.sso.providers.dpro.SSOTokenIDImpl` must be added to the whitelist for OIDC_CLAIMS engine configuration. See [Script Engine Security](https://backstage.forgerock.com/docs/am/7.1/scripting-guide/script-engine-security.html) for more details. In the ForgeRock Identity Cloud, this should already be configured for you. You also need to enable "Always Return Claims in ID Tokens" (Realms > Realm Name > Services > OAuth2 Provider > Advanced OpenID Connect). Finally, be sure you have added "iPlanetDirectoryPro" to the list of accepted headers for your CORS service.

Once you have this claim in the id_token sent to your SPA, you can change the session check logic to use this proprietary ForgeRock method, like so:

<div class="highlight highlight-source-js position-relative">
// if we are a trusted client we will have the sso_token as a custom claim...
if (claims.sso_token) {
    sessionCheck = new SessionCheck({
        subject: claims.subname,
        // and so we are going to use the sso_token in the ForgeRock-proprietary method for session checking
        ssoToken: claims.sso_token,
        amUrl: "https://default.iam.example.com/am/json/realms/root",
        initialSessionSuccessHandler: function () {
            // load the main SPA code once we have successfully verified our session
            var mainScript = document.createElement("script");
            mainScript.setAttribute("src", "app.js");
            document.getElementsByTagName("body")[0].appendChild(mainScript);
        },
        invalidSessionHandler: function () {
            // Session appears to be invalid; revoke the tokens we have and go back to the OP for fresh ones
            AppAuthHelper.logout({
                revoke_tokens: true,
                end_session: false
            }).then(function () {
                AppAuthHelper.getTokens();
            });
        }
    });
}

This code uses OIDC Session Check to perform the ForgeRock-specific session validation call mentioned above. Using this feature, you can retain a full SSO experience in your cross-domain, cookie-restricted application.

The End Result

Given all of these challenges, should third-party OAuth 2 clients be built using the JavaScript Applications without a Backend architecture? Some smart people argue that they shouldn't be. An alternative pattern for SPA clients is called JavaScript Applications with a Backend (or sometimes known as a BFF/Backend for Frontends); more and more people are arguing for this instead. This pattern does enable the SPA developer to avoid every issue described in this article. However, that pattern does come with it's own challenges; chief among them is the need to maintain a production-ready backend server component. That is not a cheap proposition, regardless of the licensing of the BFF software itself. Another issue that isn't solved by the BFF pattern is idle timeouts for the authorization server session - a solution such as the one described in "Non-Standard Options Available to Trusted Clients" will always be needed for that.

Ultimately, it is up to you, the SPA developer, to choose the pattern that is right for your client application. I think that with awareness of the above techniques, the "pure" SPA approach remains a viable option with a good user-experience (even for users which have third-party cookies blocked). If you make use of appropriate frontend libraries, I think it even remains pretty easy.

Acknowledgments

Thanks to the authors of these posts for the influence and inspiration they had on my writing of this article: