Implement SSO With Keycloak

Introduction

Authentication is hard. The selection pressure from Cheeto finger basement hackers, sophisticated criminal organizations, and everyone in-between has led to the development of new ideas and standards in web security in recent years. Among these developments are two similarly named standards, OATH (opens in a new tab) and OAUTH (opens in a new tab). OATH is an open standard for Authentication (AuthN), while OAUTH is a standard for Authorization (AuthZ). AuthN is the process of proving a principal is who they say they are and AuthZ is the process of checking if a principal is allowed to perform the operation they are asking to perform. AuthN and AuthZ, together with Accounting (the appropriate logging of AuthN and AuthZ actions), make up the 3 dimensions of the AAA (opens in a new tab) security model.

The focus of this post is to demonstrate how to use the open source Keycloak SSO (opens in a new tab) to implement OATH compliant authentication (AuthN) login flows for a SaaS application using the OpenID Connect (OIDC) protocol. This architecture simplifies identity claims for developers by allowing them to offload AuthN to a hardened service and focus on consuming and validating JWT bearer tokens from the trusted identity service. Additionally, it simplifies operational burden on application users by allowing them to use one identity and credential across many related services. Lastly, for fun, we will configure the realm to only support login with passkeys!

Passkeys?

Passkeys are a modern attempt to replace passwords using an app specific cryptographic key pair that is managed for the user by a platform authenticator. Platform authenticators are passkey API aware password managers that help users automatically set strong, unique credentials for every application while also validating the web app through a process called origin verification. These mutliple touch points allow us to consider passkeys as MFA compliant credentials for compliance standards such as SOC 2, HISTRUST, and ISO 2700x.

Install Keycloak

The Keycloak Documentation (opens in a new tab) describes the Keycloak installation process for many different platforms. I generally run services in container based environments, so I follow the docker/podman instructions. Ultimately, the underlying platform does not matter, as the important configurations are done within a Keycloak realm.

podman run --name keycloak -p 8080:8080 --replace \
    -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=change_me \
    quay.io/keycloak/keycloak:24.0.1 \
    start-dev

Create a Realm

Realms are the main subdivision of user pools in Keycloak. Often, a realm will be dedicated per client company in a multi-tenant hosting scenario or per logical app in an app factory scenario. This example uses the realm name, sso.example.com.

Most user facing apps want some flavor of the following settings applied.

You will also want to ensure that both user and admin events are being saved.

For this example, I want to configure an SMTP connection to simulate a real workflow without passwords.

Configure Authenticators for the Realm

Keycloak is designed to support a handful of different authentication types. Tradtitional architectures are built on top of passwords and OTP MFA devices. In this example, we will disable passwords and OTP authenticators, and only allow login with Passkeys (WebAuthn Passwordless).

Configure passkey flow for the Realm

Keycloak authentication flows give administrators flexibilitiy in providing different authentication mechanisms to end users. We will copy the default browser flow which contains the vanilla username and password form and we replace it with a passkey login.

Name it the passkey-browser flow or something else that makes sense to you.

The order of these authenticators defines the login process workflow. We leave the cookie authenticator, as that allows users to maintain sessions to keycloak. We remove the Kerberos login because we don't like Windows systems flinging credentials around. We allow the Identity Provider Redirect to allow login from upstream providers like Google, Okta, or another Keycloak instance. The next row starts to define the actual form. By default, the browser form supports login with a username, password, and MFA if configured. We delete everthing in that section and add a new step to add a WebAuthn Passwordless Authenticator.

The eventual flow should look similar to the following.

When you are done, you need to bind the authenticator to the browser login flow.

Create a Test User

Now that we have a realm configured, we should create a user to test with. This user should be registered to an email address you control.

Navigate to the new user, go to the Credentials tab, and then click to start a Credential Reset.

Set the reset action to Webauthn Register Passwordless and send the email.

When the email arrives, click the magic link to register the user's passkey.

Keycloak will show a dialog instructing the user to register their passkey,

Click to register the passkey.

Your devices platform authenticator will pop up. This example is on Fedora, so we use the 1password browser extension for passkey support.

Set a name for the newly registered passkey.

A window will confirm that the account was successfully updated!

Test the Passkey

Test the passkey by finding the login link under the SSO clients menu and opening it in a new browser.

Keycloak will present the login page with a Sign in with Passkey button.

