Skip to content
Will Fox
LinkedInGitHubHomepage

Implementing OAuth PKCE Flow with Azure Active Directory

Auth, Azure3 min read

This page is intended to be a set of steps and notes on implementing a custom PKCE Flow with Azure Active Directory. I'll share links, useful functions, and lessons learned. Eventually, I hope to link to the actual source code, but for now it will be all contained here.

Pre-requisites

You must have an Azure AD tenant and the ability to create a new Registered Application in that tenant. The app registration process with provide you with both a tenantId and a clientId required to authenticate against your AD instance.

PKCE Flow

There are four high-level steps involved in the PCKE authorization flow. Below, I will discuss each step in greater detail, but the general flow is as follows:

  1. The user arrives at the app's login page.
  2. The app generates a random code verifier.
  3. The app uses the verifier to generate a PKCE code challenge, and redirects to the authorization server login page at its /authorize endpoint.
  4. The user logs in to the authorization server and is redirected to the app with an authorization code.
  5. The app requests a token from the authorization server using the initial code verifier and the newly received authorization code via the auth server's /token endpoint.
  6. The authorization server responds with the token (and a refresh token) which can be used by the app to access resources on behalf of the user.
  7. When the token expires, the app can repeat steps 5 and 6, using the refresh token to obtain a new valid token from the authorization server.

In the next sections, I intend to give greater details on these steps, including sample code I have used to implement this flow in a React sample with Azure Active Directory as the identity provider.

Relevant Links

  • Best end-to-end description of the API calls required for AAD implementation
  • Technical but highly useful PKCE Reference Document.

Request an authorization code

The flow begins by making a GET request to the authorization server's /authorize endpoint to obtain an authorization code. First, however, we need to generate a verifier and code challenge which will help secure communication between the app and the authentication server.

Generate the Verifier

The verifier is a high-entropy cryptographically random string with minimum length 43 and maximum length 128.

Such a string can be constructed relatively simply as follows:

1function generateVerifier() {
2 const array = new Uint32Array(28)
3 window.crypto.getRandomValues(array)
4
5 return Array.from(array, (item) => `0${item.toString(16)}`.substr(-2)).join('')
6}

Note: Why do we use window.crypto.getRandomValues() rather than making a call to math.random()? Math.random() actually does not provide cryptographically secure random numbers, while our method does. See more in this blog post and the Mozilla crypto docs.

Credit to Tania Rascia and originally Aaron Parecki for the code snippet above.

Generate the Code Challenge

Next the app will construct a code challenge based on the verifier constructed above. The code challenge is a simple transformation of the verifier, by taking its hash using the SHA-256 algorithm and then encoding the result in Base64 to be passed via the URL parameters.

The sudo-code might look something like the following:

challenge = base64Encode(sha256(verifier))

In reality, the JavaScript implementation should look something like the following:

