Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ public abstract class AbstractUsernameFormAuthenticator extends AbstractFormAuth
public static final String SESSION_INVALID = "SESSION_INVALID";

// Flag is true if user was already set in the authContext before this authenticator was triggered. In this case we skip clearing of the user after unsuccessful password authentication
protected static final String USER_SET_BEFORE_USERNAME_PASSWORD_AUTH = "USER_SET_BEFORE_USERNAME_PASSWORD_AUTH";
public static final String USER_SET_BEFORE_USERNAME_PASSWORD_AUTH = "USER_SET_BEFORE_USERNAME_PASSWORD_AUTH";

@Override
public void action(AuthenticationFlowContext context) {
Expand Down Expand Up @@ -219,11 +219,7 @@ private boolean badPasswordHandler(AuthenticationFlowContext context, UserModel
context.getEvent().user(user);
context.getEvent().error(Errors.INVALID_USER_CREDENTIALS);

if (isUserAlreadySetBeforeUsernamePasswordAuth(context)) {
LoginFormsProvider form = context.form();
form.setAttribute(LoginFormsProvider.USERNAME_HIDDEN, true);
form.setAttribute(LoginFormsProvider.REGISTRATION_DISABLED, true);
}
AuthenticatorUtils.setupReauthenticationInUsernamePasswordFormError(context);

Response challengeResponse = challenge(context, getDefaultChallengeMessage(context), FIELD_PASSWORD);
if(isEmptyPassword) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public UsernameForm(KeycloakSession session) {

@Override
public void authenticate(AuthenticationFlowContext context) {
if (context.getUser() != null) {
if (context.getUser() != null && !isConditionalPasskeysEnabled()) {
// 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)) {
context.success();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,10 @@ public void authenticate(AuthenticationFlowContext context) {
formData.add("rememberMe", "on");
}
}
// setup webauthn data when the user is not already selected
if (webauthnAuth != null && webauthnAuth.isPasskeysEnabled()) {
webauthnAuth.fillContextForm(context);
}
}
// setup webauthn data when passkeys enabled
if (isConditionalPasskeysEnabled(context.getUser())) {
webauthnAuth.fillContextForm(context);
}
Response challengeResponse = challenge(context, formData);
context.challenge(challengeResponse);
Expand All @@ -134,8 +134,8 @@ protected Response challenge(AuthenticationFlowContext context, MultivaluedMap<S

@Override
protected Response challenge(AuthenticationFlowContext context, String error, String field) {
if (context.getUser() == null && webauthnAuth != null && webauthnAuth.isPasskeysEnabled()) {
// setup webauthn data when the user is not already selected
if (isConditionalPasskeysEnabled(context.getUser())) {
// setup webauthn data when possible
webauthnAuth.fillContextForm(context);
}
return super.challenge(context, error, field);
Expand All @@ -157,4 +157,8 @@ public void close() {

}

protected boolean isConditionalPasskeysEnabled(UserModel user) {
return webauthnAuth != null && webauthnAuth.isPasskeysEnabled() && user != null;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,8 @@ public LoginFormsProvider fillContextForm(AuthenticationFlowContext context) {

UserModel user = context.getUser();
boolean isUserIdentified = false;
if (user != null) {

if (shouldShowWebAuthnAuthenticators(context)) {
// 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()) {
Expand All @@ -120,6 +121,14 @@ public LoginFormsProvider fillContextForm(AuthenticationFlowContext context) {
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) {
return context.getRealm().getWebAuthnPolicy();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import java.util.function.Function;
import org.keycloak.WebAuthnConstants;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.authenticators.util.AuthenticatorUtils;
import org.keycloak.common.Profile;
import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.models.KeycloakSession;
Expand Down Expand Up @@ -48,6 +49,9 @@ protected Response createErrorResponse(AuthenticationFlowContext context, final
// the passkey failed, show error and maintain passkeys
context.form().setError(errorCase, "");
context.form().setAttribute(WebAuthnConstants.ENABLE_WEBAUTHN_CONDITIONAL_UI, Boolean.TRUE);

AuthenticatorUtils.setupReauthenticationInUsernamePasswordFormError(context);

fillContextForm(context);
return errorChallenge.apply(context);
}
Expand All @@ -56,4 +60,9 @@ public boolean isPasskeysEnabled() {
return Profile.isFeatureEnabled(Profile.Feature.PASSKEYS) &&
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import org.keycloak.common.util.Time;
import org.keycloak.credential.hash.PasswordHashProvider;
import org.keycloak.events.Errors;
import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.models.*;
import org.keycloak.services.managers.BruteForceProtector;
import org.keycloak.sessions.AuthenticationSessionModel;
Expand All @@ -31,6 +32,8 @@
import java.io.IOException;
import java.util.Map;

import static org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator.USER_SET_BEFORE_USERNAME_PASSWORD_AUTH;

/**
* @author Vaclav Muzikar <[email protected]>
*/
Expand Down Expand Up @@ -116,4 +119,17 @@ public static void updateCompletedExecutions(AuthenticationSessionModel authSess
throw new IllegalStateException(e);
}
}


// Make sure that form is setup for "re-authentication" rather than regular authentication if some error happens during re-authentication
public static void setupReauthenticationInUsernamePasswordFormError(AuthenticationFlowContext context) {
String userAlreadySetBeforeUsernamePasswordAuth = context.getAuthenticationSession().getAuthNote(USER_SET_BEFORE_USERNAME_PASSWORD_AUTH);

if (Boolean.parseBoolean(userAlreadySetBeforeUsernamePasswordAuth)) {
LoginFormsProvider form = context.form();
form.setAttribute(LoginFormsProvider.USERNAME_HIDDEN, true);
form.setAttribute(LoginFormsProvider.REGISTRATION_DISABLED, true);
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import org.keycloak.models.credential.WebAuthnCredentialModel;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.organization.authentication.authenticators.browser.OrganizationAuthenticatorFactory;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.representations.idm.AuthenticationExecutionInfoRepresentation;
import org.keycloak.representations.idm.AuthenticatorConfigRepresentation;
import org.keycloak.representations.idm.OrganizationDomainRepresentation;
Expand Down Expand Up @@ -193,10 +194,10 @@ public void passwordLoginWithNonDiscoverableKey() throws Exception {
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
// now the passkeys username password page should be presented with username selected. Passkeys still enabled
loginPage.assertCurrent();
MatcherAssert.assertThat(loginPage.getAttemptedUsername(), Matchers.is("userwebauthn"));
Assert.assertThrows(NoSuchElementException.class, () -> driver.findElement(By.xpath("//form[@id='webauth']")));
MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue());
loginPage.login("invalid-password");
loginPage.assertCurrent();
MatcherAssert.assertThat(loginPage.getPasswordInputError(), Matchers.is("Invalid password."));
Expand All @@ -207,7 +208,7 @@ public void passwordLoginWithNonDiscoverableKey() throws Exception {

// correct login now
MatcherAssert.assertThat(loginPage.getAttemptedUsername(), Matchers.is("userwebauthn"));
Assert.assertThrows(NoSuchElementException.class, () -> driver.findElement(By.xpath("//form[@id='webauth']")));
MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue());
loginPage.login(getPassword(USERNAME));
appPage.assertCurrent();
events.expectLogin()
Expand Down Expand Up @@ -263,4 +264,59 @@ public void passwordLoginWithExternalKey() throws Exception {
logout();
}
}

// Test users is able to authenticate with passkey during re-authentication (for example when OIDC parameter prompt=login is used)
@Test
public void webauthnLoginWithDiscoverableKey_reauthentication() throws IOException {
getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.PASSKEYS.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()) {

checkWebAuthnConfiguration(Constants.WEBAUTHN_POLICY_OPTION_YES, Constants.WEBAUTHN_POLICY_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();

// Re-authentication now with prompt=login. Passkeys login should be possible.
oauth.loginForm()
.prompt(OIDCLoginProtocol.PROMPT_VALUE_LOGIN)
.open();
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();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
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.AuthenticationExecutionExportRepresentation;
import org.keycloak.representations.idm.AuthenticationFlowRepresentation;
import org.keycloak.representations.idm.CredentialRepresentation;
Expand Down Expand Up @@ -254,4 +255,68 @@ public void passwordLoginWithExternalKey() throws Exception {
logout();
}
}

// Test that user is able to authenticate with passkeys even during re-authentication (For example when OIDC parameter prompt=login is used)
@Test
public void webauthnLoginWithDiscoverableKey_reauthentication() throws IOException {
getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.PASSKEYS.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()) {

checkWebAuthnConfiguration(Constants.WEBAUTHN_POLICY_OPTION_YES, Constants.WEBAUTHN_POLICY_OPTION_REQUIRED);

registerDefaultUser();

UserRepresentation user = userResource().toRepresentation();
MatcherAssert.assertThat(user, Matchers.notNullValue());

logout();

// remove the password, so passkeys are the only credential in the user
final CredentialRepresentation passwordCredRep = userResource().credentials().stream()
.filter(cred -> PasswordCredentialModel.TYPE.equals(cred.getType()))
.findAny()
.orElse(null);
Assert.assertNotNull("User has no password credential", passwordCredRep);
userResource().removeCredential(passwordCredRep.getId());

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();

// Re-authentication now with prompt=login. Passkeys login should be possible.
oauth.loginForm()
.prompt(OIDCLoginProtocol.PROMPT_VALUE_LOGIN)
.open();
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();
}
}
}
Loading
Loading