Skip to content

Instantly share code, notes, and snippets.

@rchild-okta
Last active June 30, 2022 12:25
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save rchild-okta/424b621c8931c249d6ef964798132224 to your computer and use it in GitHub Desktop.
Save rchild-okta/424b621c8931c249d6ef964798132224 to your computer and use it in GitHub Desktop.
SPAs, OIDC, and ITP

OIDC + SPAs + ITP

Background

Terms

  • OpenID Provider (OP) - OAuth 2.0 Authorization Server that is capable of Authenticating the End-User and providing Claims to a Relying Party about the Authentication event and the End-User (from here).
  • Relying Party (RP) - OAuth 2.0 Client application requiring End-User Authentication and Claims from an OpenID Provider (from here). For the purposes of this doc, this is the SPA.
  • ITP - Intelligent Tracking Prevention
  • SPA - Single Page App
  • OIDC - OpenID Connect
  • Prevalent - link to readme
  • Site - call is "same-site" from this thread. Lots of different names from this thread.

How SPAs currently work with OpenID Connect

Authentication

SPAs use the OIDC Implicit Flow to authenticate a user against an OP and retrieve an ID Token. An example flow for a user that doesn't have a session on either the RP or the OP might look like:

  1. The user clicks a login button in the RP
  2. The RP initiates the implicit flow by redirecting to the OP authorization endpoint with the correct parameters.
  3. The user logs into the OP and grants consent. A session is created on the OP. Safari classifies this as user interaction on the OP in a first-party context.
  4. The OP redirects back to the RP with an ID Token in the hash fragment.

Okta has an extension to OIDC that adds an optional sessionToken parameter to the authorization request. This sessionToken is returned from a separate CORS-enabled Authentication API and gives SPAs a way to build custom login forms to bypass the top-level page redirect to the OP.

  1. The user logs into the SPA using a form that sends cross-origin requests to the Authentication API. This can go through several authentication steps, but returns a sessionToken at the end.
  2. The SPA creates a hidden iframe that initiates the implicit flow and uses prompt=none and the sessionToken to get the ID Token. There is no user interaction on the OP in a first-party context.

Renewing tokens

An ID Token is stamped with an expiration time using the exp claim. When an ID Token expires, SPAs can renew the token by initiating a new authorization request via a hidden iframe:

  1. The SPA creates a hidden iframe that initiates the implicit flow and uses prompt=none to signal a non-interactive flow. There must be an active session on the OP.
  2. The OP redirects back to the RP with a new ID Token in the hash fragment.

An RP may rely on the expiration claim to expire the RP session. However, this doesn't account for users logging out of the OP before the expiration date. To solve this, the draft OIDC Session Management Spec describes an approach to query session state more frequently by postMessaging to a persistent, hidden, check_session_state OP iframe. This iframe depends on either localStorage or a non-httpOnly cookie to store the current session state.

How ITP affects SPA OpenID Connect flows

Classification

ITP classifies a site as prevalent, or having the ability to track users, when the number of unique sites that redirect to or embed it in an iframe reaches a target threshold. Classification statistics are stored on device, so it depends on users accessing multiple services that depend on the site.

OPs are especially vulnerable to getting classified as prevalent because of the way OIDC works:

  • Each distinct site that redirects to the OP authorization endpoint will count towards prevalence. This affects the code, implicit, and hybrid flows.
  • Each distinct site that embeds the OP in an frame to renew tokens or check for session state will count towards prevalence. This affects the implicit flow.

There are two algorithms that define the threshold - Core Prediction and Vector Threshold. Core Prediction uses a machine learning model that's more opaque to reason about, but Vector Threshold gives a good sense of how easy it is to get classified as prevalent:

  • If a site is redirected to by more than 3 unique domains, it is classified as prevalent.
  • If a site is embedded as an iframe in more than 3 unique domains, it is classified as prevalent.

Purging cookies

When a site is non-prevalent, its cookies expire via the standard browser cookie policy - cookies without an Expires directive are deleted when the client shuts down, and cookies with an Expires directive are deleted at the specified date.

When a site is prevalent, in addition to the above:

  • If there has been no user interaction in a first-party context, cookies are purged immediately in a digest cycle that's run once an hour. This means that any OIDC flow that is completely non-interactive, like the Okta sessionToken extension, will no longer work - in that flow, a session cookie that's set in the iframe will at most live for an hour.

  • If there has been user interaction in a first-party context, cookies are not purged as long as the user interacts with the site once every 30 days. Interaction can come from a first-party gesture on the site like clicking a button, or by calling document.requestStorageAccess from within an embedded iframe. If the 30 day window passes without user interaction, cookies are purged.

Access to third-party cookies

When a site is non-prevalent, there is no restriction on third-party cookies for that site - another site can embed it in an iframe and it will have access to any cookies that have been set.

When a site is prevalent, third-party cookies are blocked by default. Storage access is independent of cookie purging - even if the user has interacted with the site and cookies haven't been purged in a first-party context, they still won't be accessible when loaded in an embedded cross-origin iframe.

