Update per feedback review

Signed-off-by: mposolda <mposolda@gmail.com>
This commit is contained in:
mposolda
2025-07-18 13:36:51 +02:00
committed by Bruno Oliveira da Silva
parent bba869b3d5
commit 57972d85d3
10 changed files with 106 additions and 33 deletions

View File

@@ -47,6 +47,12 @@ public class PasswordForm extends UsernamePasswordForm implements CredentialVali
context.success(); context.success();
return; return;
} }
// setup webauthn data when passkeys enabled
if (isConditionalPasskeysEnabled(context.getUser())) {
webauthnAuth.fillContextForm(context);
}
Response challengeResponse = context.form().createLoginPassword(); Response challengeResponse = context.form().createLoginPassword();
context.challenge(challengeResponse); context.challenge(challengeResponse);
} }
@@ -54,6 +60,7 @@ public class PasswordForm extends UsernamePasswordForm implements CredentialVali
@Override @Override
public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
return user.credentialManager().isConfiguredFor(getCredentialProvider(session).getType()) return user.credentialManager().isConfiguredFor(getCredentialProvider(session).getType())
|| (isConditionalPasskeysEnabled(user))
|| alreadyAuthenticatedUsingPasswordlessCredential(session.getContext().getAuthenticationSession()); || alreadyAuthenticatedUsingPasswordlessCredential(session.getContext().getAuthenticationSession());
} }

View File

@@ -44,7 +44,7 @@ public final class UsernameForm extends UsernamePasswordForm {
@Override @Override
public void authenticate(AuthenticationFlowContext context) { public void authenticate(AuthenticationFlowContext context) {
if (context.getUser() != null && !isConditionalPasskeysEnabled()) { if (context.getUser() != null) {
// We can skip the form when user is re-authenticating. Unless current user has some IDP set, so he can re-authenticate with that IDP // We can skip the form when user is re-authenticating. Unless current user has some IDP set, so he can re-authenticate with that IDP
if (!this.hasLinkedBrokers(context)) { if (!this.hasLinkedBrokers(context)) {
context.success(); context.success();

View File

@@ -112,7 +112,7 @@ public class UsernamePasswordForm extends AbstractUsernameFormAuthenticator impl
} }
} }
// setup webauthn data when passkeys enabled // setup webauthn data when passkeys enabled
if (isConditionalPasskeysEnabled()) { if (isConditionalPasskeysEnabled(context.getUser())) {
webauthnAuth.fillContextForm(context); webauthnAuth.fillContextForm(context);
} }
Response challengeResponse = challenge(context, formData); Response challengeResponse = challenge(context, formData);
@@ -134,7 +134,7 @@ public class UsernamePasswordForm extends AbstractUsernameFormAuthenticator impl
@Override @Override
protected Response challenge(AuthenticationFlowContext context, String error, String field) { protected Response challenge(AuthenticationFlowContext context, String error, String field) {
if (isConditionalPasskeysEnabled()) { if (isConditionalPasskeysEnabled(context.getUser())) {
// setup webauthn data when possible // setup webauthn data when possible
webauthnAuth.fillContextForm(context); webauthnAuth.fillContextForm(context);
} }
@@ -157,8 +157,9 @@ public class UsernamePasswordForm extends AbstractUsernameFormAuthenticator impl
} }
protected boolean isConditionalPasskeysEnabled() { protected boolean isConditionalPasskeysEnabled(UserModel currentUser) {
return webauthnAuth != null && webauthnAuth.isPasskeysEnabled(); return webauthnAuth != null && webauthnAuth.isPasskeysEnabled() &&
(currentUser == null || currentUser.credentialManager().isConfiguredFor(webauthnAuth.getCredentialType()));
} }
} }

View File

@@ -97,8 +97,7 @@ public class WebAuthnAuthenticator implements Authenticator, CredentialValidator
UserModel user = context.getUser(); UserModel user = context.getUser();
boolean isUserIdentified = false; boolean isUserIdentified = false;
if (user != null) {
if (shouldShowWebAuthnAuthenticators(context)) {
// in 2 Factor Scenario where the user has already been identified // in 2 Factor Scenario where the user has already been identified
WebAuthnAuthenticatorsBean authenticators = new WebAuthnAuthenticatorsBean(context.getSession(), context.getRealm(), user, getCredentialType()); WebAuthnAuthenticatorsBean authenticators = new WebAuthnAuthenticatorsBean(context.getSession(), context.getRealm(), user, getCredentialType());
if (authenticators.getAuthenticators().isEmpty()) { if (authenticators.getAuthenticators().isEmpty()) {
@@ -121,14 +120,6 @@ public class WebAuthnAuthenticator implements Authenticator, CredentialValidator
return form; return form;
} }
/**
* @param context authentication context
* @return true if the available webauthn authenticators should be shown on the screen. Typically during 2-factor authentication for example
*/
protected boolean shouldShowWebAuthnAuthenticators(AuthenticationFlowContext context) {
return context.getUser() != null;
}
protected WebAuthnPolicy getWebAuthnPolicy(AuthenticationFlowContext context) { protected WebAuthnPolicy getWebAuthnPolicy(AuthenticationFlowContext context) {
return context.getRealm().getWebAuthnPolicy(); return context.getRealm().getWebAuthnPolicy();
} }

View File

@@ -60,9 +60,4 @@ public class WebAuthnConditionalUIAuthenticator extends WebAuthnPasswordlessAuth
return Profile.isFeatureEnabled(Profile.Feature.PASSKEYS) && return Profile.isFeatureEnabled(Profile.Feature.PASSKEYS) &&
Boolean.TRUE.equals(session.getContext().getRealm().getWebAuthnPolicyPasswordless().isPasskeysEnabled()); Boolean.TRUE.equals(session.getContext().getRealm().getWebAuthnPolicyPasswordless().isPasskeysEnabled());
} }
// Do not show authenticators during login with conditional passkeys (For example during username/password)
protected boolean shouldShowWebAuthnAuthenticators(AuthenticationFlowContext context) {
return false;
}
} }

