Make passkeys feature supported

Closes #41556

Signed-off-by: rmartinc <rmartinc@redhat.com>
This commit is contained in:
rmartinc
2025-08-02 11:56:37 +02:00
committed by Marek Posolda
parent a8225655cf
commit acf39b34c3
15 changed files with 51 additions and 60 deletions

View File

@@ -124,7 +124,7 @@ public class Profile {
ORGANIZATION("Organization support within realms", Type.DEFAULT),
PASSKEYS("Passkeys", Type.PREVIEW, Feature.WEB_AUTHN),
PASSKEYS("Passkeys", Type.DEFAULT, Feature.WEB_AUTHN),
PASSKEYS_CONDITIONAL_UI_AUTHENTICATOR("Passkeys conditional UI authenticator", Type.DEPRECATED, FeatureUpdatePolicy.ROLLING_NO_UPGRADE, Feature.PASSKEYS),
USER_EVENT_METRICS("Collect metrics based on user events", Type.DEFAULT),

View File

@@ -59,11 +59,11 @@ public class StartDevCommandDistTest {
}
@Test
@Launch({ "start-dev", "--debug", "--features=passkeys:v1" })
@Launch({ "start-dev", "--debug", "--features=oid4vc-vci:v1" })
void testStartDevShouldStartTwoJVMs(CLIResult cliResult) {
cliResult.assertMessageWasShownExactlyNumberOfTimes("Listening for transport dt_socket at address:", 2);
cliResult.assertStartedDevMode();
cliResult.assertMessage("passkeys");
cliResult.assertMessage("oid4vc-vci");
// ensure consistency with build-time properties
cliResult.assertNoMessage("Build time property cannot");
}

View File

@@ -20,6 +20,7 @@ package org.keycloak.authentication;
import java.util.Collections;
import java.util.Set;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.provider.ConfiguredProvider;
/**
@@ -50,9 +51,10 @@ public interface ConfigurableAuthenticatorFactory extends ConfiguredProvider {
/**
* Optional categories that this authenticator can have (for example passkeys in username/form).
* Optional categories are not taken into account by LoA.
* @param session The current session in the request
* @return Set of extra optional categories, empty by default
*/
default Set<String> getOptionalReferenceCategories() {
default Set<String> getOptionalReferenceCategories(KeycloakSession session) {
return Collections.emptySet();
}

View File

@@ -375,19 +375,21 @@ public class DefaultAuthenticationFlows {
execution.setAuthenticatorFlow(false);
realm.addAuthenticatorExecution(execution);
AuthenticatorConfigModel configModel = new AuthenticatorConfigModel();
configModel.setAlias("browser-conditional-credential");
configModel.setConfig(Map.of("credentials", WebAuthnCredentialModel.TYPE_PASSWORDLESS));
configModel = realm.addAuthenticatorConfig(configModel);
if (Profile.isFeatureEnabled(Profile.Feature.PASSKEYS)) {
AuthenticatorConfigModel configModel = new AuthenticatorConfigModel();
configModel.setAlias("browser-conditional-credential");
configModel.setConfig(Map.of("credentials", WebAuthnCredentialModel.TYPE_PASSWORDLESS));
configModel = realm.addAuthenticatorConfig(configModel);
execution = new AuthenticationExecutionModel();
execution.setParentFlow(conditionalOTP.getId());
execution.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED);
execution.setAuthenticator("conditional-credential");
execution.setPriority(20);
execution.setAuthenticatorFlow(false);
execution.setAuthenticatorConfig(configModel.getId());
realm.addAuthenticatorExecution(execution);
execution = new AuthenticationExecutionModel();
execution.setParentFlow(conditionalOTP.getId());
execution.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED);
execution.setAuthenticator("conditional-credential");
execution.setPriority(20);
execution.setAuthenticatorFlow(false);
execution.setAuthenticatorConfig(configModel.getId());
realm.addAuthenticatorExecution(execution);
}
// otp processing
execution = new AuthenticationExecutionModel();
@@ -676,19 +678,21 @@ public class DefaultAuthenticationFlows {
execution.setAuthenticatorFlow(false);
realm.addAuthenticatorExecution(execution);
AuthenticatorConfigModel configModel = new AuthenticatorConfigModel();
configModel.setAlias("first-broker-login-conditional-credential");
configModel.setConfig(Map.of("credentials", WebAuthnCredentialModel.TYPE_PASSWORDLESS));
configModel = realm.addAuthenticatorConfig(configModel);
if (Profile.isFeatureEnabled(Profile.Feature.PASSKEYS)) {
AuthenticatorConfigModel configModel = new AuthenticatorConfigModel();
configModel.setAlias("first-broker-login-conditional-credential");
configModel.setConfig(Map.of("credentials", WebAuthnCredentialModel.TYPE_PASSWORDLESS));
configModel = realm.addAuthenticatorConfig(configModel);
execution = new AuthenticationExecutionModel();
execution.setParentFlow(conditionalOTP.getId());
execution.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED);
execution.setAuthenticator("conditional-credential");
execution.setPriority(20);
execution.setAuthenticatorFlow(false);
execution.setAuthenticatorConfig(configModel.getId());
realm.addAuthenticatorExecution(execution);
execution = new AuthenticationExecutionModel();
execution.setParentFlow(conditionalOTP.getId());
execution.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED);
execution.setAuthenticator("conditional-credential");
execution.setPriority(20);
execution.setAuthenticatorFlow(false);
execution.setAuthenticatorConfig(configModel.getId());
realm.addAuthenticatorExecution(execution);
}
execution = new AuthenticationExecutionModel();
execution.setParentFlow(conditionalOTP.getId());