This means that once an OP is marked as prevalent, a SPA will no longer be able to renew its tokens via a hidden iframe using the standard non-interactive flow. When the iframe navigates to the authorization endpoint, it won't have access to a session cookie to show that the user is already logged in.

Safari 12 does provide a new document.requestStorageAccess method that allows iframes to request third-party cookie access, but it must be called within an interactive flow that depends on a user gesture.

Possible Approaches

Don't trigger ITP by supporting custom url domains

ITP is triggered by cross-site scenarios. If the RP and OP are served from the same site, where a site can mean different subdomains on the same top-level domain, most ITP issues go away. For OPs, this would mean allowing RPs to alias a subdomain on their site to the OP authorization domain via DNS records.

For example, an RP https://rp.com could alias https://login.rp.com to an OP https://op.com, and access the OIDC flows via https://login.rp.com/authorize.

This solves the key pain points around ITP:

  • Cookie access for the same site is always allowed. It's not necessary to call requestStorageAccess from a user gesture, even if the authorization endpoint is served from a different subdomain.
  • There will always be first-party user interaction. Even if the RP is independently classified as prevalent outside of the OIDC flows, it's more likely that there will be user interaction within the 30 day window to prevent cookies from being purged.
  • The RP is less likely to be classified as prevalent than the OP. If all RPs use their own custom domains, the OP is also less likely to be classified as prevalent. And, even if the OP is prevalent, it won't affect the OIDC flows of an RP that has setup a custom domain.

The downside to this approach is that it doesn't address the core problem, and won't support more complex scenarios where a single organization has multiple domains that use the same authorization domain. The more domains this authorization domain needs to support, the closer it looks to a traditional OP and the higher the chance it will be classified as prevalent.

Redesign token renewal to support an interactive flow

Storage Access Methods

Safari 12 introduces two new document methods for interacting with storage in a third-party context - hasStorageAccess and requestStorageAccess.

An OP can use hasStorageAccess in the hidden iframe renewal flow to determine if it is classified as prevalent. If hasStorageAccess is false, the OP is prevalent and the flow will fail without user interaction. In this case, the authorization endpoint can return the interaction_required error response.

The requestStorageAccess method can be used to request access to third-party cookies in the context of a user gesture. The first time this method is called by the OP iframe in the embedding site, Safari will prompt the user for consent to grant cookie access. The response is saved if the user grants access, which means that the prompt is only shown once for the same duration as the 30 day rolling purge window. As long as the user keeps interacting with the site via subsequent requestStorageAccess calls at least once every 30 days, they will not be shown the prompt again.

requestStorageAccess has other constraints:

  • There is no way to know if the prompt will be shown. The RP could rely on the heuristic that it's shown only once upfront and can be extended with subsequent requests, but this won't account for the user using different user agents, clearing their history, or Safari changing its algorithms. This means that any UI element that initiates the user gesture, like a button, must be paired with a description that explains both cases - that the user might see a prompt and what it means to click it, and that it might not always show up.

  • Even if the user has consented to the prompt, requestStorageAccess must always be called within the context of a user gesture. This rules out any non-interactive flows to renew the token.

  • The storage access request applies only to the lifetime of the frame. In the OIDC SPA renewal flow, this is lost each time the iframe redirects internally, and when the hidden iframe is removed from the DOM. This means that with the current OIDC renewal flow, a user will have to interact with the iframe every time the token needs to be renewed because OIDC requires a navigation to the authorization endpoint. It also means that the authorization endpoint cannot have any internal redirects that depend on the session cookie once the user has granted access - if it does, it will require another user gesture and call to requestStorageAccess.

  • There is no way to know if the user has an existing session. It's possible that after the user clicks the button to request storage access, the OP iframe will respond withlogin_required. This means that the user will then have to be redirected to the OP in the top frame to go through a login flow and then get redirected back to the RP, and then once again interact with the iframe to enable storage access.

Prompt for every token renewal

The most straightforward approach is one where users must click a button whenever the ID Token expires. It requires the least changes to the OIDC protocol - the addition of a new prompt value (i.e. storage_access) in the authorization request that signals to the OP that this is an interactive flow within an embedded iframe:

  1. Create a visible iframe that initiates the implicit flow with prompt=storage_access.
  2. The OP iframe calls hasStorageAccess to see if it is prevalent. If hasStorageAccess=true i.e. not prevalent, continue to 5.
  3. Display a description and button to request access in the iframe.
  4. When the button is clicked, call requestStorageAccess. If this rejects, return a new error response storage_access_required.
  5. The session cookie is now accessible. Check the session state and any additional standard checks (i.e. id_token_hint). If the user does not have an existing session, return the login_required error response.
  6. All checks have passed. Generate a new ID Token and redirect back to the redirect_uri.

Once storage access is granted, there could be an optimization to display the login screen in the embedded iframe if the user doesn't have an existing session and the login flow does not require redirects. However, this fails the security smell test - users should not be entering their credentials into an embedded UI from a hidden origin.

Prompt once per session