View File

@@ -307,6 +307,10 @@ public class PasskeysOrganizationAuthenticationTest extends AbstractWebAuthnVirt
.open(); .open();
WaitUtils.waitForPageToLoad(); WaitUtils.waitForPageToLoad();
loginPage.assertCurrent();
MatcherAssert.assertThat(loginPage.isPasswordInputPresent(), Matchers.is(true));
MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue());
webAuthnLoginPage.clickAuthenticate();
appPage.assertCurrent(); appPage.assertCurrent();
events.expectLogin() events.expectLogin()

View File

@@ -42,6 +42,7 @@ import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.Assert; import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.admin.AbstractAdminTest; 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.EnableFeature;
import org.keycloak.testsuite.arquillian.annotation.IgnoreBrowserDriver; import org.keycloak.testsuite.arquillian.annotation.IgnoreBrowserDriver;
import org.keycloak.testsuite.util.WaitUtils; import org.keycloak.testsuite.util.WaitUtils;
@@ -51,6 +52,9 @@ import org.openqa.selenium.By;
import org.openqa.selenium.NoSuchElementException; import org.openqa.selenium.NoSuchElementException;
import org.openqa.selenium.firefox.FirefoxDriver; import org.openqa.selenium.firefox.FirefoxDriver;
import static org.hamcrest.Matchers.nullValue;
import static org.junit.Assert.assertEquals;
/** /**
* *
* @author rmartinc * @author rmartinc
@@ -66,6 +70,7 @@ public class PasskeysUsernameFormTest extends AbstractWebAuthnVirtualTest {
makePasswordlessRequiredActionDefault(realmRepresentation); makePasswordlessRequiredActionDefault(realmRepresentation);
switchExecutionInBrowser(realmRepresentation); switchExecutionInBrowser(realmRepresentation);
configureTestRealm(realmRepresentation);
testRealms.add(realmRepresentation); testRealms.add(realmRepresentation);
} }
@@ -198,8 +203,10 @@ public class PasskeysUsernameFormTest extends AbstractWebAuthnVirtualTest {
// login OK now // login OK now
loginPage.loginUsername(USERNAME); loginPage.loginUsername(USERNAME);
loginPage.assertCurrent(); loginPage.assertCurrent();
// Passkeys available on password-form as well. Allows to login only with the passkey of current user
MatcherAssert.assertThat(loginPage.getAttemptedUsername(), Matchers.is(USERNAME)); MatcherAssert.assertThat(loginPage.getAttemptedUsername(), Matchers.is(USERNAME));
Assert.assertThrows(NoSuchElementException.class, () -> driver.findElement(By.xpath("//form[@id='webauth']"))); MatcherAssert.assertThat(loginPage.isPasswordInputPresent(), Matchers.is(true));
MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue());
loginPage.login(getPassword(USERNAME)); loginPage.login(getPassword(USERNAME));
appPage.assertCurrent(); appPage.assertCurrent();
events.expectLogin() events.expectLogin()
@@ -307,6 +314,11 @@ public class PasskeysUsernameFormTest extends AbstractWebAuthnVirtualTest {
.open(); .open();
WaitUtils.waitForPageToLoad(); WaitUtils.waitForPageToLoad();
loginPage.assertCurrent();
MatcherAssert.assertThat(loginPage.isPasswordInputPresent(), Matchers.is(true));
MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue());
webAuthnLoginPage.clickAuthenticate();
appPage.assertCurrent(); appPage.assertCurrent();
events.expectLogin() events.expectLogin()
@@ -319,4 +331,67 @@ public class PasskeysUsernameFormTest extends AbstractWebAuthnVirtualTest {
logout(); logout();
} }
} }
@Test
public void passwordLogin_reauthenticationOfUserWithoutPasskey() 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(Constants.WEBAUTHN_POLICY_OPTION_YES)
.setWebAuthnPolicyUserVerificationRequirement(Constants.WEBAUTHN_POLICY_OPTION_REQUIRED)
.setWebAuthnPolicyPasskeysEnabled(Boolean.TRUE)
.update()) {
// Login with password
oauth.openLoginForm();
WaitUtils.waitForPageToLoad();
// WebAuthn elements available, user is not yet known. Password not available as on username-form
loginPage.assertCurrent();
MatcherAssert.assertThat(loginPage.isPasswordInputPresent(), Matchers.is(false));
MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue());
// Login with password. WebAuthn elements not available on password screen as user does not have passkeys
loginPage.loginUsername("test-user@localhost");
Assert.assertThrows(NoSuchElementException.class, () -> driver.findElement(By.xpath("//form[@id='webauth']")));
loginPage.login(getPassword("test-user@localhost"));
appPage.assertCurrent();
events.clear();
// Re-authentication now with prompt=login. Passkeys login should not be available on the page as this user does not have passkey
oauth.loginForm()
.prompt(OIDCLoginProtocol.PROMPT_VALUE_LOGIN)
.open();
WaitUtils.waitForPageToLoad();
loginPage.assertCurrent();
assertEquals("Please re-authenticate to continue", loginPage.getInfoMessage());
Assert.assertThrows(NoSuchElementException.class, () -> driver.findElement(By.xpath("//form[@id='webauth']")));
// Incorrect password (password of different user)
loginPage.login(getPassword("john-doh@localhost"));
MatcherAssert.assertThat(loginPage.getPasswordInputError(), Matchers.is("Invalid password."));
events.clear();
// Login with password
loginPage.login(getPassword("test-user@localhost"));
appPage.assertCurrent();
UserRepresentation testUser = ApiUtil.findUserByUsernameId(testRealm(), "test-user@localhost").toRepresentation();
events.expectLogin()
.user(testUser.getId())
.detail(Details.USERNAME, testUser.getUsername())
.detail(WebAuthnConstants.USER_VERIFICATION_CHECKED, nullValue())
.assertEvent();
logout();
}
}
} }

View File

@@ -46,6 +46,7 @@ import org.keycloak.testsuite.util.WaitUtils;
import org.keycloak.testsuite.webauthn.AbstractWebAuthnVirtualTest; import org.keycloak.testsuite.webauthn.AbstractWebAuthnVirtualTest;
import org.keycloak.testsuite.webauthn.authenticators.DefaultVirtualAuthOptions; import org.keycloak.testsuite.webauthn.authenticators.DefaultVirtualAuthOptions;
import org.openqa.selenium.By; import org.openqa.selenium.By;
import org.openqa.selenium.NoSuchElementException;
import org.openqa.selenium.firefox.FirefoxDriver; import org.openqa.selenium.firefox.FirefoxDriver;
import static org.hamcrest.Matchers.nullValue; import static org.hamcrest.Matchers.nullValue;
@@ -288,12 +289,7 @@ public class PasskeysUsernamePasswordFormTest extends AbstractWebAuthnVirtualTes
// WebAuthn elements not available // WebAuthn elements not available
loginPage.assertCurrent(); loginPage.assertCurrent();
try { Assert.assertThrows(NoSuchElementException.class, () -> driver.findElement(By.xpath("//form[@id='webauth']")));
MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), nullValue());
fail("Not expected to have webauthn button");
} catch (Exception nsee) {
// expected
}
// Login with password // Login with password
loginPage.login("test-user@localhost", getPassword("test-user@localhost")); loginPage.login("test-user@localhost", getPassword("test-user@localhost"));
@@ -309,12 +305,7 @@ public class PasskeysUsernamePasswordFormTest extends AbstractWebAuthnVirtualTes
loginPage.assertCurrent(); loginPage.assertCurrent();
assertEquals("Please re-authenticate to continue", loginPage.getInfoMessage()); assertEquals("Please re-authenticate to continue", loginPage.getInfoMessage());
try { Assert.assertThrows(NoSuchElementException.class, () -> driver.findElement(By.xpath("//form[@id='webauth']")));
MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), nullValue());
fail("Not expected to have webauthn button");
} catch (Exception nsee) {
// expected
}
// Login with password // Login with password
loginPage.login(getPassword("test-user@localhost")); loginPage.login(getPassword("test-user@localhost"));

View File

@@ -8,6 +8,13 @@
<input type="hidden" id="userHandle" name="userHandle"/> <input type="hidden" id="userHandle" name="userHandle"/>
<input type="hidden" id="error" name="error"/> <input type="hidden" id="error" name="error"/>
</form> </form>
<#if authenticators??>
<form id="authn_select" class="${properties.kcFormClass!}">
<#list authenticators.authenticators as authenticator>
<input type="hidden" name="authn_use_chk" value="${authenticator.credentialId}"/>
</#list>
</form>
</#if>
<script type="module"> <script type="module">
import { authenticateByWebAuthn } from "${url.resourcesPath}/js/webauthnAuthenticate.js"; import { authenticateByWebAuthn } from "${url.resourcesPath}/js/webauthnAuthenticate.js";
import { initAuthenticate } from "${url.resourcesPath}/js/passkeysConditionalAuth.js"; import { initAuthenticate } from "${url.resourcesPath}/js/passkeysConditionalAuth.js";

View File

@@ -1,6 +1,7 @@
<#import "template.ftl" as layout> <#import "template.ftl" as layout>
<#import "field.ftl" as field> <#import "field.ftl" as field>
<#import "buttons.ftl" as buttons> <#import "buttons.ftl" as buttons>
<#import "passkeys.ftl" as passkeys>
<@layout.registrationLayout displayMessage=!messagesPerField.existsError('password'); section> <@layout.registrationLayout displayMessage=!messagesPerField.existsError('password'); section>
<!-- template: login-password.ftl --> <!-- template: login-password.ftl -->
<#if section = "header"> <#if section = "header">
@@ -14,6 +15,7 @@
</form> </form>
</div> </div>
</div> </div>
<@passkeys.conditionalUIData />
</#if> </#if>
</@layout.registrationLayout> </@layout.registrationLayout>