Tutorial 6 - Configuring Passkey

What are we doing?

In this tutorial, we use our Keycloak Custom instance and enhance it to support passkey. First, we create a new realm tutorial_passkey and within it a client client_passkey. Next, we create a registration flow called registration-passkey that allows users to register their accounts with either passkey or password. Then, we create a new authentication flow called browser-passkey that allows users to sign in with either a passkey or a password.

Registration
The user can choose whether to set up a passkey or password for their user account. Password setup requires you to enter a password to complete the registration process. The passkey setup provides two options for creating passkey credentials:

Check the compatibility matrix to see if your devices can be used. The registration process is completed once the passkey has been labeled.

Registration Flow Vertical
Authentication

The user can choose whether to sign in with passkey or password. To sign in with passkey, the user clicks on the button Sign in with Passkey. Two options are provided:

Check the compatibility matrix to see if your devices can be used. The authentication process is completed once the chosen passkey has been successfully unlocked.

Authentication Flow Vertical

Why are we doing this?

“Passkeys are a password replacement that provide faster, easier, and more secure sign-ins to websites and apps across a user’s devices. Unlike passwords, passkeys are resistant to phishing, are always strong, and are designed so that there are no shared secrets.” - FIDO Alliance.

Passkeys are also known as multi-device FIDO credentials and aim to make FIDO authentication more usable and available to users. Passkeys are a new option for users to access FIDO sign-in credentials on multiple devices and even across platforms. A single passkey credential identifies a user account on an online service. Major tech companies, such as Google, Microsoft and Apple, already adopted passkey on their respective platforms. Passkeys can be synchronized on their respective platform services such as iCloud Chain (Apple) or Google Password Manager (Google) and retrieved from other passkey-supporting devices connected to the same account. With Cross-Device Authentication, passkeys available on one device can be also used to sign in on another device. For example, you can sign in from your macOS computer to an account whose passkey credentials were created on your Android phone.

FIDO authentication is enabled through WebAuthn, which is a web-based API. The WebAuthn standard is a universally accepted W3C specification. WebAuthn uses public-key cryptography for authentication, which uses a pair of cryptographic keys: a public key and a private key. The private key is stored on the user’s device, such as a hardware security key, smartphone, or any device with a trusted platform module, while the public key is stored on a server. Databases are no longer attack targets because public keys are useless without the corresponding private keys.
FIDO2 Building Blocks

WebAuthn/FIDO2 standards enable strong and even passwordless authentication between servers, authenticators and browsers.

Requirements

To complete our tutorial you need:

Steps

The steps of this tutorial are grouped around the implementation details of the passkey extension.

To see the up & running result of the custom Keycloak with passkey support, follow the Quick Start section. This is followed by step-by-step instructions on how to register a user account with passkey and also how to sign in.

Quick Start

Please create your own new custom Keycloak project by either cloning or forking the Keycloak-Custom GitHub repo. Checkout the tutorial-passkey branch.

Then open the new repository with your favourite Java IDE. For the IntelliJ IDE, we provide ready to use run configurations. Please refer to our Keycloak Custom Tutorial for more details about each module, as the focus of this tutorial is solely on passkey.

As for all projects based on our custom Keycloak template you need to execute the following 4 commands:

./mvnw clean package
docker-compose -f ./docker-compose/postgres/docker-compose.yml up
./server/run-keycloak.sh
./server/run-keycloak-setup.sh

User Interaction

If you want to see the result from a user perspective, you can use the testing application provided by Keycloak on their website: https://www.keycloak.org/app/. It is worth noting that you have to change the realm to passkey_tutorial and the client to passkey_client as shown below:

Keycloak Testing Application

After clicking the Save and then the Sign in button, you should see the login page as shown below:


Passkey Login Page

Registration

To register a new account, click on the blue Register link. You should see the registration form as shown below:

Registration Form Page

After filling out the form, continue the registration process by clicking on the Register button. You should see the credential selection page, as shown below:


Registration Selection Page

You can choose between 2 options for setting up your account:

  1. Password: To set up a password for your newly created account, click the Setup Password button. You will then be taken to another form where you can set your password.

  2. Passkey: To set up a passkey for your newly created account, click the Setup passkey button. You will then be taken to the security key registration form, as shown below:

    Registration Passkey Page Click the Register button. This will check if your current device’s operating system and browser support creating passkey credentials.

    • If your current device supports passkey creation you will be prompted to create a passkey credential for this domain, as shown below:

      MAC Registration Passkey Page

    • A prompt will appear if your current device does not support the creation of passkeys. You are asked to choose an external physical device with passkey support. Once you have successfully verified your identity with an authorization gesture (fingerprint, facial recognition, etc.), you are allowed to create a passkey credential for this domain, as shown below:

Authentication

