CORS OAuth 2.0 response mode for silent prompt=none requests

This guide describes the configuration and setup of a custom CORS response mode to handle silent prompt=none authorisation requests from browser-based applications, also known as Single Page Applications (SPA).

1. Background

An SPA that signs-in users with OpenID Connect or obtains access tokens via OAuth 2.0 can have the need to periodically check with the Connect2id server if the person still has a session or to get a new access token in the absence of a long-lived refresh token credential.

To do this the SPA can make an authorisation request with the optional prompt=none parameter. This parameter tells the Connect2id server to try to fulfill the request silently, without any user interaction, and proceed directly to the authorisation response. If the server doesn't find a valid session for the user or no recorded (long-lived) consent for the requesting client, it will return a login_required, consent_required or interaction_required error.

https://c2id.com/login?
 response_type=code
 &scope=openid
 &client_id=123
 &state=af0ifjsldkj
 &redirect_uri=https%3A%2F%2Fclient.example.org%2Fcb
 &prompt=none
 &id_token_hint=eyJhbGciOiJSUzI1NiIsImtpZCI6IjFlO...

The prompt parameter is part of the OpenID Connect specification, but due to its general usefulness the Connect2id server also supports it in plain OAuth 2.0 authorisation requests.

For an SPA that is already loaded doing periodic top-level browser redirections to the Connect2id server authorisation endpoint for a prompt=none request normally isn't acceptable as it breaks the user experience. Performing the redirection in a hidden iframe doesn't normally work for an SPA either.

The most natural and convenient solution for an SPA is therefore to make a cross-origin (CORS) fetch call to the authorisation endpoint.

The authorisation endpoint of the Connect2id server will need to see any previously set session cookie in the CORS request, so the fetch call must be created with the "include credentials" parameter. There is a snag however -- on the 303 redirection to the redirect_uri of the SPA however the CORS request is going to fail, because the browser is obliged to null the Origin header on a redirection. This means we will need an authorisation response where its parameters are returned in the HTTP entity body, preferably in a JSON object. With a straight GET call all CORS parameters can now successfully pass. Enter our new custom cors response mode.

2. Connect2id server configuration

Let's call this new custom OAuth 2.0 response mode cors.

Declare it in the op.authz.responseModes configuration property so that the Connect2id server will accept it when an OAuth client requests it. Append it at end of the response modes list:

op.authz.responseModes=query,...,cors

To apply the necessary logic for processing prompt=none requests with response_mode=cors also add these two configurations:

To intercept prompt=none requests in the login page:

op.authz.alwaysPromptForAuth=true

To include the redirect_uri, prompt and response_mode request parameters at the authentication step:

op.authz.requestParamsInAuthPrompt=prompt,response_mode

Configure the Connect2id server to require an ID token hint for all prompt=none requests. Since the ID token is an OpenID Connect object this will prevent plain OAuth 2.0 authorisation requests with prompt=none.

op.authz.requireIDTokenHintWithPromptNone=true

If you need to process plain prompt=none authorisation requests two or more clients must not have the same web origin (host / domain name) in the registeredredirection_uris. The security section explains why.

3. Client permission to use the CORS response mode

Since there is no standard client metadata to flag which OAuth clients are permitted to use the CORS response mode we are going to define our own, inside the custom data JSON object.

Example registration for an SPA that is a public OAuth client, meaning it doesn't store credentials for authenticating at the token endpoint:

{
  "redirect_uris"              : [ "https://client.example.org/cb" ],
  "token_endpoint_auth_method" : "none",
  "data"                       : {
    "allow_response_mode_cors" : true
  }
}

The explicit client permission to use the CORS response mode is needed to block potential CORS requests from other client applications in case those suffer some attack. Such an attack can be especially devastating if there are clients using the implicit flow, deprecated in OAuth 2.1, where the access token gets returned via the front-end.

4. Login page

The login page sits on top of the Connect2id server authorisation session API and is responsible for the login and consent UI among other things.