Your platform authenticator should pop up offering to use the registered passkey.

We are then successfully authenticated into Keycloak.

We can reconfirm the user only has a passkey configured as a credential.

Create an OIDC client

Now we want to integrate Keyclaok with our web app. Navigate to clients and create a client for the example web application.

set a client name and other information.

Disable the Direct access grant flow and ensure the client is public.

Set valid redirect_uris. My example webapp will run at http://loclhost:8000/keycloak-example-login.html so I set the valid redirect links to be relative to that.

Deploy the web app

My webapp code in HTML looks like the following.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Login with Keycloak</title>
  </head>
  <body>
    <h1>Login with Keycloak</h1>
 
    <button onclick="login()">Login with Keycloak</button>
 
    <div id="emailDisplay" style="display: none;">
      <p>Email: <span id="email"></span></p>
    </div>
 
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/keycloak.min.js"></script>
    <script>
      function login() {
        // Define Keycloak configuration URL
        const keycloakConfigUrl =
          "http://localhost:8080/realms/sso.example.com/.well-known/openid-configuration";
 
        // Fetch Keycloak configuration
        fetch(keycloakConfigUrl)
          .then((response) => {
            if (!response.ok) {
              throw new Error("Network response was not ok");
            }
            return response.json();
          })
          .then((config) => {
            // Construct login URL with email scope
            const loginUrl =
              config.authorization_endpoint +
              "?client_id=example-web-app" +
              "&redirect_uri=http://localhost:8000/keycloak-example-login.html" +
              "&response_type=code" + // Request authorization code
              "&scope=openid"; // Include openid scope
 
            // Redirect to Keycloak login page
            window.location.href = loginUrl;
          })
          .catch((error) => {
            console.error("Error fetching Keycloak configuration:", error);
          });
      }
 
      function displayEmail(email) {
        document.getElementById("email").innerText = email;
        document.getElementById("emailDisplay").style.display = "block";
      }
 
      // Function to parse and display email from JWT
      function parseAndDisplayEmail(token) {
        const decodedToken = JSON.parse(atob(token.split(".")[1]));
        const email = decodedToken.email;
        displayEmail(email);
      }
 
      // Function to store tokens in session storage
      function storeTokens(tokens) {
        sessionStorage.setItem("access_token", tokens.access_token);
        sessionStorage.setItem("id_token", tokens.id_token);
        // Optionally store refresh token if available
        if (tokens.refresh_token) {
          sessionStorage.setItem("refresh_token", tokens.refresh_token);
        }
      }
 
      // Check if the URL contains a code parameter
      const urlParams = new URLSearchParams(window.location.search);
      if (urlParams.has("code")) {
        const code = urlParams.get("code");
        // Once the code is received, exchange it for tokens
        fetch(
          "http://localhost:8080/realms/sso.example.com/protocol/openid-connect/token",
          {
            method: "POST",
            headers: {
              "Content-Type": "application/x-www-form-urlencoded",
            },
            body: new URLSearchParams({
              grant_type: "authorization_code",
              client_id: "example-web-app",
              redirect_uri: "http://localhost:8000/keycloak-example-login.html",
              code: code,
            }),
          }
        )
          .then((response) => {
            if (!response.ok) {
              throw new Error("Failed to exchange code for tokens");
            }
            return response.json();
          })
          .then((tokens) => {
            // Store tokens in session storage
            storeTokens(tokens);
            // Once the tokens are received, extract and display the email from the ID token
            parseAndDisplayEmail(tokens.id_token);
          })
          .catch((error) => {
            console.error("Error exchanging code for tokens:", error);
          });
      }
    </script>
  </body>
</html>

On first load, the page just shows the keycloak login button.

When we click the login button, we are redirected to our Keycloak instance.

We sign in with our Passkey and are redirected back to our web app. However, we are now able to display the users email from the id token.

Further inspection shows that we have a valid JWT from the keycloak server to pass to our APIs.

Conclusion

This example demonstrates how to quickly configure a Keycloak realm to support passkey login and integrate with a simple front end web app. Leveraging this pattern allows us to quickly add standard authentication workflows to web apps that are hardened and support the strongest MFA available.

Nutfield Security has deep experience implementing and improving authentication systems. Please Contact Us to see how Nutfield Security can help you implement Keycloak and Passkeys for your web app today.