You have 2 options for signing in:

  1. Password: Enter your username and click the Continue button and enter your password on the next form to sign in.

  2. Passkey: Click the Sign in with passkey button. This checks whether any passkeys credentials are available on the current device.

    • If passkeys are available, you will be prompted to pick one, as shown below:

      MAC Login with Passkey Page

      Once you have successfully verified your identity with an authorization gesture (fingerprint, facial recognition, etc.), the external device unlocks your passkey to sign in.

      OS & Browser used in this example: macOS Ventura 13.0.1 and Safari 16.1.

    • Cross-Device Authentication: A different prompt will appear if none are available, as shown below:

      Cross Device Login Prompt

      You will be prompted to select an external device (also called roaming authenticator) that has access to your passkey. Next, you need to select the desired passkey (it is possible to have multiple passkey credentials for one domain), as shown below:

      Once you have successfully verified your identity with an authorization gesture (fingerprint, facial recognition, etc.), the external device unlocks your passkey to sign in. In this example, we used an Android device as roaming authenticator.

      OS & Browser & external device used in this example: Ubuntu 22.04.1 LTS and Chrome 109.0.5414.74 as cross-device client (CDA Client) , Android 13.0 as cross-device authenticator (CDA Authenticator).

After a successful login, you should be redirected to the testing application on keycloak, as shown below:

Keycloak Testing Application Signed In

Step 1: Overview

The passkey extension consists of 7 Java classes implementing Keycloak SPIs and 3 FTL templates. The extension provides customized registration and authentication actions that can be plugged into the registration and authentication flow. We structured the extension the same way as proposed in the extensions section of the Keycloak Custom Tutorial. The extension can be found as submodule named extension-passkey in the extensions module.

The Java classes can be found in src/main/java/com/inventage/keycloak and are grouped into subpackages according to their functionality:

In the following sections Step 2 and Step 3, each component is explained in technical detail.

The FTL templates can be found in src/main/resources/theme-resources/templates:

Please note: We are aware that passkey support on Keycloak can be enabled with minimal configuration that is not as extensive as ours. The aim of our passkey extension is to provide a more user-friendly passkey interface that also supports other sign-in options.

Keycloak configuration

In this subsection we provide an overview of all the configuration adjustments we have made in Keycloak. Please note that all of the configurations below are specified by the realm-passkey.json file and executed by the keycloak-config-cli tool.

We created a new realm tutorial_passkey and within it a new client client_passkey. We have replaced both built-in flows with our custom registration-passkey and browser-passkey flows, as shown below:

Keycloak Flows Overview

Next, we had to enable Webauthn Register Passwordless in Keycloak under Required actions in the Authentication settings, as shown below:

Keycloak Required Action Settings

This allows users in this realm to register FIDO credentials (e.g. passkey) as their first authentication factor.

We also had to enable Require resident key in Keycloak under Ẁebauthn Passwordless Policy, Policies in the Authentication settings, as shown below:

Keycloak Webauthn Policy Settings

The term resident key is deprecated. The official term is Client-side discoverable credential. This allows Webauthn to initiate the authentication process without requiring the relying party (in our case, the Keycloak server) to first identify the user. This means that the user does not necessarily need to enter credential IDs (e.g. username) when signing in with passkeys.

Step 2: Registration

We replaced the built-in registration flow with our own registration-passkey as shown below:

Keycloak Browser Flow

In this section, we explain each step’s implementation based on its order in the registration flow:

Registration User Creation with no Account

Our implementation of the FormActionFactory interface is called RegistrationUserCreationNoAccount. We extend the built-in RegistrationUserCreation class (which already implements FormActionFactory) because we want to reuse its validation implementation, but not its success implementation because it creates a user account. As mentioned earlier, we want to delay the creation of the user account as much as possible.

As shown below, we do not entirely rely on the validation method of RegistrationUserCreation, since it does not validate the email address. Email validation is usually done in another FormAction called RegistrationProfile, which is also part of the built-in registration flow. We copied its email validation implementation and additionally call the validation method of RegistrationUserCreation to validate the remaining form data.

Once the submitted form data passes the validation checks, the success method is invoked. Instead of creating a user account, the submitted form data is saved and retrieved later in the registration process.