The login page should set the policy of the issued session cookies to SameSite=None; Secure. This will instruct the browser to include the authorisation endpoint cookie in CORS requests when the "include credentials" parameter is set. If the SameSite policy is more restrictive no cookies will be included in CORS requests, even if the credentials parameter is set.

Note, the SameSite policy is a crucial measure for stopping CSRF attacks. When relaxing the SameSite policy from the default Lax to None to allow CORS with credentials, sufficient other measures must be in place to guard against CSRF. See the OWASP cheatsheet for suggestions.

For extra security the login page may choose to have a default SameSite=Lax policy and relax it to None only when a client flagged for the CORS response mode has been authorised in the current user session.

4.2 Authentication step

During the authorisation session with op.authz.alwaysPromptForAuth enabled the Connect2id server is always going to bring up the authentication prompt. This is necessary for the login page to intercept the prompt=none requests.

At the authentication prompt the login page must check if the authorisation request has the prompt=none and response_mode=cors parameters. If they are present the login page must ensure:

  1. That the Origin HTTP request header is present and it matches exactly the origin of the redirect_uri, e.g. for an https://client.example.org/cb redirection URI the origin must equal https://client.example.org.

  2. That the client is permitted to use the mode according to its data.allow_response_mode_cors metadata field.

If those criteria are met the request is allowed to proceed. If not the login page must return an error to the client, with invalid_request as the recommended code and an appropriate error_description.

4.3 Final response step

If the CORS request was allowed to proceed, as explained above, the final response in the authorisation session will be a JSON object with the redirect_uri for the client and the authorisation parameters to return to it.

Example:

{
  "uri"  : "https://client.example.org/cb",
  "code" : "aeL2koh8aeveishooquaeFaex7Eech7g",
  "state": "Uu2ijed0"
}

The login page should take these details to produce an HTTP 200 response, setting two CORS specific headers and including the authorisation response parameters in the body.

The uri parameter needs to be converted to an origin URL to set the Access-Control-Allow-Origin HTTP response header. The Access-Control-Allow-Credentials must be set to true, to tell the browser the cookie(s) were accepted.

Note, the Access-Control-Allow-Origin value must not be set to the wildcard (*) when credentials are involved, in those case the allowed origin URI must be set explicitly.

Example HTTP response:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://client.example.org
Access-Control-Allow-Credentials: true
Content-Type: application/json
Cache-Control: no-store
Pragma: no-cache

{
  "code" : "aeL2koh8aeveishooquaeFaex7Eech7g",
  "state": "Uu2ijed0"
}

5. The OAuth client / OpenID relying party

To make a prompt=none authorisation request with this custom CORS response mode the client simply needs to add the extra required response_mode parameter, to switch from the default redirection behaviour.

https://c2id.com/login?
 response_type=code
 &scope=openid
 &client_id=123
 &state=af0ifjsldkj
 &redirect_uri=https%3A%2F%2Fclient.example.org%2Fcb
 &prompt=none
 &id_token_hint=eyJhbGciOiJSUzI1NiIsImtpZCI6IjFlO...
 &response_mode=cors

The fetch call needs to have the credentials include parameter set, to tell the browser to include the session cookie in the CORS request.

fetch(url, { credentials:"include" }).then(success, failure)

On success, if the echoed state parameter is valid and the client is following the authorisation code flow, the next step is to make a call to the token endpoint to have the code exchanged for the requested token(s).

6. Security considerations

The security of public OAuth 2.0 clients relies on delivering the authorisation code to the client's registered redirection URI, which must match exactly.

The CORS security on the other hand is based on web origin (URI scheme, host and port) matching. This means that web origin matching cannot differentiate between two OAuth clients which redirect_uris differ only by their path component.

To ensure sufficient proof of client identity in CORS prompt=none requests the client must therefore include a previous ID token in the optional id_token_hint parameter, to tie the request to the original front-channel request where the client redirect_uri was matched exactly. This is ensured by enabling the op.authz.requireIDTokenHintWithPromptNone setting.

If the CORS prompt=none requests must also cover plain OAuth 2.0 authorisation requests, where an ID token hint cannot be included, every registered public client must have a redirection URIs with unique host (domain) names.