wissel.net

Usability - Productivity - Business - The web - Singapore & Twins

Authentication with PKCE and vanilla JavaScript


Finally you got rid of user management in your application since your organisation has standardized on an IdP. There are plenty to choose from. I usually develop using a Keycloak IdP before I test it with the target IdP (and yes: Azure Entrada tries to be different).

So it's time to adjust your SPA to use the IdP. The admins tell you, you need to use PKCE, part of the OIDC specification

Where is the fun in ready made?

A quick search on npmjs shows, the topic is popular and AWS takes the crown. But for deeper understanding, let's roll our own.

Prerequisites

There are just four items you need. The first three are provided by your IdP admins, the forth one you have to provide to them.

  • The URL of your IdP, the full path where .well-known can be found. That's usually the root, but does differ for Entrada or Keycloak
  • The issuer. That typically is the URL, except for Entrada
  • The clientId. A String, usually opaque
  • The callback urls. Pick those wisely. Typically you want three: http://localhost:3000/myApp, https://localhost:8443/myApp and https://final.place.com/myApp The first two enable you to test your application locally with both http and https

Step 1 - Generate codeVerifier and codeChallenge

They are used to secure the call in the PKCE flow

const generateCodeVerifier = (length = 128) => {
  const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
  let result = '';
  const randomValues = new Uint8Array(length);
  window.crypto.getRandomValues(randomValues);
  for (let i = 0; i < length; i++) {
    result += charset.charAt(randomValues[i] % charset.length);
  }
  return result;
};

const generateCodeChallenge = async (codeVerifier) => {
  const encoder = new TextEncoder();
  const data = encoder.encode(codeVerifier);
  const digest = await window.crypto.subtle.digest('SHA-256', data);
  return base64UrlEncode(digest);
};

const base64UrlEncode = (buffer) => {
  return btoa(String.fromCharCode(...new Uint8Array(buffer)))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
};

Step 2 - Query the IdP's .well-known/openid-configuration

We will retrieve the token endpoints and the issuer.

const getIdp = async (url) => {
  const response = await fetch(url);
  const json = await response.json();
  return json;
};

Step 3 - Creating the URL for triggering login

const loginUrl = (config, codeChallenge, state) => {
  const params = new URLSearchParams({
    client_id: 'yourClientId',
    redirect_uri: 'http://localhost:3000',
    response_type: 'code',
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
    scope: 'openid email',
    state: state
  });

  return `${config.authorizationEndpoint}?${params}`;
};

Step 4 - Putting it together, trigger login

const configUrl = `https://your.keycloak/realms/demo/.well-known/openid-configuration`;
const codeVerifier = generateCodeVerifier();
const state = generateCodeVerifier(32);
const codeChallenge = await generateCodeChallenge(codeVerifier);
const config = await getIdp(configUrl);
window.location.href = loginUrl(config, codeChallenge, state);

Step 5 - Processsing the return

At our callback location we can collect code, iss and state to then exchange the code for tokens.

const tokenFromCode = async () => {
  const urlParams = new URLSearchParams(window.location.search);
  const code = urlParams.get('code');
  const iss = urlParams.get('iss');
  const state = urlParams.get('state');

  const tokenResponse = await fetch(token_endpoint, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded'
    },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      client_id: 'yourClientId',
      code,
      redirect_uri: 'http://localhost:3000',
      code_verifier: codeVerifier,
      state
    })
  });
  if (!tokenResponse.ok) {
    throw new Error(`IdP login failed ${tokenResponse.status}:${tokenResponse.statusText}`);
  }
  const json = await tokenResponse.json();
  return json;
};

The returned json contains the access_token and, depending on the IdP the refresh_token. Store them local and use them for Bearer authentication.

As usual YMMV. You want to add error handling and loggin.


Posted by on 01 September 2025 | Comments (0) | categories: JavaScript WebDevelopment

Comments

  1. No comments yet, be the first to comment