TL;DR

When authenticating with Single Page Applications (SPA) you might encounter the implicit grant flow (or hybrid flow). This method of authenticating is not the most secure of scenarios as it relies solely on browser redirects and the access token are returned as part of the query.

The preferred flow would be the authorization code flow which uses back-channels and client secrets to retrieve access tokens, but this has not been viable for use with SPAs or native desktop/mobile applications due to the fact that you cannot reliably store the client secret securely and thus implicit flow has been the fallback because "its what we have".

Now, with the plans to block third party cookies from browsers, life gets hard with implicit flow. Why this is can be read in the linked article.

Not to drag on too long, let's jump to the solution!

Azure Active Directory now supports using authorization code flow with PKCE (pronounced pixy) - Proof Key of Code Exchange.

This solves the problem with storing the client secret by... not using one. Or, it kind of does.

Before you can request authorization codes using PKCE, you first need to tell Azure AD that this is a SPA by going into your application registration under Authentication and click on Add a platform.

Authorization code flow with PKCE

For every request to issue an authorization code, a code_verifier random string is generated (43-128 characters). This is then hashed, encoded and sent with the request to the /authorize endpoint in the code_challenge parameter.

The request can look like this:

https://login.microsoftonline.com/<tenant_id>/oauth2/v2.0/authorize?client_id=<client_id>&response_type=code&redirect_uri=http%3A%2F%2Flocalhost%2Fgivemethecode&response_mode=query&scope=User.Read openid profile&state=12345&code_challenge=DmrQOrSGUg3q-x52DRkOMhx6S6fDCnU-1sC9ADFBlws&code_challenge_method=S256

The authorization code is then returned (either as a POST to an endpoint or as part of the query string in a redirect) and in order to redeem it for a token, the application needs to send the code and the code_verifier that the original request was submitted with. The /token endpoint then compares the code_verifier with the previous code_challenge and issues an access token if its a match.

Sending the authorization code to the /token endpoint is done using POST and the resulting access token is returned in the body - encrypted over HTTPS and not in the query string as with implicit flow.

fetch("https://login.microsoftonline.com/<tenant_id>/oauth2/v2.0/token", {
  method: "POST", 
  headers: {"Content-Type": "application/x-www-form-urlencoded"},
  body: "client_id=<client_id>&scope=User.Read openid profile&code=<authorization_code>&redirect_uri=http://localhost/givemethecode&grant_type=authorization_code&code_verifier=<unhashed code verifier>"
}).then(res => res.json())
.then(res => {
  console.log("token response:", res);
});

This way, only the client knows the "client secret" aka code_verifier so if someone manages to steal the authorization code in transit (as it may still be sent as part of the query string), they still cannot issue tokens with it.

Easily generate your own PKCE challenge using CyberChef if you want to try it out.

Ok, so all well and good - still not what the title of the post indicates.

Making it work with ASP.NET Core

When creating a ASP.NET Core WebApp using Razor pages, for example, I didn't find a straight-forward way to configure it to use PKCE instead of implicit flow.

The way to do it is to add two configuration parameters to your appsettings.json:

"AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "Domain": "yourdomain.com",
    "TenantId": "<tenant_id>",
    "ClientId": "<client_id>",
    "CallbackPath": "/signin-oidc",
    "UsePkce": true,
    "ResponseMode": "code"
  }

By setting UsePkce to true I figured that would be it, but it still defaulted to implicit flow and asked me to enable id_token in my application registration.

The trick was to add the additional ResponseMode: code as that forces the authorization code flow.

If we now start our application and open our browser we get to our next hurdle - this error message:

An unhandled exception occurred while processing the request.

OpenIdConnectProtocolException: Message contains error: 'invalid_request', error_description: 'AADSTS9002327: Tokens issued for the 'Single-Page Application' client-type may only be redeemed via cross-origin requests.
Trace ID: c7b0b55e-dc79-4453-87c3-4dc952089200
Correlation ID: d484a08a-a397-491f-bf7d-0b6923b0b12c
Timestamp: 2021-02-16 10:08:50Z', error_uri: 'error_uri is null'.

This is because the /token endpoint expects a browser to be making this request and our ASP.NET Core WebApp are using back-channels to retrieve the token securely via a HttpClient. Basically all it does is check that the Origin: header is sent with the request which most browsers will do.

So, to complete it all we need to add that in. But of course, that means we now have to do the configuration in code, instead.

services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
                .AddMicrosoftIdentityWebApp(o => {
                    o.UsePkce = true;
                    o.ClientId = "<client_id>";
                    o.TenantId = "<tenant_id>";
                    o.Domain = "yourdomain.com";
                    o.Instance = "https://login.microsoftonline.com";
                    o.CallbackPath = "/signin-oidc";
                    o.ResponseType = "code";
                    var defaultBackChannel = new HttpClient();
                    defaultBackChannel.DefaultRequestHeaders.Add("Origin", "thisismyapp");
                    o.Backchannel = defaultBackChannel;
                });

As you can see, it doesn't really care what the Origin: header value is, it just needs to be there.

So, to sum up:

  • Drop implicit flow if you can
  • Move to authorization code with PKCE
  • Authorization code with PKCE is targetted for SPA and native apps, but also works for confidential apps such as ASP.NET Core WebApps with back-channel and you don't need to worry about a client secret (bonus!)