Integrate passkeys with the organization authenticator

Closes #40022

Signed-off-by: rmartinc <rmartinc@redhat.com>
This commit is contained in:
rmartinc 2025-06-23 15:51:41 +02:00 committed by Marek Posolda
parent 5af775db0f
commit a9202d48e2
6 changed files with 328 additions and 99 deletions

View File

@ -90,6 +90,12 @@ public class UsernamePasswordForm extends AbstractUsernameFormAuthenticator impl
String rememberMeUsername = AuthenticationManager.getRememberMeUsername(context.getSession());
if (context.getUser() != null) {
if (alreadyAuthenticatedUsingPasswordlessCredential(context)) {
// if already authenticated using passwordless webauthn just success
context.success();
return;
}
LoginFormsProvider form = context.form();
form.setAttribute(LoginFormsProvider.USERNAME_HIDDEN, true);
form.setAttribute(LoginFormsProvider.REGISTRATION_DISABLED, true);

View File

@ -35,9 +35,12 @@ import java.util.stream.Stream;
import jakarta.ws.rs.core.MultivaluedHashMap;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;
import org.keycloak.WebAuthnConstants;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.FlowStatus;
import org.keycloak.authentication.authenticators.browser.IdentityProviderAuthenticator;
import org.keycloak.authentication.authenticators.browser.WebAuthnConditionalUIAuthenticator;
import org.keycloak.email.freemarker.beans.ProfileBean;
import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.forms.login.freemarker.model.AuthenticationContextBean;
@ -53,7 +56,6 @@ import org.keycloak.models.OrganizationModel.IdentityProviderRedirectMode;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.FormMessage;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.organization.OrganizationProvider;
import org.keycloak.organization.forms.login.freemarker.model.OrganizationAwareAuthenticationContextBean;
import org.keycloak.organization.forms.login.freemarker.model.OrganizationAwareIdentityProviderBean;
@ -68,9 +70,11 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator {
private static final String LOGIN_HINT_ALREADY_HANDLED = "loginHintAlreadyHandled";
private final KeycloakSession session;
private final WebAuthnConditionalUIAuthenticator webauthnAuth;
public OrganizationAuthenticator(KeycloakSession session) {
this.session = session;
this.webauthnAuth = new WebAuthnConditionalUIAuthenticator(session, (context) -> createLoginForm(context));
}
@Override
@ -108,6 +112,17 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator {
public void action(AuthenticationFlowContext context) {
HttpRequest request = context.getHttpRequest();
MultivaluedMap<String, String> parameters = request.getDecodedFormParameters();
// check if it's a webauthn submission and perform the webauth login
if (webauthnAuth.isPasskeysEnabled() && (parameters.containsKey(WebAuthnConstants.AUTHENTICATOR_DATA)
|| parameters.containsKey(WebAuthnConstants.ERROR))) {
webauthnAuth.action(context);
if (FlowStatus.SUCCESS != context.getStatus()) {
// if failure doing webauthn authentication return error; continue if success checking organizations
return;
}
}
String username = parameters.getFirst(UserModel.USERNAME);
RealmModel realm = context.getRealm();
UserModel user = resolveUser(context, username);
@ -341,25 +356,12 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator {
UserModel user = context.getUser();
if (user == null) {
// the default challenge won't show any broker but just the identity-first login page and the option to try a different authentication mechanism
LoginFormsProvider form = context.form()
.setAttributeMapper(attributes -> {
attributes.computeIfPresent("social",
(key, bean) -> new OrganizationAwareIdentityProviderBean((IdentityProviderBean) bean, false, true)
);
attributes.computeIfPresent("auth",
(key, bean) -> new OrganizationAwareAuthenticationContextBean((AuthenticationContextBean) bean, false)
);
return attributes;
});
String loginHint = authenticationSession.getClientNote(OIDCLoginProtocol.LOGIN_HINT_PARAM);
if (loginHint != null) {
form.setFormData(new MultivaluedHashMap<>(Map.of(UserModel.USERNAME, loginHint)));
// setup webauthn data when the user is not already selected
if (webauthnAuth.isPasskeysEnabled()) {
webauthnAuth.fillContextForm(context);
}
context.challenge(form.createLoginUsername());
context.challenge(createLoginForm(context));
} else if (isSSOAuthentication(authenticationSession)) {
if (shouldUserSelectOrganization(context, user)) {
return;
@ -373,6 +375,28 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator {
}
}
private Response createLoginForm(AuthenticationFlowContext context) {
// the default challenge won't show any broker but just the identity-first login page and the option to try a different authentication mechanism
LoginFormsProvider form = context.form()
.setAttributeMapper(attributes -> {
attributes.computeIfPresent("social",
(key, bean) -> new OrganizationAwareIdentityProviderBean((IdentityProviderBean) bean, false, true)
);
attributes.computeIfPresent("auth",
(key, bean) -> new OrganizationAwareAuthenticationContextBean((AuthenticationContextBean) bean, false)
);
return attributes;
});
String loginHint = context.getAuthenticationSession().getClientNote(OIDCLoginProtocol.LOGIN_HINT_PARAM);
if (loginHint != null) {
form.setFormData(new MultivaluedHashMap<>(Map.of(UserModel.USERNAME, loginHint)));
}
return form.createLoginUsername();
}
private boolean hasPublicBrokers(OrganizationModel organization) {
return organization.getIdentityProviders().anyMatch(Predicate.not(IdentityProviderModel::isHideOnLogin));
}

View File

@ -21,6 +21,7 @@ import static org.keycloak.provider.ProviderConfigProperty.BOOLEAN_TYPE;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import org.keycloak.Config.Scope;
import org.keycloak.authentication.Authenticator;
@ -28,6 +29,7 @@ import org.keycloak.authentication.authenticators.browser.IdentityProviderAuthen
import org.keycloak.common.Profile;
import org.keycloak.common.Profile.Feature;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.credential.WebAuthnCredentialModel;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
import org.keycloak.provider.ProviderConfigProperty;
@ -68,4 +70,11 @@ public class OrganizationAuthenticatorFactory extends IdentityProviderAuthentica
public List<ProviderConfigProperty> getConfigProperties() {
return Collections.singletonList(new ProviderConfigProperty(REQUIRES_USER_MEMBERSHIP, "Requires user membership", "Enforces that users authenticating in the scope of an organization are members. If not a member, the user won't be able to proceed authenticating to the realm", BOOLEAN_TYPE, null));
}
@Override
public Set<String> getOptionalReferenceCategories() {
return Profile.isFeatureEnabled(Profile.Feature.PASSKEYS)
? Collections.singleton(WebAuthnCredentialModel.TYPE_PASSWORDLESS)
: super.getOptionalReferenceCategories();
}
}

View File

@ -39,6 +39,7 @@ import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.admin.AbstractAdminTest;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.pages.AppPage;
import org.keycloak.testsuite.pages.ErrorPage;
import org.keycloak.testsuite.pages.InfoPage;
import org.keycloak.testsuite.pages.LoginPage;
import org.keycloak.testsuite.pages.LogoutConfirmPage;
@ -91,6 +92,9 @@ public abstract class AbstractWebAuthnVirtualTest extends AbstractChangeImported
@Page
protected LoginPage loginPage;
@Page
protected ErrorPage errorPage;
@Page
protected RegisterPage registerPage;

View File

@ -0,0 +1,267 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.testsuite.webauthn.passwordless;
import java.io.Closeable;
import java.io.IOException;
import java.util.List;
import org.hamcrest.MatcherAssert;
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;
import org.keycloak.models.credential.WebAuthnCredentialModel;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.organization.authentication.authenticators.browser.OrganizationAuthenticatorFactory;
import org.keycloak.representations.idm.AuthenticationExecutionInfoRepresentation;
import org.keycloak.representations.idm.AuthenticatorConfigRepresentation;
import org.keycloak.representations.idm.OrganizationDomainRepresentation;
import org.keycloak.representations.idm.OrganizationRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
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.updaters.RealmAttributeUpdater;
import org.keycloak.testsuite.util.WaitUtils;
import org.keycloak.testsuite.webauthn.AbstractWebAuthnVirtualTest;
import org.keycloak.testsuite.webauthn.authenticators.DefaultVirtualAuthOptions;
import org.keycloak.testsuite.webauthn.utils.PropertyRequirement;
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 {
@Override
public void addTestRealms(List<RealmRepresentation> testRealms) {
RealmRepresentation realmRepresentation = AbstractAdminTest.loadJson(getClass().getResourceAsStream("/webauthn/testrealm-webauthn.json"), RealmRepresentation.class);
makePasswordlessRequiredActionDefault(realmRepresentation);
switchExecutionInBrowserFormToProvider(realmRepresentation, UsernamePasswordFormFactory.PROVIDER_ID);
realmRepresentation.setOrganizationsEnabled(Boolean.TRUE);
OrganizationRepresentation emailOrg = new OrganizationRepresentation();
emailOrg.setName("email");
emailOrg.setAlias("email");
OrganizationDomainRepresentation domainRep = new OrganizationDomainRepresentation();
domainRep.setName("email");
emailOrg.addDomain(domainRep);
realmRepresentation.addOrganization(emailOrg);
testRealms.add(realmRepresentation);
}
@Override
public boolean isPasswordless() {
return true;
}
@Test
public void webauthnLoginWithDiscoverableKey() throws IOException {
getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.PASSKEYS.getOptions());
// set passwordless policy for discoverable keys
try (Closeable c = getWebAuthnRealmUpdater()
.setWebAuthnPolicyRpEntityName("localhost")
.setWebAuthnPolicyRequireResidentKey(PropertyRequirement.YES.getValue())
.setWebAuthnPolicyUserVerificationRequirement(WebAuthnConstants.OPTION_REQUIRED)
.setWebAuthnPolicyPasskeysEnabled(Boolean.TRUE)
.update()) {
checkWebAuthnConfiguration(PropertyRequirement.YES.getValue(), WebAuthnConstants.OPTION_REQUIRED);
registerDefaultUser();
UserRepresentation user = userResource().toRepresentation();
MatcherAssert.assertThat(user, Matchers.notNullValue());
logout();
events.clear();
// the user should be automatically logged in using the discoverable key
oauth.openLoginForm();
WaitUtils.waitForPageToLoad();
appPage.assertCurrent();
events.expectLogin()
.user(user.getId())
.detail(Details.USERNAME, user.getUsername())
.detail(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS)
.detail(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true")
.assertEvent();
logout();
}
}
@Test
public void webauthnLoginWithDiscoverableKeyRequiresMembership() throws IOException {
getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.PASSKEYS.getOptions());
// enable organization configuration
AuthenticationExecutionInfoRepresentation organizationExec = testRealm().flows().getExecutions("browser-webauthn-conditional-organization").stream()
.filter(exec -> OrganizationAuthenticatorFactory.ID.equals(exec.getProviderId()))
.findAny()
.orElse(null);
Assert.assertNotNull("Organization execution not found", organizationExec);
AuthenticatorConfigRepresentation config = new AuthenticatorConfigRepresentation();
config.setAlias(KeycloakModelUtils.generateId());
config.getConfig().put(OrganizationAuthenticatorFactory.REQUIRES_USER_MEMBERSHIP, Boolean.TRUE.toString());
getCleanup().addAuthenticationConfigId(ApiUtil.getCreatedId(testRealm().flows().newExecutionConfig(organizationExec.getId(), config)));
// set passwordless policy for discoverable keys
try (Closeable c = getWebAuthnRealmUpdater()
.setWebAuthnPolicyRpEntityName("localhost")
.setWebAuthnPolicyRequireResidentKey(PropertyRequirement.YES.getValue())
.setWebAuthnPolicyUserVerificationRequirement(WebAuthnConstants.OPTION_REQUIRED)
.setWebAuthnPolicyPasskeysEnabled(Boolean.TRUE)
.update()) {
checkWebAuthnConfiguration(PropertyRequirement.YES.getValue(), WebAuthnConstants.OPTION_REQUIRED);
registerDefaultUser();
UserRepresentation user = userResource().toRepresentation();
MatcherAssert.assertThat(user, Matchers.notNullValue());
logout();
events.clear();
// the user should be automatically logged in using the discoverable key but error because no org
oauth.openLoginForm();
WaitUtils.waitForPageToLoad();
errorPage.assertCurrent();
MatcherAssert.assertThat(errorPage.getError(), Matchers.containsString("User is not a member of the organization"));
}
}
@Test
public void passwordLoginWithNonDiscoverableKey() throws Exception {
getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.PASSKEYS.getOptions());
// set passwordless policy not specified, key will not be discoverable
try (Closeable c = getWebAuthnRealmUpdater()
.setWebAuthnPolicyRpEntityName("localhost")
.setWebAuthnPolicyRequireResidentKey(PropertyRequirement.NOT_SPECIFIED.getValue())
.setWebAuthnPolicyUserVerificationRequirement(WebAuthnConstants.OPTION_NOT_SPECIFIED)
.setWebAuthnPolicyPasskeysEnabled(Boolean.TRUE)
.update()) {
registerDefaultUser();
UserRepresentation user = userResource().toRepresentation();
MatcherAssert.assertThat(user, Matchers.notNullValue());
logout();
events.clear();
// access login page, key is not discoverable so webauthn should be enabled but login should be manual
oauth.openLoginForm();
WaitUtils.waitForPageToLoad();
loginPage.assertCurrent();
MatcherAssert.assertThat(loginPage.getUsernameAutocomplete(), Matchers.is("username webauthn"));
MatcherAssert.assertThat(loginPage.isPasswordInputPresent(), Matchers.is(false));
MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue());
loginPage.loginUsername(USERNAME);
// now the passkeys username password page should be presented with username selected and passkeys disabled
loginPage.assertCurrent();
MatcherAssert.assertThat(loginPage.getAttemptedUsername(), Matchers.is("userwebauthn"));
Assert.assertThrows(NoSuchElementException.class, () -> driver.findElement(By.xpath("//form[@id='webauth']")));
loginPage.login("invalid-password");
loginPage.assertCurrent();
MatcherAssert.assertThat(loginPage.getPasswordInputError(), Matchers.is("Invalid password."));
events.expect(EventType.LOGIN_ERROR)
.error(Errors.INVALID_USER_CREDENTIALS)
.user(user.getId())
.assertEvent();
// correct login now
MatcherAssert.assertThat(loginPage.getAttemptedUsername(), Matchers.is("userwebauthn"));
Assert.assertThrows(NoSuchElementException.class, () -> driver.findElement(By.xpath("//form[@id='webauth']")));
loginPage.login(getPassword(USERNAME));
appPage.assertCurrent();
events.expectLogin()
.user(user.getId())
.detail(Details.USERNAME, "userwebauthn")
.detail(Details.CREDENTIAL_TYPE, Matchers.nullValue())
.assertEvent();
}
}
@Test
public void passwordLoginWithExternalKey() throws Exception {
// use a default resident key which is not shown in conditional UI
getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.DEFAULT_RESIDENT_KEY.getOptions());
// set passwordless policy for discoverable keys
try (Closeable c = getWebAuthnRealmUpdater()
.setWebAuthnPolicyRpEntityName("localhost")
.setWebAuthnPolicyRequireResidentKey(PropertyRequirement.YES.getValue())
.setWebAuthnPolicyUserVerificationRequirement(WebAuthnConstants.OPTION_REQUIRED)
.setWebAuthnPolicyPasskeysEnabled(Boolean.TRUE)
.update()) {
checkWebAuthnConfiguration(PropertyRequirement.YES.getValue(), WebAuthnConstants.OPTION_REQUIRED);
registerDefaultUser();
UserRepresentation user = userResource().toRepresentation();
MatcherAssert.assertThat(user, Matchers.notNullValue());
logout();
events.clear();
// open login page, the key is not internal so not opened by default
oauth.openLoginForm();
WaitUtils.waitForPageToLoad();
loginPage.assertCurrent();
MatcherAssert.assertThat(loginPage.getUsernameAutocomplete(), Matchers.is("username webauthn"));
MatcherAssert.assertThat(loginPage.isPasswordInputPresent(), Matchers.is(false));
MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue());
// force login using webauthn link
webAuthnLoginPage.clickAuthenticate();
appPage.assertCurrent();
events.expectLogin()
.user(user.getId())
.detail(Details.USERNAME, user.getUsername())
.detail(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS)
.detail(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true")
.assertEvent();
logout();
}
}
}

View File

@ -31,22 +31,17 @@ import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventType;
import org.keycloak.models.credential.WebAuthnCredentialModel;
import org.keycloak.representations.idm.OrganizationDomainRepresentation;
import org.keycloak.representations.idm.OrganizationRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.admin.AbstractAdminTest;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.arquillian.annotation.IgnoreBrowserDriver;
import org.keycloak.testsuite.pages.SelectOrganizationPage;
import org.keycloak.testsuite.updaters.RealmAttributeUpdater;
import org.keycloak.testsuite.util.WaitUtils;
import org.keycloak.testsuite.webauthn.AbstractWebAuthnVirtualTest;
import org.keycloak.testsuite.webauthn.authenticators.DefaultVirtualAuthOptions;
import org.keycloak.testsuite.webauthn.utils.PropertyRequirement;
import org.openqa.selenium.By;
import org.openqa.selenium.NoSuchElementException;
import org.openqa.selenium.firefox.FirefoxDriver;
/**
@ -207,80 +202,4 @@ public class PasskeysUsernamePasswordFormTest extends AbstractWebAuthnVirtualTes
logout();
}
}
@Test
public void organizationLoginWithDiscoverableKey() throws Exception {
getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.PASSKEYS.getOptions());
// set passwordless policy for discoverable keys
try (Closeable c = getWebAuthnRealmUpdater()
.setWebAuthnPolicyRpEntityName("localhost")
.setWebAuthnPolicyRequireResidentKey(PropertyRequirement.YES.getValue())
.setWebAuthnPolicyUserVerificationRequirement(WebAuthnConstants.OPTION_REQUIRED)
.setWebAuthnPolicyPasskeysEnabled(Boolean.TRUE)
.update();
Closeable realmUpdate = new RealmAttributeUpdater(testRealm())
.setOrganizationsEnabled(true)
.update()) {
OrganizationRepresentation orgRep = createRepresentation("testOrg", "email");
testRealm().organizations().create(orgRep);
checkWebAuthnConfiguration(PropertyRequirement.YES.getValue(), WebAuthnConstants.OPTION_REQUIRED);
registerDefaultUser();
UserRepresentation user = userResource().toRepresentation();
MatcherAssert.assertThat(user, Matchers.notNullValue());
logout();
events.clear();
// login using the organization
oauth.openLoginForm();
WaitUtils.waitForPageToLoad();
loginPage.assertCurrent();
MatcherAssert.assertThat(loginPage.isPasswordInputPresent(), Matchers.is(false));
loginPage.loginUsername(USERNAME);
// now the passkeys username password page should be presented with username selected and passkeys disabled
loginPage.assertCurrent();
MatcherAssert.assertThat(loginPage.getAttemptedUsername(), Matchers.is("userwebauthn"));
Assert.assertThrows(NoSuchElementException.class, () -> driver.findElement(By.xpath("//form[@id='webauth']")));
loginPage.login("invalid-password");
loginPage.assertCurrent();
MatcherAssert.assertThat(loginPage.getPasswordInputError(), Matchers.is("Invalid password."));
events.expect(EventType.LOGIN_ERROR)
.error(Errors.INVALID_USER_CREDENTIALS)
.user(user.getId())
.assertEvent();
// correct login now
MatcherAssert.assertThat(loginPage.getAttemptedUsername(), Matchers.is("userwebauthn"));
Assert.assertThrows(NoSuchElementException.class, () -> driver.findElement(By.xpath("//form[@id='webauth']")));
loginPage.login(getPassword(USERNAME));
appPage.assertCurrent();
events.expectLogin()
.user(user.getId())
.detail(Details.USERNAME, "userwebauthn")
.detail(Details.CREDENTIAL_TYPE, Matchers.nullValue())
.assertEvent();
}
}
private OrganizationRepresentation createRepresentation(String name, String... orgDomains) {
OrganizationRepresentation org = new OrganizationRepresentation();
org.setName(name);
org.setAlias(name);
org.setDescription(name + " is a test organization!");
for (String orgDomain : orgDomains) {
OrganizationDomainRepresentation domainRep = new OrganizationDomainRepresentation();
domainRep.setName(orgDomain);
org.addDomain(domainRep);
}
return org;
}
}