View File

@@ -22,7 +22,6 @@ import java.util.Set;
import org.keycloak.Config;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.common.Profile;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
@@ -71,10 +70,10 @@ public class UsernameFormFactory implements AuthenticatorFactory {
}
@Override
public Set<String> getOptionalReferenceCategories() {
return Profile.isFeatureEnabled(Profile.Feature.PASSKEYS)
public Set<String> getOptionalReferenceCategories(KeycloakSession session) {
return WebAuthnConditionalUIAuthenticator.isPasskeysEnabled(session)
? Collections.singleton(WebAuthnCredentialModel.TYPE_PASSWORDLESS)
: AuthenticatorFactory.super.getOptionalReferenceCategories();
: AuthenticatorFactory.super.getOptionalReferenceCategories(session);
}
@Override

View File

@@ -25,12 +25,11 @@ import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.credential.PasswordCredentialModel;
import org.keycloak.models.credential.WebAuthnCredentialModel;
import org.keycloak.provider.ProviderConfigProperty;
import java.util.List;
import java.util.Set;
import org.keycloak.common.Profile;
import org.keycloak.models.credential.WebAuthnCredentialModel;
/**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
@@ -71,10 +70,10 @@ public class UsernamePasswordFormFactory implements AuthenticatorFactory {
}
@Override
public Set<String> getOptionalReferenceCategories() {
return Profile.isFeatureEnabled(Profile.Feature.PASSKEYS)
public Set<String> getOptionalReferenceCategories(KeycloakSession session) {
return WebAuthnConditionalUIAuthenticator.isPasskeysEnabled(session)
? Collections.singleton(WebAuthnCredentialModel.TYPE_PASSWORDLESS)
: AuthenticatorFactory.super.getOptionalReferenceCategories();
: AuthenticatorFactory.super.getOptionalReferenceCategories(session);
}
@Override

View File

@@ -57,7 +57,12 @@ public class WebAuthnConditionalUIAuthenticator extends WebAuthnPasswordlessAuth
}
public boolean isPasskeysEnabled() {
return isPasskeysEnabled(session);
}
static public boolean isPasskeysEnabled(KeycloakSession session) {
return Profile.isFeatureEnabled(Profile.Feature.PASSKEYS) &&
session.getContext().getRealm() != null &&
Boolean.TRUE.equals(session.getContext().getRealm().getWebAuthnPolicyPasswordless().isPasskeysEnabled());
}
}

View File

@@ -26,6 +26,7 @@ import java.util.Set;
import org.keycloak.Config.Scope;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.authenticators.browser.IdentityProviderAuthenticatorFactory;
import org.keycloak.authentication.authenticators.browser.WebAuthnConditionalUIAuthenticator;
import org.keycloak.common.Profile;
import org.keycloak.common.Profile.Feature;
import org.keycloak.models.KeycloakSession;
@@ -72,9 +73,9 @@ public class OrganizationAuthenticatorFactory extends IdentityProviderAuthentica
}
@Override
public Set<String> getOptionalReferenceCategories() {
return Profile.isFeatureEnabled(Profile.Feature.PASSKEYS)
public Set<String> getOptionalReferenceCategories(KeycloakSession session) {
return WebAuthnConditionalUIAuthenticator.isPasskeysEnabled(session)
? Collections.singleton(WebAuthnCredentialModel.TYPE_PASSWORDLESS)
: super.getOptionalReferenceCategories();
: super.getOptionalReferenceCategories(session);
}
}

View File

@@ -248,7 +248,7 @@ public class AccountCredentialResource {
.map(exe -> (AuthenticatorFactory) session.getKeycloakSessionFactory()
.getProviderFactory(Authenticator.class, exe.getAuthenticator()))
.filter(Objects::nonNull)
.flatMap(authFact -> Stream.concat(Stream.of(authFact.getReferenceCategory()), authFact.getOptionalReferenceCategories().stream()))
.flatMap(authFact -> Stream.concat(Stream.of(authFact.getReferenceCategory()), authFact.getOptionalReferenceCategories(session).stream()))
.filter(Objects::nonNull)
).collect(Collectors.toSet());
}

View File

@@ -44,7 +44,6 @@ import org.openqa.selenium.firefox.FirefoxDriver;
*
* @author rmartinc
*/
@EnableFeature(value = Profile.Feature.PASSKEYS, skipRestart = true)
@EnableFeature(value = Profile.Feature.PASSKEYS_CONDITIONAL_UI_AUTHENTICATOR, skipRestart = true)
@IgnoreBrowserDriver(FirefoxDriver.class) // See https://github.com/keycloak/keycloak/issues/10368
public class PasskeysConditionalUITest extends AbstractWebAuthnVirtualTest {

View File

@@ -25,7 +25,6 @@ import static org.hamcrest.Matchers.nullValue;
import org.jboss.arquillian.graphene.page.Page;
import org.junit.Test;
import org.keycloak.WebAuthnConstants;
import org.keycloak.common.Profile;
import org.keycloak.events.Details;
import org.keycloak.models.Constants;
import org.keycloak.models.UserModel;
@@ -34,7 +33,6 @@ import org.keycloak.models.utils.TimeBasedOTP;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.admin.AbstractAdminTest;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.arquillian.annotation.IgnoreBrowserDriver;
import org.keycloak.testsuite.auth.page.login.OneTimeCode;
import org.keycloak.testsuite.pages.LoginConfigTotpPage;
@@ -49,7 +47,6 @@ import org.openqa.selenium.firefox.FirefoxDriver;
*
* @author rmartinc
*/
@EnableFeature(value = Profile.Feature.PASSKEYS, skipRestart = true)
@IgnoreBrowserDriver(FirefoxDriver.class) // See https://github.com/keycloak/keycloak/issues/10368
public class PasskeysDefaultBrowserFlowTest extends AbstractWebAuthnVirtualTest {

View File

@@ -26,13 +26,11 @@ import org.junit.Before;
import org.junit.Test;
import org.keycloak.admin.client.resource.AuthenticationManagementResource;
import org.keycloak.authentication.requiredactions.WebAuthnPasswordlessRegisterFactory;
import org.keycloak.common.Profile;
import org.keycloak.models.IdentityProviderSyncMode;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.RequiredActionProviderRepresentation;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.arquillian.annotation.IgnoreBrowserDriver;
import org.keycloak.testsuite.broker.AbstractBrokerTest;
import org.keycloak.testsuite.broker.AbstractInitializedBaseBrokerTest;
@@ -59,7 +57,6 @@ import org.openqa.selenium.firefox.FirefoxDriver;
*
* @author rmartinc
*/
@EnableFeature(value = Profile.Feature.PASSKEYS, skipRestart = true)
@IgnoreBrowserDriver(FirefoxDriver.class) // See https://github.com/keycloak/keycloak/issues/10368
public class PasskeysKcOidcFirstBrokerLoginTest extends AbstractInitializedBaseBrokerTest {

View File

@@ -24,7 +24,6 @@ import org.hamcrest.Matchers;
import org.junit.Test;
import org.keycloak.WebAuthnConstants;
import org.keycloak.authentication.authenticators.browser.UsernamePasswordFormFactory;
import org.keycloak.common.Profile;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventType;
@@ -42,20 +41,17 @@ import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.admin.AbstractAdminTest;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.arquillian.annotation.IgnoreBrowserDriver;
import org.keycloak.testsuite.util.WaitUtils;
import org.keycloak.testsuite.webauthn.AbstractWebAuthnVirtualTest;
import org.keycloak.testsuite.webauthn.authenticators.DefaultVirtualAuthOptions;
import org.openqa.selenium.By;
import org.openqa.selenium.NoSuchElementException;
import org.openqa.selenium.firefox.FirefoxDriver;
/**
*
* @author rmartinc
*/
@EnableFeature(value = Profile.Feature.PASSKEYS, skipRestart = true)
@IgnoreBrowserDriver(FirefoxDriver.class) // See https://github.com/keycloak/keycloak/issues/10368
public class PasskeysOrganizationAuthenticationTest extends AbstractWebAuthnVirtualTest {

View File

@@ -26,7 +26,6 @@ import org.junit.Test;
import org.keycloak.WebAuthnConstants;
import org.keycloak.authentication.authenticators.browser.PasswordFormFactory;
import org.keycloak.authentication.authenticators.browser.UsernameFormFactory;
import org.keycloak.common.Profile;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventType;
@@ -43,7 +42,6 @@ import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.admin.AbstractAdminTest;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.arquillian.annotation.IgnoreBrowserDriver;
import org.keycloak.testsuite.util.WaitUtils;
import org.keycloak.testsuite.webauthn.AbstractWebAuthnVirtualTest;
@@ -59,7 +57,6 @@ import static org.junit.Assert.assertEquals;
*
* @author rmartinc
*/
@EnableFeature(value = Profile.Feature.PASSKEYS, skipRestart = true)
@IgnoreBrowserDriver(FirefoxDriver.class) // See https://github.com/keycloak/keycloak/issues/10368
public class PasskeysUsernameFormTest extends AbstractWebAuthnVirtualTest {

View File

@@ -27,19 +27,16 @@ import org.junit.Assert;
import org.junit.Test;
import org.keycloak.WebAuthnConstants;
import org.keycloak.authentication.authenticators.browser.UsernamePasswordFormFactory;
import org.keycloak.common.Profile;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventType;
import org.keycloak.models.Constants;
import org.keycloak.models.credential.PasswordCredentialModel;
import org.keycloak.models.credential.WebAuthnCredentialModel;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.admin.AbstractAdminTest;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.arquillian.annotation.IgnoreBrowserDriver;
import org.keycloak.testsuite.pages.SelectOrganizationPage;
import org.keycloak.testsuite.util.WaitUtils;
@@ -51,13 +48,11 @@ import org.openqa.selenium.firefox.FirefoxDriver;
import static org.hamcrest.Matchers.nullValue;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.fail;
/**
*
* @author rmartinc
*/
@EnableFeature(value = Profile.Feature.PASSKEYS, skipRestart = true)
@IgnoreBrowserDriver(FirefoxDriver.class) // See https://github.com/keycloak/keycloak/issues/10368
public class PasskeysUsernamePasswordFormTest extends AbstractWebAuthnVirtualTest {