@AutoService(org.keycloak.authentication.FormActionFactory.class)
public class RegistrationUserCreationNoAccount extends RegistrationUserCreation {
...

//Copied from org.keycloak.authentication.forms.RegistrationProfile as the validation check of RegistrationUserCreation does not check for invalid emails.
@Override
public void validate(ValidationContext context) {
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();

context.getEvent().detail(Details.REGISTER_METHOD, "form");

UserProfileProvider profileProvider = context.getSession().getProvider(UserProfileProvider.class);
UserProfile profile = profileProvider.create(UserProfileContext.REGISTRATION_PROFILE, formData);

//We check if the email address is already in use. If yes, we return an error that is displayed to the user.
try {
profile.validate();
} catch (ValidationException pve) {
List<FormMessage> errors = Validation.getFormErrorsFromValidation(pve.getErrors());

if (pve.hasError(Messages.EMAIL_EXISTS, Messages.INVALID_EMAIL)) {
context.getEvent().detail(Details.EMAIL, profile.getAttributes().getFirstValue(UserModel.EMAIL));
}

if (pve.hasError(Messages.EMAIL_EXISTS)) {
context.error(Errors.EMAIL_IN_USE);
} else
context.error(Errors.INVALID_REGISTRATION);

context.validationError(formData, errors);
return;
}

super.validate(context);
}

@Override
public void success(FormContext context) {
//Following successful filling of the form, we store the required user information in the authentication session notes. This stored information is then retrieved at a later time to create the user account.
Utils.storeUserDataInAuthSessionNotes(context);
}

...
}

Passkey or Password Registration

This step implements the Authenticator SPI and consists of its factory, authenticator and its custom ftl file that renders the selection page.

Factory: PasskeyOrPasswordRegistrationAuthenticatorFactory

Our implementation of the AuthenticatorFactory interface is called PasskeyOrPasswordRegistrationAuthenticatorFactory. The implementation contains mainly the ID of this provider. It also contains the possible requirement options within the registration flow. The following snippet shows an excerpt of the implementation:

public class PasskeyOrPasswordRegistrationAuthenticatorFactory implements AuthenticatorFactory {

public static final String PROVIDER_ID = "passkey-password-selection";
private static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
AuthenticationExecutionModel.Requirement.REQUIRED,
AuthenticationExecutionModel.Requirement.ALTERNATIVE,
AuthenticationExecutionModel.Requirement.DISABLED
};

private static final PasskeyOrPasswordRegistrationAuthenticator SINGLETON = new PasskeyOrPasswordRegistrationAuthenticator();

@Override
public Authenticator create(KeycloakSession session) {
return SINGLETON;
}

@Override
public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
return REQUIREMENT_CHOICES;
}

@Override
public String getId() {
return PROVIDER_ID;
}
...
}

Authenticator: PasskeyOrPasswordRegistrationAuthenticator

Our implementation of the Authenticator interface is called PasskeyOrPasswordRegistrationAuthenticator. Once this step is reached in the registration flow, the authenticate method is called, that will display passkey-or-password-selection.ftl to the user.

Once the user has submitted their choice, the action method is invoked. If the user decides to set up a passkey, we need to create their account. The existence of their user account is required in the built-in WebAuthnPasswordlessRegister. WebAuthnPasswordlessRegister needs to be dynamically added to the registration flow, otherwise this action (if enabled as a required action by default) will also appear at the end when setting up a password.

When the user decides to set up a password, context.attempted() is called, which takes us to the password registration form that is configured as an alternative step.

When the user decides to set up a passkey, context.success() is called, the registration flow is completed and shows as the last step the dynamically added built-in required action WebAuthnPasswordlessRegister that allows the user to register their passkey.

public class PasskeyOrPasswordRegistrationAuthenticator implements Authenticator {

public static final String SETUP_TYPE = "setupType";
public static final String SETUP_PASSKEY = "passkey";
private static final String TPL_CODE = "passkey-or-password-registration.ftl";

@Override
public void authenticate(AuthenticationFlowContext context) {
LoginFormsProvider form = context.form();
// Render passkey-or-password-selection.ftl form to user
context.challenge(form.createForm(TPL_CODE));
}

@Override
public void action(AuthenticationFlowContext context) {
MultivaluedMap<String, String> params = context.getHttpRequest().getDecodedFormParameters();

//The setup type (whether the user wants to set up a passkey or a password) is passed via form parameter
String setupType = params.getFirst(SETUP_TYPE);

AuthenticationSessionModel authenticationSession = context.getAuthenticationSession();

if (setupType.equals(SETUP_PASSKEY)) {
//We create an user from the session notes. The existence of this user is required in WebAuthnPasswordlessRegister.
Utils.createUserFromAuthSessionNotes(context);
if (!authenticationSession.getRequiredActions().contains(WebAuthnPasswordlessRegisterFactory.PROVIDER_ID)) {
//We add the WebAuthnPasswordlessRegister as required action (for registering passkeys) if not already configured in keycloak.
authenticationSession.addRequiredAction(WebAuthnPasswordlessRegisterFactory.PROVIDER_ID);
}
context.success();
} else {
// If the user chooses another setup type (password). We continue with the alternative configured in the registration flow.
context.attempted();
}
}
...
}

Template: passkey-or-password-registration.ftl

Once the step in the registration flow is reached, this page is displayed to the user. The form action defined at the beginning of the file passes the setupType to the action url. With javascript it is first checked if WebAuthn is supported by the current browser. If WebAuthn is not supported, passkey is not supported, so it goes directly to the password registration step by passing password as setupType. Otherwise, setupType is defined by the button pressed by the user. If the Setup Password button is pressed, the setupPassword() function is called, passing password as setupType. The same applies to the Setup Passkey button.

<#import "template.ftl" as layout>
<@layout.registrationLayout; section>
<#if section = "header">
<#elseif section = "form">
<form id="setupAuth" action="${url.loginAction}" method="post">
<input type="hidden" id="setupType" name="setupType"/>
</form>
<div id="kc-form-buttons" class="${properties.kcFormGroupClass!}">
<input tabindex="4"
class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}"
name="login" id="kc-login" type="submit" value="Setup password" onclick="setupPassword()"/>

</div>

<div style="border-bottom: 1px solid; text-align: center; height: 10px; margin-bottom: 10px;">
<span style="background: #fff; padding: 0 5px;">Or</span>
</div>

<div id="kc-form-buttons" class="${properties.kcFormGroupClass!}">
<input tabindex="4"
class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}"
name="login" id="kc-login" type="submit" value="Setup passkey" onclick="setupPasskey()"/>

</div>
</#if>
<script type="text/javascript" src="${url.resourcesCommonPath}/node_modules/jquery/dist/jquery.min.js"></script>
<script type="text/javascript">
// Check if WebAuthn is supported by this browser
// If not redirect to password setup
if (!window.PublicKeyCredential) {
$("#setupType").val("password");
$("#setupAuth").submit();
}
if (document.getElementById("kc-select-try-another-way-form") != null) {
document.getElementById("kc-select-try-another-way-form").style.display = "none";
}
function setupPasskey() {
$("#setupType").val("passkey");
$("#setupAuth").submit();
}
function setupPassword() {
$("#setupType").val("password");
$("#setupAuth").submit();
}
</script>

</@layout.registrationLayout>

Step 3: Authentication

We replaced the built-in browser flow with our own browser-passkey as shown below:

Keycloak Browser Flow

In this section, we explain each step’s implementation based on its order in the authentication flow:

Passkey Login Form

This step implements the Authenticator SPI and consists of its factory, authenticator and its custom ftl file that renders the login page.

Factory: PasskeyLoginFormAuthenticatorFactory

Our implementation of the AuthenticatorFactory interface is called PasskeyLoginFormAuthenticatorFactory that inherits from the WebAuthnPasswordlessAuthenticatorFactory. Unless we inherit from this class, the user profile settings will not display the registered passkey credentials. Keycloak only displays credential types they can detect in our flows. This makes Keycloak aware that our flow also supports FIDO credentials (e.g. passkey) and thus displays the registered passkeys. The implementation contains as usual the ID of this provider. It also contains the possible requirement options within the authentication flow. The following snippet shows an excerpt of the implementation:

public class PasskeyLoginFormAuthenticatorFactory extends WebAuthnPasswordlessAuthenticatorFactory {

public static final String PROVIDER_ID = "passkey-login-form";
private static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
AuthenticationExecutionModel.Requirement.REQUIRED,
AuthenticationExecutionModel.Requirement.ALTERNATIVE
};

@Override
public Authenticator create(KeycloakSession session) {
return new PasskeyLoginFormAuthenticator(session);
}

@Override
public String getId() {
return PROVIDER_ID;
}

@Override
public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
return REQUIREMENT_CHOICES;
}
...
}

Authenticator: PasskeyLoginFormAuthenticator

Our implementation of the Authenticator interface is called PasskeyLoginFormAuthenticator, that inherits from the built-in WebAuthnPasswordlessAuthenticator. Once this step is reached in the authentication flow, the authenticate method is called, which will render our login page passkey-login.ftl to the user. As mentioned earlier, the user can either sign in with passkey or with username & password. To enable passkey login, Keycloak (as the WebAuthn Relying Party) needs to bootstrap the WebAuthn authentication ceremony by generating a challenge that the user needs to cryptographically sign to prove their identity. The challenge value and other necessary parameters for the authentication ceremony will be passed on to passkey-login.ftl. This happens in the createLoginFormWithWebauthnChallenge method, that is for the most part identical to the built-in WebAuthnAuthenticator authenticate method. The reason we can’t simply reuse its code is because we want to render our own ftl file. But by inheriting from WebAuthnPasswordlessAuthenticator that inherits from WebAuthnAuthenticator we can reuse some useful methods.

Once the user data has been submitted, the action method is called. First of all, it is determined with the method isPasskeyLogin whether the user has attempted to sign in with their passkey or with their username.

If the user attempted to sign in with passkey:

If the user wants to sign in with their username:

The implementation and explanation of each method mentioned can be found below this code excerpt:

public class PasskeyLoginFormAuthenticator extends WebAuthnPasswordlessAuthenticator {

private static final String TPL_CODE = "passkey-login.ftl";
private static final String USERNAME_KEY = "username";
private KeycloakSession session;

public PasskeyLoginFormAuthenticator(KeycloakSession session) {
super(session);
this.session = session;
}

// renders initial login page.
@Override
public void authenticate(AuthenticationFlowContext context) {
// The authenticate method is identical with the built-in method of org.keycloak.authentication.authenticators.browser.WebAuthnAuthenticator
// except that we use our own ftl-template "passkey-login.ftl", that renders our custom login page.
LoginFormsProvider form = createLoginFormWithWebauthnChallenge(context);
if (form != null) {
context.challenge(form.createForm(TPL_CODE));
}
}

/**
* Copied from org.keycloak.authentication.authenticators.browser.WebAuthnAuthenticator#authenticate
* Encodes the Webauthn challenge in the ftl template, that will be displayed to the user.
**/

private LoginFormsProvider createLoginFormWithWebauthnChallenge(AuthenticationFlowContext context) {
LoginFormsProvider form = context.form();

Challenge challenge = new DefaultChallenge();
String challengeValue = Base64Url.encode(challenge.getValue());
context.getAuthenticationSession().setAuthNote(WebAuthnConstants.AUTH_CHALLENGE_NOTE, challengeValue);
form.setAttribute(WebAuthnConstants.CHALLENGE, challengeValue);

WebAuthnPolicy policy = getWebAuthnPolicy(context);
String rpId = getRpID(context);
form.setAttribute(WebAuthnConstants.RP_ID, rpId);
form.setAttribute(WebAuthnConstants.CREATE_TIMEOUT, policy.getCreateTimeout());

UserModel user = context.getUser();
boolean isUserIdentified = false;
if (user != null) {
// in 2 Factor Scenario where the user has already been identified
WebAuthnAuthenticatorsBean authenticators = new WebAuthnAuthenticatorsBean(context.getSession(), context.getRealm(), user, getCredentialType());
if (authenticators.getAuthenticators().isEmpty()) {
// require the user to register webauthn authenticator
return null;
}
isUserIdentified = true;
form.setAttribute(WebAuthnConstants.ALLOWED_AUTHENTICATORS, authenticators);
} else {
// in ID-less & Password-less Scenario
// NOP
}
form.setAttribute(WebAuthnConstants.IS_USER_IDENTIFIED, Boolean.toString(isUserIdentified));

// read options from policy
String userVerificationRequirement = policy.getUserVerificationRequirement();
form.setAttribute(WebAuthnConstants.USER_VERIFICATION, userVerificationRequirement);
form.setAttribute(WebAuthnConstants.SHOULD_DISPLAY_AUTHENTICATORS, shouldDisplayAuthenticators(context));
return form;
}

// validates user data transmitted as form parameters
@Override
public void action(AuthenticationFlowContext context) {
// Retrieve parameters from incoming http request.
MultivaluedMap<String, String> params = context.getHttpRequest().getDecodedFormParameters();

// check if user tries to sign in with webauthn(passkey)
boolean isPasskeyLogin = isPasskeyLogin(params);
if (isPasskeyLogin) {
// Check if webauthn failed
boolean webAuthnFailed = hasWebAuthnFailed(params);
if (webAuthnFailed) {
// Webauthn inserts the error in the http request.
String error = params.getFirst(WebAuthnConstants.ERROR);
handleWebauthnError(context, error);
return;
}
// verify webauthn challenge response. If successful, the user has successfully signed in to their user account
verifyWebauthnChallengeResponse(context, params);
return;
}

// user wants to sign in with username and password
String username = params.getFirst(USERNAME_KEY).trim();
UserModel user = retrieveUser(context, username);

// no user with this username has been found
if (user == null) {
handleInvalidUsernameError(context);
} else {
boolean noPassword = hasNoPasswordSetup(user);
if (noPassword) {
handleNoPasswordError(context);
return;
}

//User is required for the built-in password form authenticator to work: org.keycloak.authentication.authenticators.browser.PasswordForm
context.setUser(user);
//Call attempted to signal keycloak to call the next "Alternative" in the authentication flow.
context.attempted();
}
}
...
}
private boolean isPasskeyLogin(MultivaluedMap<String, String> params) {
return (params.getFirst(WebAuthnConstants.CREDENTIAL_ID) != null && params.getFirst(WebAuthnConstants.AUTHENTICATOR_DATA) != null && params.getFirst(WebAuthnConstants.CLIENT_DATA_JSON) != null
&& params.getFirst(WebAuthnConstants.SIGNATURE) != null && params.getFirst(WebAuthnConstants.USER_HANDLE) != null) || hasWebAuthnFailed(params);
}
private boolean hasWebAuthnFailed(MultivaluedMap<String, String> params) {
String error = params.getFirst(WebAuthnConstants.ERROR);
if (error != null) {
return !error.isEmpty();
}
return false;
}
private void handleWebauthnError(AuthenticationFlowContext context, String error) {
LoginFormsProvider form = createLoginFormWithWebauthnChallenge(context);
if (form != null) {
context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS,
form.setError(error).createForm(TPL_CODE));
}
}
// verify webauthn challenge response. If successful, the user has successfully signed in to their user account.
// Copied from org.keycloak.authentication.authenticators.browser.WebAuthnAuthenticator#action. Difference: Error message can be displayed on our custom "ftl"-file
private void verifyWebauthnChallengeResponse(AuthenticationFlowContext context, MultivaluedMap<String, String> params) {
context.getEvent().detail(Details.CREDENTIAL_TYPE, getCredentialType());

String baseUrl = UriUtils.getOrigin(context.getUriInfo().getBaseUri());
String rpId = getRpID(context);

Origin origin = new Origin(baseUrl);
Challenge challenge = new DefaultChallenge(context.getAuthenticationSession().getAuthNote(WebAuthnConstants.AUTH_CHALLENGE_NOTE));
ServerProperty server = new ServerProperty(origin, rpId, challenge, null);

byte[] credentialId = Base64Url.decode(params.getFirst(WebAuthnConstants.CREDENTIAL_ID));
byte[] clientDataJSON = Base64Url.decode(params.getFirst(WebAuthnConstants.CLIENT_DATA_JSON));
byte[] authenticatorData = Base64Url.decode(params.getFirst(WebAuthnConstants.AUTHENTICATOR_DATA));
byte[] signature = Base64Url.decode(params.getFirst(WebAuthnConstants.SIGNATURE));

final String userHandle = params.getFirst(WebAuthnConstants.USER_HANDLE);
final String userId;
// existing User Handle means that the authenticator used Resident Key supported public key credential
if (userHandle == null || userHandle.isEmpty()) {
// Resident Key not supported public key credential was used
// so rely on the user set in a previous step (if available)
if (context.getUser() != null) {
userId = context.getUser().getId();
} else {
setErrorResponse(context, WEBAUTHN_ERROR_USER_NOT_FOUND,
"Webauthn credential provided doesn't include user id and user id wasn't provided in a previous step");
return;
}
} else {
// decode using the same charset as it has been encoded (see: WebAuthnRegister.java)
userId = new String(Base64Url.decode(userHandle), StandardCharsets.UTF_8);
if (context.getUser() != null) {
// Resident Key supported public key credential was used,
// so need to confirm whether the already authenticated user is equals to one authenticated by the webauthn authenticator
String firstAuthenticatedUserId = context.getUser().getId();
if (firstAuthenticatedUserId != null && !firstAuthenticatedUserId.equals(userId)) {
context.getEvent()
.detail(WebAuthnConstants.FIRST_AUTHENTICATED_USER_ID, firstAuthenticatedUserId)
.detail(WebAuthnConstants.AUTHENTICATED_USER_ID, userId);
setErrorResponse(context, WEBAUTHN_ERROR_DIFFERENT_USER, null);
return;
}
} else {
// Resident Key supported public key credential was used,
// and the user has not yet been identified
// so rely on the user authenticated by the webauthn authenticator
// NOP
}
}

boolean isUVFlagChecked = false;
String userVerificationRequirement = getWebAuthnPolicy(context).getUserVerificationRequirement();
if (WebAuthnConstants.OPTION_REQUIRED.equals(userVerificationRequirement)) isUVFlagChecked = true;

UserModel user = session.users().getUserById(context.getRealm(), userId);

AuthenticationRequest authenticationRequest = new AuthenticationRequest(
credentialId,
authenticatorData,
clientDataJSON,
signature
);

WebAuthnCredentialModelInput.KeycloakWebAuthnAuthenticationParameters authenticationParameters = new WebAuthnCredentialModelInput.KeycloakWebAuthnAuthenticationParameters(
server,
isUVFlagChecked
);

WebAuthnCredentialModelInput cred = new WebAuthnCredentialModelInput(getCredentialType());

cred.setAuthenticationRequest(authenticationRequest);
cred.setAuthenticationParameters(authenticationParameters);
String encodedCredentialID = Base64Url.encode(credentialId);

if (user == null) {
context.getEvent()
.detail(WebAuthnConstants.AUTHENTICATED_USER_ID, userId)
.detail(WebAuthnConstants.PUBKEY_CRED_ID_ATTR, encodedCredentialID);
setErrorResponse(context, WEBAUTHN_ERROR_USER_NOT_FOUND, null);
return;
}

boolean result = false;
try {
result = user.credentialManager().isValid(cred);
} catch (WebAuthnException wae) {
setErrorResponse(context, WEBAUTHN_ERROR_AUTH_VERIFICATION, wae.getMessage());
return;
}

if (result) {
String isUVChecked = Boolean.toString(isUVFlagChecked);
context.setUser(user);
context.getEvent()
.detail(WebAuthnConstants.USER_VERIFICATION_CHECKED, isUVChecked)
.detail(WebAuthnConstants.PUBKEY_CRED_ID_ATTR, encodedCredentialID);
context.success();
} else {
context.getEvent()
.detail(WebAuthnConstants.AUTHENTICATED_USER_ID, userId)
.detail(WebAuthnConstants.PUBKEY_CRED_ID_ATTR, encodedCredentialID);
setErrorResponse(context, WEBAUTHN_ERROR_USER_NOT_FOUND, null);
}
}
private UserModel retrieveUser(AuthenticationFlowContext context, String username) {
context.getEvent().detail(Details.USERNAME, username);
context.getAuthenticationSession().setAuthNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME, username);
UserModel user;
try {
user = KeycloakModelUtils.findUserByNameOrEmail(context.getSession(), context.getRealm(), username);
} catch (ModelDuplicateException mde) {
throw new RuntimeException(mde);
}
return user;
}
private void handleInvalidUsernameError(AuthenticationFlowContext context) {
LoginFormsProvider form = createLoginFormWithWebauthnChallenge(context);
if (form != null) {
context.failureChallenge(AuthenticationFlowError.INVALID_CLIENT_CREDENTIALS,
form.setErrors(List.of(new FormMessage(RegistrationPage.FIELD_USERNAME, Messages.INVALID_USERNAME))).createForm(TPL_CODE));
}
}
 private boolean hasNoPasswordSetup(UserModel user) {
List<CredentialModel> credentialModels = user.credentialManager().getStoredCredentialsStream().collect(Collectors.toList());
return !credentialModels.stream().anyMatch((cred -> cred.getType().equals(PasswordCredentialModel.TYPE)));
}
private void handleNoPasswordError(AuthenticationFlowContext context) {
LoginFormsProvider form = createLoginFormWithWebauthnChallenge(context);
if (form != null) {
context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS,
form.setError("This user has not setup a password").createForm(TPL_CODE));
}
}

Template: passkey-login-form.ftl

At the beginning of the browser-flow, this page (custom login page) is displayed to the user. It shows two options for signing in, either with username or with passkey. The source code of this ftl is a mixture from the built-in login.ftl and webauthn-authenticate.ftl files.

<#import "template.ftl" as layout>
<@layout.registrationLayout displayMessage=!messagesPerField.existsError('username') displayInfo=(realm.password && realm.registrationAllowed && !registrationDisabled??); section>
<#if section = "header">
${msg("loginAccountTitle")}
<#elseif section = "form">
<div id="kc-form">
<form id="webauth" action="${url.loginAction}" method="post">
<input type="hidden" id="clientDataJSON" name="clientDataJSON"/>
<input type="hidden" id="authenticatorData" name="authenticatorData"/>
<input type="hidden" id="signature" name="signature"/>
<input type="hidden" id="credentialId" name="credentialId"/>
<input type="hidden" id="userHandle" name="userHandle"/>
<input type="hidden" id="error" name="error"/>
</form>
<div id="kc-form-wrapper">
<#if realm.password>
<form id="kc-form-login" onsubmit="login.disabled = true; return true;" action="${url.loginAction}"
method="post">

<#if !usernameHidden??>
<div class="${properties.kcFormGroupClass!}">
<label for="username"
class="${properties.kcLabelClass!}">
<#if !realm.loginWithEmailAllowed>${msg("username")}<#elseif !realm.registrationEmailAsUsername>${msg("usernameOrEmail")}<#else>${msg("email")}</#if></label>

<input tabindex="1" id="username"
aria-invalid="<#if messagesPerField.existsError('username')>true</#if>"
class="${properties.kcInputClass!}" name="username"
value=""
type="text" autofocus autocomplete="off"/>


<#if messagesPerField.existsError('username')>
<span id="input-error-username" class="${properties.kcInputErrorMessageClass!}"
aria-live="polite">

${kcSanitize(messagesPerField.get('username'))?no_esc}
</span>
</#if>
</div>
</#if>

<div class="${properties.kcFormGroupClass!} ${properties.kcFormSettingClass!}">
<div id="kc-form-options">
<#if realm.rememberMe && !usernameHidden??>
<div class="checkbox">
<label>
<#if login.rememberMe??>
<input tabindex="3" id="rememberMe" name="rememberMe" type="checkbox"
checked>
${msg("rememberMe")}
<#else>
<input tabindex="3" id="rememberMe" name="rememberMe"
type="checkbox">
${msg("rememberMe")}
</#if>
</label>
</div>
</#if>
</div>
</div>

<div id="kc-form-buttons" class="${properties.kcFormGroupClass!}">
<input tabindex="4"
class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}"
name="login" id="kc-login" type="submit" value="Continue"/>

</div>
</form>
<div id="showPasskeyOption">
<div style="border-bottom: 1px solid; text-align: center; height: 10px; margin-bottom: 10px; margin-top: 10px;">
<span style="background: #fff; padding: 0 5px;">Other Sign in Options</span>
</div>
<div id="kc-form-buttons" class="${properties.kcFormGroupClass!}">
<input tabindex="4"
class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}"
name="login" id="kc-login" type="submit" value="Sign in with Passkey"
onclick="webAuthnAuthenticate()"/>

</div>
</div>
</#if>
</div>
</div>

<#elseif section = "info" >
<#if realm.password && realm.registrationAllowed && !registrationDisabled??>
<div id="kc-registration">
<span>${msg("noAccount")} <a tabindex="6" href="${url.registrationUrl}">${msg("doRegister")}</a></span>
</div>
</#if>
<#elseif section = "socialProviders" >
<#if realm.password && social.providers??>
<div id="kc-social-providers" class="${properties.kcFormSocialAccountSectionClass!}">
<hr/>
<h4>${msg("identity-provider-login-label")}</h4>

<ul class="${properties.kcFormSocialAccountListClass!} <#if social.providers?size gt 3>${properties.kcFormSocialAccountListGridClass!}</#if>">
<#list social.providers as p>
<a id="social-${p.alias}"
class="${properties.kcFormSocialAccountListButtonClass!} <#if social.providers?size gt 3>${properties.kcFormSocialAccountGridItem!}</#if>"
type="button" href="${p.loginUrl}">

<#if p.iconClasses?has_content>
<i class="${properties.kcCommonLogoIdP!} ${p.iconClasses!}" aria-hidden="true"></i>
<span class="${properties.kcFormSocialAccountNameClass!} kc-social-icon-text">${p.displayName!}</span>
<#else>
<span class="${properties.kcFormSocialAccountNameClass!}">${p.displayName!}</span>
</#if>
</a>
</#list>
</ul>
</div>
</#if>
</#if>
<script type="text/javascript" src="${url.resourcesCommonPath}/node_modules/jquery/dist/jquery.min.js"></script>
<script type="text/javascript" src="${url.resourcesPath}/js/base64url.js"></script>
<script type="text/javascript">
if (!window.PublicKeyCredential) {
if (document.getElementById("showPasskeyOption") != null) {
document.getElementById("showPasskeyOption").style.display = "none";
console.log("Webauthn not supported")
}
}

function webAuthnAuthenticate() {
let isUserIdentified = ${isUserIdentified};
if (!isUserIdentified) {
doAuthenticate([]);
return;
}
checkAllowCredentials();
}

function checkAllowCredentials() {
let allowCredentials = [];
let authn_use = document.forms['authn_select'].authn_use_chk;

if (authn_use !== undefined) {
if (authn_use.length === undefined) {
allowCredentials.push({
id: base64url.decode(authn_use.value, {loose: true}),
type: 'public-key',
});
} else {
for (let i = 0; i < authn_use.length; i++) {
allowCredentials.push({
id: base64url.decode(authn_use[i].value, {loose: true}),
type: 'public-key',
});
}
}
}
doAuthenticate(allowCredentials);
}


function doAuthenticate(allowCredentials) {

// Check if WebAuthn is supported by this browser
if (!window.PublicKeyCredential) {
$("#error").val("${msg("webauthn-unsupported-browser-text")?no_esc}");
$("#webauth").submit();
return;
}

let challenge = "${challenge}";
let userVerification = "${userVerification}";
let rpId = "${rpId}";
let publicKey = {
rpId: rpId,
challenge: base64url.decode(challenge, {loose: true})
};

let createTimeout = ${createTimeout};
if (createTimeout !== 0) publicKey.timeout = createTimeout * 1000;

if (allowCredentials.length) {
publicKey.allowCredentials = allowCredentials;
}

if (userVerification !== 'not specified') publicKey.userVerification = userVerification;

navigator.credentials.get({publicKey})
.then((result) => {
window.result = result;

let clientDataJSON = result.response.clientDataJSON;
let authenticatorData = result.response.authenticatorData;
let signature = result.response.signature;

$("#clientDataJSON").val(base64url.encode(new Uint8Array(clientDataJSON), {pad: false}));
$("#authenticatorData").val(base64url.encode(new Uint8Array(authenticatorData), {pad: false}));
$("#signature").val(base64url.encode(new Uint8Array(signature), {pad: false}));
$("#credentialId").val(result.id);
if (result.response.userHandle) {
$("#userHandle").val(base64url.encode(new Uint8Array(result.response.userHandle), {pad: false}));
}
$("#webauth").submit();
})
.catch((err) => {
$("#error").val(err);
$("#webauth").submit();
})
;
}
</script>
</@layout.registrationLayout>

Device Support

Please refer to Passkey Matrix to check for compatibility.

References