If the SPA is going to embed a cross-origin iframe to a prevalent OP, it must call requestStorageAccess within the context of a user gesture at least once per session. But, ideally, it wouldn't need to call it on every token renewal like the previous approach. For this to be work, the iframe:

  • Must be long-lived. It cannot be moved, removed or replaced in the DOM after storage access is granted.
  • Cannot navigate with internal redirects, or have its url navigated by the parent frame after storage access is granted.

A possible approach is creating a new OIDC endpoint (or re-tooling the existing authorization endpoint) that uses postMessage to initiate authorization requests and return ID Tokens.

When the page loads:

  1. Create a visible iframe that navigates to this new OP endpoint. If reusing the existing authorization endpoint, one option is to pass only two parameters - response_mode=post_message and prompt=storage_access.
  2. Use postMessage to send the standard authorization parameters to the iframe.
  3. The OP iframe calls hasStorageAccess to see if it is prevalent. If hasStorageAccess=true i.e. not prevalent, continue to 6.
  4. Display a description and button to request access.
  5. When the button is clicked, call requestStorageAccess. If this rejects, postMessage a new error response storage_access_required to the top frame.
  6. The session cookie is now accessible. Check the session state and any additional standard checks (i.e. id_token_hint). If the user does not have an existing session, postMessage the login_required error response to the top frame.
  7. All checks have passed. Generate a new ID Token and postMessage it to the top frame.
  8. Hide the iframe. Do not remove it to preserve storage access.

When the token is up for renewal, it does not require additional user interaction:

  1. Use postMessage to send the standard authorization parameters to the existing hidden iframe.
  2. Storage access is still available. If the session is also still active, generate a new ID Token and postMessage it back.

One of the main concerns with this approach is the difference in security posture - the original OIDC renewal flow can target the token recipient to an exact origin and path (the redirect_uri), but the postMessage approach can only target the origin. However, this may not make a difference in the context of a SPA - in the original OIDC renewal flow, it can already create the hidden iframe and receive tokens via postMessage. Any additional checks - for example, validating the top frame when postMessaging back after the implicit flow redirect - could also be recreated via an XSS attack on the page.

CORS response_mode for authorization endpoint

Todo: Verify this flow in a POC and review WebKit code for any gotchas around third-party cookie sharing in top-frame CORS requests.

Third-party cookies are not blocked when sending CORS requests from the top-frame if the third-party domain allows cross-origin access (verify this!). This can be used to construct a flow that's similar to the previous "prompt once per session" flow, but skipping the middle-man of the iframe and directly sending CORS requests to the authorization endpoint via a new CORS response_mode:

  1. To login, the user must perform a first-party login interaction on the OP. This creates a user session on the OP.
  2. The RP initiates the implicit flow by POST-ing the standard OIDC parameters to a now CORS-enabled authorization endpoint. This would require a new response_mode=cors type, which is briefly discussed as a possible future response_mode here.
    • Todo: Find any previous OIDC working group discussions around a CORS response_mode and see why it's not already standardized.
  • Similar to postMessage from previous example, but skipping the middle man iframe
  • CORS enable the authorization endpoint, and allow for a response_mode=cors option
  • RP can call authorization endpoint directly to get the ID Token in the response body. No redirects.
  • This works because CORS requests are not disallowed by ITP
  • Much simpler than previous flows because no need to worry about iframes, can just post directly.
  • Depends on session cookie, but that's allowed under current ITP rules
  • Has the same security concern as the previous example - CORS also works on an origin and won't have the same granularity with targeting a specific redirect_uri.

Bypass session cookies with a SPA PKCE flow

Add more details here once the flow is finalized

Redesign SPA renewals without redirects or cookies

Response mode - they are actually considering postMessage and CORS.

  • The optimization is to introduce something new to the OIDC flows - constraint is that it can't rely on redirects, should live for the lifetime of that frame. Since it's a SPA, we can assume it lives as long as we're in the app.

    • One option here is to make the authorize call for SPA a CORS request. Has similar properties anyway to the original flow - only depends on a session. CORS cannot restrict to the level we had before (redirects to a specific callback uri), but we do get origins baked in.
  • Motivation - can't use things that are redirects or embedded iframes unless the iframe is on the same site.

  • There is an OIDC flow that handles this for untrusted clients - PKCE code flow for native apps.

  • General idea is setting up a flow to get a refreshToken that can then be subsequently used to get shorter-lived access and id tokens.

  • Main issue is security around storage.

  • One option - cross domain iframe on same site, have to limit what code runs on it and verify that there are no XSS's. Can then limit what's passed out to the embedding frame. Also, need to CORS-enable the token endpoint.

  • Another option is opening up the token endpoint to CORS requests. Third-party cookies are still sent if made from the top level iframe. VERIFY THIS?!

    • Is already similar to what would happen via the iframe redirects.
    • Depends on session on OP the same as the iframe.
    • Would basically follow the same contract as the existing authorization/token stuff, but relying on a session cookie instead of an iframe going through a redirect flow.

Resources

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment