DEV Community

Cover image for Building a zero dependency PKCE Auth client
Klee Thomas
Klee Thomas

Posted on • Updated on

Building a zero dependency PKCE Auth client

This post goes through how to build a PKCE client for browser using TypeScript based applications with no dependencies. If you want to know more about what PKCE (Proof Key Code Exchange) is you can read my previous post, What Is PKCE.

Before I start on how do to this, as a general rule I would use a client side library provided by a reputable authentication vendor, like the React SDK provided by Auth0 for any production application I was building. If you're interested in what is going on under the hood in libraries like that, or you have a specific use case, or you're just interested in a hands on example of how PKCE works then I'll go through how I implemented this only using what is available in the browser.

The example code for this blog can be found on GitHub

A quick qualification on what I mean when I say zero. This is written in TypeScript so clearly there are some dependencies at play. There are no production dependencies and four dev dependencies,

  1. TypeScript: for adding the wonderful development experience that is types in my code.
  2. Prettier: because formatting should be an afterthought.
  3. ESlint: to help catch stupid mistakes.
  4. Parcel: To bundle the scripts together and serve the sample site.

Steps

PKCE can be broken down into a number of steps. I'll address each of those in turn.

Generate code verifier

The first step is to generate the code verifier that can be used to generate the code challenge and later used to verify that this app was in fact the app that originally requested authentication.

The code verifier is random data presented as a Base64 URL encoded string.

Generate the random data

The browser has some APIs that allow us to generate some random data. For this we need to use the web crypto API available at window.crypto or globalthis.crypto. The function to generate random values is getRandomValues this takes an ArrayBuffer and fills it with random data. For PKCE we need that ArrayBuffer to be a string. We can get this using the String.fromCharCode function. The random code generation function looks like this:

function randomCode(): string {
  let array = new Uint8Array(32);
  array = globalThis.crypto.getRandomValues(array);
  return String.fromCharCode.apply(null, Array.from(array));
}
Enter fullscreen mode Exit fullscreen mode

Base64 url encoding the data

The final step in generating a code verifier is to base64 url encode the random string. To turn our string into base64 we can use the btoa function available in the browsers global scope. To make this base64 string base64 url encoded we need to replace all the "+", "/" and "=" with "-", "_" and "" (empty string) respectively, we can replace these values using a chain of String.replace functions and regular expressions.

The code to base64 url encode a string looks like this:

function base64URLEncode(str: string): string {
  const b64 = btoa(str);
  const encoded = b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
  return encoded;
}
Enter fullscreen mode Exit fullscreen mode

Generate the challenge

The second step in PKCE is to generate the code challenge. This is derived from the code verifier generated in the previous step. To derive it we need to apply the SHA256 hash function to the code verifier string.

Hashing the code verifier with SHA256

To hash the code verifier string we can use the web crypto api provided to us by the browser. The digest function on crypto.subtle will create a hash for us, but we need to pass in the algorithm we want to use and the data as something that implements the BufferSource interface.

For the first argument we want a SHA256 algorithm so we pass in { name : "SHA-256" }. For the second argument we need to change our code verifier string into a TypedArray we can do this using an instance of the TextEncoder class new TextEncoder().encode(s).

const sha256Hash = await crypto.subtle.digest(
  { name: "SHA-256" },
  new TextEncoder().encode(str)
);
Enter fullscreen mode Exit fullscreen mode

Finally we need to base64 url encode our hash which has been created as an ArrayBuffer. To do this we need to jump through a couple of hoops to convert the ArrayBuffer containing our hash to a string that we can base64 using the same mechanism as previously.
Let's start by taking our ArrayBuffer and making it into a TypedArray specifically a Uint8Array: new Uint8Array(hash). From here we can convert that into a number array: Array.from(uint8Array). This can be converted into a string using the String.fromCharCode function String.fromCharCode.apply(null, Array.from(numberArray)). This string can be passed through the same base64 and url encoding functions as were used in the code verifier creation. The final sha256 function looks like this:

const sha256 = async (str: string): Promise<string> => {
  const hashArray = await getCryptoSubtle().digest(
    { name: "SHA-256" },
    new TextEncoder().encode(str)
  );
  const uIntArray = new Uint8Array(hashArray);
  const numberArray = Array.from(uIntArray);
  const hashString = String.fromCharCode.apply(null, numberArray);
  return base64URLEncode(base64(hashString));
};
Enter fullscreen mode Exit fullscreen mode

Store the verifier

We'll need the code verifier after redirecting off to the authentication server so it needs to be stored in between renders. To do this I'll store it in the local storage. The store verifier function looks like this:

export function storeVerifier(verifier: string) {
  localstorage.setItem("verifier", JSON.stringify({ verifier }));
}
Enter fullscreen mode Exit fullscreen mode

Generate the login url

The next to do is to generate the url tha we need to redirect the user to so that they can authenticate.

For this example I'm using Auth0 as the authentication server. Some configuration will need to be done in the authentication server and some of the parameters that we need to include in the url will need to be sourced from the Auth0 dashboard.

We'll be directing the user to login using a url so any information that we need to pass to the authentication server must be passed as a query parameter to do that I'll use the URLSearchParams object to make things easier.

The values I'll be appending as query parameters are:

parameter variable in example notes
client_id auth0ClientId The ID of the Auth0 application / client the user is logging into. Copy from Auth0
audience auth0ApiAudience The ID of the API that the token will be issued to access. Copy from Auth0
response_type Hard coded "code" This has to be code in order to use PKCE.
scope scope The scopes (space delimited) that are being requested. These need to be configured in Auth0 or they'll be ignored
recirect_uri authCallbackUrl This is the url that will be redirected back to. This must be explicitly configured to be allowed in Auth0. In this case it's the same URL as the web app is being served from.
code_challenge challenge The challenge generated previously
code_challenge_method Hard coded "sha256" Auth0 only supports SHA 256 as the challenge method

These values are then appended as a query string to the auth0BaseUrl that is the url for the Auth0 tenant (e.g. https://tenantname.au.auth0.com).

function authLoginUrl(): string {
  const params = new URLSearchParams();
  params.append("client_id", auth0ClientId);
  params.append("audience", auth0ApiAudience);
  params.append("response_type", "code");
  params.append("scope", scope);
  params.append("redirect_uri", authCallbackUrl);
  params.append("code_challenge", challenge);
  params.append("code_challenge_method", "S256");
  const url = new URL(`${auth0BaseUrl}/authorize?${params.toString()}`);
  return url.toString();
}
Enter fullscreen mode Exit fullscreen mode

Now when the user clicks on or is redirected to the URL generated by the authLoginUrl they will be directed to Auth0 as the authentication server. They will the be redirected back to the redirect_url. The next thing to do is to handle that redirect and get an access token.

Handling the redirect

Once the user had completed authenticating at the authentication server then they're redirected back to our app and we have to pick up the code flow in order to get an identity token and access token.

Get the code

The first stage once the user has been redirected back to the client is to get the code. The code is passed back as a query parameter in the url. I'll do this by passing the query string to the URLSearchParams object constructor and checking for the presence of a code parameter. If the code object is there we can exchange it for access and identity tokens. The code to get the code looks like this:

const queryParams = new URLSearchParams(window.location.search);
if (queryParams.has("code")) {
  const code = queryParams.get("code");
  handleAuthorizationCodeExchange(code);
}
Enter fullscreen mode Exit fullscreen mode

Retrieve the code verifier

In order to exchange the code for an access token we are going to need to have the code verifier that was used to generate the code challenge previously. Before redirecting to the authentication server I stored this in local storage so we'll need to retrieve that; localStorage.getItem("verifier")

Exchange the code

To exchange the code for an access token we need to make a POST request back to the authentication server on the <authentication-server-url>/oauth/token endpoint.

The request body needs to include some information to prove that this client should be granted an access token.

parameter variable in example notes
grant_type "authorization_code" hard coded For PKCE this has to be "authorization_code"
client_id auth0ClientId The client id for the Auth0 client/application. This has to be the same as was used when requesting the code
code_verifier codeVerifier The code verifier that was used to generate the code challenge in the previous step. This is retrieved from local storage.
code code The code extracted from the query string
redirect_uri authCallbackUrl The same callback url that was passed when requesting the user authenticate

The request body is going to look like this:

const res = await fetch(`${auth0TokenUrl}`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    grant_type: "authorization_code",
    client_id: auth0ClientId,
    code_verifier: codeVerifier,
    code,
    redirect_uri: authCallbackUrl,
  }),
});
Enter fullscreen mode Exit fullscreen mode

Read the response

A successful response from the authentication server will include a JSON response body that matches this signature:

type TokenResponse = {
  access_token: string;
  refresh_token: string;
  id_token: string;
  token_type: "Bearer";
  expires_in: number;
};
Enter fullscreen mode Exit fullscreen mode

The last thing to do is to parse the fetch response. Here I'm writing it out to the tokens to the DOM, just to show that they've been received.

if (response.ok) {
  const resJson = (await response.json()) as TokenResponse;
  writeToDom(`<p>Got tokens ${JSON.stringify(resJson)}</p>`);
  return;
}
Enter fullscreen mode Exit fullscreen mode

Summing up

In this article I've gone through the steps to authenticate a user using PKCE using only the API's available in the browser. I said it at the top but I'll say it again, unless there is a solid reason to implement your own PKCE I'd strongly suggest using a library to take care of it for you. It's not worth committing to maintaining your own implementation when the libraries are fantastic.

The example code for this blog can be found on GitHub

Top comments (0)