1async function generateCodeChallenge(codeVerifier) {
2 // Encode verifier using SHA-256
3 var encoder = new TextEncoder();
4 var encodedVerifier = encoder.encode(codeVerifier):
5 var digest = await window.crypto.subtle.digest("SHA-256",
6 encodedVerifier);
7
8 // Convert the result to base64, remove special URL chars, and return
9 return window.btoa(String.fromCharCode(...new Uint8Array(digest)))
10 .replace(/=/g, '')
11 .replace(/\+/g, '-')
12 .replace(/\//g, '_')
13}

Inspiration for this function from Tania Rascia and this sample on GitHub. Had to modify the samples and take bits from each to get it working.

Call the /authorize endpoint

Finally we have the code challenge that we need, and we are ready to construct our call to the /authorize endpoint. To build this request, we need to provide the following parameters:

ParameterDescription
tenantAzure Active Directory tenant ID where your application is registered (from pre-reqs)
client_idClient ID generated by Azure Active Directory when registering the application (from pre-reqs)
response_typeIndicates which auth flow to use. In our case, the value will be code.
redirect_uriURL in your app where you would like to receive authentication responses from the authentication server. Must match one of your redirect URLs provided during App Registration in Azure AD.
scopeA space-separated list of scopes that you want the user to consent to. More info here.
stateA string value (can be a random string) included in the request that is returned in the response as well. Used to prevent cross-site request forgery attacks.
code_challengeOur previously generated code challenge
code_challenge_methodIndicates the method used to encode our code_challenge parameter. In our case, it will be S256.

Finally it is time to build our API call!

1async function buildAuthorizeEndpointAndRedirect() {
2 const tenantId = 'tenant123'
3 const clientId = 'abc123'
4 const redirectUri = 'https://my-app-host.example.com/callback'
5 const scope = 'specific,scopes,for,app'
6 const state = '12345'
7 const verifier = generateVerifier()
8 const challenge = await generateChallenge(verifier)
9
10 // Build endpoint
11 const endpoint = `https://login.microsoftonline.com/
12 ${tenantId}/oauth2/v2.0/authorize?
13 client_id=${clientId}&
14 response_type=code&
15 redirect_uri=${redirectUri}&
16 scope=${scope}&
17 state=${state}&
18 code_challenge=${challenge}&
19 code_challenge_method=S256`
20
21 // Set verifier to local storage
22 localStorage.setItem('verifier', verifier)
23
24 // Redirect to authentication server's login page
25 window.location = endpoint
26}

Once again, a huge shoutout to the great blog post by Tania Rascia for inspiration on this function.

Parsing the response

A successful response from this endpoint will look something like the following:

1GET {redirectUri}?
2code=AwABAAAAvPM1KaPlrEqdFSBzjqfTGBCmLdgfSTLEMPGYuNHSUYBrq...
3&state=12345

You will likely need to parse this response and use the parameters to construct your call to the /token endpoint. Here is how you might go about it:

1function parseAndVerify(state) {
2 // Parse the response parameters
3 const params = new URLSearchParams(window.location.search);
4 const authCode = params.get('code');
5 const returnedState = params.get('state');
6
7 // Verify that state matches
8 if (state == returnedState) {
9 return authCode;
10 } else {
11 // Bad response! Could be result of CSRF. Proceed with care!
12 }
13}

Redeem the code for an access token

Now that we have received an authorization code, we can redeem the code for an access token which allows the application to perform actions on the user's behalf. To build this request, we need to provide the following parameters:

ParameterDescription
tenantAzure Active Directory tenant ID where your application is registered (from pre-reqs)
client_idClient ID generated by Azure Active Directory when registering the application (from pre-reqs)
scopeA space-separated list of scopes that you want the user to consent to. More info here.
codeThe authorization code we received earlier in the flow
redirect_uriURL in your app where you would like to receive authentication responses from the authentication server. Must match one of your redirect URLs provided during App Registration in Azure AD.
grant_typeMust be set to authorization_code for our flow.
code_verifierThe same verifier generated earlier to create our PKCE code challenge

We can now build a URL and submit the request to retrieve our token!

1async function getToken(verifier, authCode) {
2 const tenant = "tenant123";
3 const host = `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`
4 const clientId = 'abc123'
5 const redirectUri = `http%3A%2F%2Flocalhost%3A3000`
6 const grantType = 'authorization_code';
7
8 // Build params to send to token endpoint
9 const params = `client_id=${clientId}&
10 grant_type=${grantType}&
11 code_verifier=${verifier}&
12 redirect_uri=${redirectUri}&
13 code=${authCode}`
14
15 // Make a POST request
16 try {
17 const response = await fetch(host, {
18 method: 'POST',
19 headers: {
20 'Content-Type': 'application/x-www-form-urlencoded',
21 },
22 body: params,
23 })
24 const data = await response.json()
25
26 // Token
27 console.log(data)
28 } catch (e) {
29 console.log(e)
30 }
31}

A successful response will include a JSON body with data similar to the following:

1{
2 "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6Ik5HVEZ2ZEstZnl0aEV1Q...",
3 "token_type": "Bearer",
4 "expires_in": 3599,
5 "scope": "https%3A%2F%2Fgraph.microsoft.com%2Fmail.read",
6 "refresh_token": "AwABAAAAvPM1KaPlrEqdFSBzjqfTGAMxZGUTdM0t4B4...",
7 "id_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJhdWQiOiIyZDRkMTFhMi1mODE0LTQ2YTctOD...",
8}

You can now save the access_token and use it to authenticate calls to relevant services on behalf of the user.

If you have stored the verifier in localStorage, you should immediately delete it when you obtain your token.

1const response = await getToken(localStorage.getItem('verifier'))
2localStorage.removeItem('verifier')

This section was again inspired by Tania Rascia (with changes).

Use the access token

Now that you have an access token, you can use that token to authorize calls against the relevant APIs. To use it, include the token as a Bearer in the Authorization header. For example,

1GET /v1.0/me/messages
2Host: https://graph.microsoft.com
3Authorization: Bearer {access_token}...

Refresh the access token

Access tokens are short lived. Refresh them after they expire to continue accessing resources. You can do so by submitting another POST request to the /token endpoint. Provide the refresh_token instead of the code. Refresh tokens are valid for all permissions that your client has already received consent for.

More information on refresh in the Microsoft Docs.