Skip to content

Commit bba869b

Browse files
mposoldaabstractj
authored andcommitted
Fixing Re-authentication with passkeys
closes #41242 closes #41008 Signed-off-by: mposolda <[email protected]>
1 parent 30f804a commit bba869b

File tree

9 files changed

+407
-24
lines changed

9 files changed

+407
-24
lines changed

services/src/main/java/org/keycloak/authentication/authenticators/browser/AbstractUsernameFormAuthenticator.java

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ public abstract class AbstractUsernameFormAuthenticator extends AbstractFormAuth
5555
public static final String SESSION_INVALID = "SESSION_INVALID";
5656

5757
// 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
58-
protected static final String USER_SET_BEFORE_USERNAME_PASSWORD_AUTH = "USER_SET_BEFORE_USERNAME_PASSWORD_AUTH";
58+
public static final String USER_SET_BEFORE_USERNAME_PASSWORD_AUTH = "USER_SET_BEFORE_USERNAME_PASSWORD_AUTH";
5959

6060
@Override
6161
public void action(AuthenticationFlowContext context) {
@@ -219,11 +219,7 @@ private boolean badPasswordHandler(AuthenticationFlowContext context, UserModel
219219
context.getEvent().user(user);
220220
context.getEvent().error(Errors.INVALID_USER_CREDENTIALS);
221221

222-
if (isUserAlreadySetBeforeUsernamePasswordAuth(context)) {
223-
LoginFormsProvider form = context.form();
224-
form.setAttribute(LoginFormsProvider.USERNAME_HIDDEN, true);
225-
form.setAttribute(LoginFormsProvider.REGISTRATION_DISABLED, true);
226-
}
222+
AuthenticatorUtils.setupReauthenticationInUsernamePasswordFormError(context);
227223

228224
Response challengeResponse = challenge(context, getDefaultChallengeMessage(context), FIELD_PASSWORD);
229225
if(isEmptyPassword) {

services/src/main/java/org/keycloak/authentication/authenticators/browser/UsernameForm.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ public UsernameForm(KeycloakSession session) {
4444

4545
@Override
4646
public void authenticate(AuthenticationFlowContext context) {
47-
if (context.getUser() != null) {
47+
if (context.getUser() != null && !isConditionalPasskeysEnabled()) {
4848
// 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
4949
if (!this.hasLinkedBrokers(context)) {
5050
context.success();

services/src/main/java/org/keycloak/authentication/authenticators/browser/UsernamePasswordForm.java

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,10 @@ public void authenticate(AuthenticationFlowContext context) {
110110
formData.add("rememberMe", "on");
111111
}
112112
}
113-
// setup webauthn data when the user is not already selected
114-
if (webauthnAuth != null && webauthnAuth.isPasskeysEnabled()) {
115-
webauthnAuth.fillContextForm(context);
116-
}
113+
}
114+
// setup webauthn data when passkeys enabled
115+
if (isConditionalPasskeysEnabled()) {
116+
webauthnAuth.fillContextForm(context);
117117
}
118118
Response challengeResponse = challenge(context, formData);
119119
context.challenge(challengeResponse);
@@ -134,8 +134,8 @@ protected Response challenge(AuthenticationFlowContext context, MultivaluedMap<S
134134

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

158158
}
159159

160+
protected boolean isConditionalPasskeysEnabled() {
161+
return webauthnAuth != null && webauthnAuth.isPasskeysEnabled();
162+
}
163+
160164
}

services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnAuthenticator.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,8 @@ public LoginFormsProvider fillContextForm(AuthenticationFlowContext context) {
9797

9898
UserModel user = context.getUser();
9999
boolean isUserIdentified = false;
100-
if (user != null) {
100+
101+
if (shouldShowWebAuthnAuthenticators(context)) {
101102
// in 2 Factor Scenario where the user has already been identified
102103
WebAuthnAuthenticatorsBean authenticators = new WebAuthnAuthenticatorsBean(context.getSession(), context.getRealm(), user, getCredentialType());
103104
if (authenticators.getAuthenticators().isEmpty()) {
@@ -120,6 +121,14 @@ public LoginFormsProvider fillContextForm(AuthenticationFlowContext context) {
120121
return form;
121122
}
122123

124+
/**
125+
* @param context authentication context
126+
* @return true if the available webauthn authenticators should be shown on the screen. Typically during 2-factor authentication for example
127+
*/
128+
protected boolean shouldShowWebAuthnAuthenticators(AuthenticationFlowContext context) {
129+
return context.getUser() != null;
130+
}
131+
123132
protected WebAuthnPolicy getWebAuthnPolicy(AuthenticationFlowContext context) {
124133
return context.getRealm().getWebAuthnPolicy();
125134
}

services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnConditionalUIAuthenticator.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import java.util.function.Function;
2121
import org.keycloak.WebAuthnConstants;
2222
import org.keycloak.authentication.AuthenticationFlowContext;
23+
import org.keycloak.authentication.authenticators.util.AuthenticatorUtils;
2324
import org.keycloak.common.Profile;
2425
import org.keycloak.forms.login.LoginFormsProvider;
2526
import org.keycloak.models.KeycloakSession;
@@ -48,6 +49,9 @@ protected Response createErrorResponse(AuthenticationFlowContext context, final
4849
// the passkey failed, show error and maintain passkeys
4950
context.form().setError(errorCase, "");
5051
context.form().setAttribute(WebAuthnConstants.ENABLE_WEBAUTHN_CONDITIONAL_UI, Boolean.TRUE);
52+
53+
AuthenticatorUtils.setupReauthenticationInUsernamePasswordFormError(context);
54+
5155
fillContextForm(context);
5256
return errorChallenge.apply(context);
5357
}
@@ -56,4 +60,9 @@ public boolean isPasskeysEnabled() {
5660
return Profile.isFeatureEnabled(Profile.Feature.PASSKEYS) &&
5761
Boolean.TRUE.equals(session.getContext().getRealm().getWebAuthnPolicyPasswordless().isPasskeysEnabled());
5862
}
63+
64+
// Do not show authenticators during login with conditional passkeys (For example during username/password)
65+
protected boolean shouldShowWebAuthnAuthenticators(AuthenticationFlowContext context) {
66+
return false;
67+
}
5968
}

services/src/main/java/org/keycloak/authentication/authenticators/util/AuthenticatorUtils.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import org.keycloak.common.util.Time;
2424
import org.keycloak.credential.hash.PasswordHashProvider;
2525
import org.keycloak.events.Errors;
26+
import org.keycloak.forms.login.LoginFormsProvider;
2627
import org.keycloak.models.*;
2728
import org.keycloak.services.managers.BruteForceProtector;
2829
import org.keycloak.sessions.AuthenticationSessionModel;
@@ -31,6 +32,8 @@
3132
import java.io.IOException;
3233
import java.util.Map;
3334

35+
import static org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator.USER_SET_BEFORE_USERNAME_PASSWORD_AUTH;
36+
3437
/**
3538
* @author Vaclav Muzikar <[email protected]>
3639
*/
@@ -116,4 +119,17 @@ public static void updateCompletedExecutions(AuthenticationSessionModel authSess
116119
throw new IllegalStateException(e);
117120
}
118121
}
122+
123+
124+
// Make sure that form is setup for "re-authentication" rather than regular authentication if some error happens during re-authentication
125+
public static void setupReauthenticationInUsernamePasswordFormError(AuthenticationFlowContext context) {
126+
String userAlreadySetBeforeUsernamePasswordAuth = context.getAuthenticationSession().getAuthNote(USER_SET_BEFORE_USERNAME_PASSWORD_AUTH);
127+
128+
if (Boolean.parseBoolean(userAlreadySetBeforeUsernamePasswordAuth)) {
129+
LoginFormsProvider form = context.form();
130+
form.setAttribute(LoginFormsProvider.USERNAME_HIDDEN, true);
131+
form.setAttribute(LoginFormsProvider.REGISTRATION_DISABLED, true);
132+
}
133+
}
134+
119135
}

testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/passwordless/PasskeysOrganizationAuthenticationTest.java

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import org.keycloak.models.credential.WebAuthnCredentialModel;
3333
import org.keycloak.models.utils.KeycloakModelUtils;
3434
import org.keycloak.organization.authentication.authenticators.browser.OrganizationAuthenticatorFactory;
35+
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
3536
import org.keycloak.representations.idm.AuthenticationExecutionInfoRepresentation;
3637
import org.keycloak.representations.idm.AuthenticatorConfigRepresentation;
3738
import org.keycloak.representations.idm.OrganizationDomainRepresentation;
@@ -193,10 +194,10 @@ public void passwordLoginWithNonDiscoverableKey() throws Exception {
193194
MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue());
194195
loginPage.loginUsername(USERNAME);
195196

196-
// now the passkeys username password page should be presented with username selected and passkeys disabled
197+
// now the passkeys username password page should be presented with username selected. Passkeys still enabled
197198
loginPage.assertCurrent();
198199
MatcherAssert.assertThat(loginPage.getAttemptedUsername(), Matchers.is("userwebauthn"));
199-
Assert.assertThrows(NoSuchElementException.class, () -> driver.findElement(By.xpath("//form[@id='webauth']")));
200+
MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue());
200201
loginPage.login("invalid-password");
201202
loginPage.assertCurrent();
202203
MatcherAssert.assertThat(loginPage.getPasswordInputError(), Matchers.is("Invalid password."));
@@ -207,7 +208,7 @@ public void passwordLoginWithNonDiscoverableKey() throws Exception {
207208

208209
// correct login now
209210
MatcherAssert.assertThat(loginPage.getAttemptedUsername(), Matchers.is("userwebauthn"));
210-
Assert.assertThrows(NoSuchElementException.class, () -> driver.findElement(By.xpath("//form[@id='webauth']")));
211+
MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue());
211212
loginPage.login(getPassword(USERNAME));
212213
appPage.assertCurrent();
213214
events.expectLogin()
@@ -263,4 +264,59 @@ public void passwordLoginWithExternalKey() throws Exception {
263264
logout();
264265
}
265266
}
267+
268+
// Test users is able to authenticate with passkey during re-authentication (for example when OIDC parameter prompt=login is used)
269+
@Test
270+
public void webauthnLoginWithDiscoverableKey_reauthentication() throws IOException {
271+
getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.PASSKEYS.getOptions());
272+
273+
// set passwordless policy for discoverable keys
274+
try (Closeable c = getWebAuthnRealmUpdater()
275+
.setWebAuthnPolicyRpEntityName("localhost")
276+
.setWebAuthnPolicyRequireResidentKey(Constants.WEBAUTHN_POLICY_OPTION_YES)
277+
.setWebAuthnPolicyUserVerificationRequirement(Constants.WEBAUTHN_POLICY_OPTION_REQUIRED)
278+
.setWebAuthnPolicyPasskeysEnabled(Boolean.TRUE)
279+
.update()) {
280+
281+
checkWebAuthnConfiguration(Constants.WEBAUTHN_POLICY_OPTION_YES, Constants.WEBAUTHN_POLICY_OPTION_REQUIRED);
282+
283+
registerDefaultUser();
284+
285+
UserRepresentation user = userResource().toRepresentation();
286+
MatcherAssert.assertThat(user, Matchers.notNullValue());
287+
288+
logout();
289+
events.clear();
290+
291+
// the user should be automatically logged in using the discoverable key
292+
oauth.openLoginForm();
293+
WaitUtils.waitForPageToLoad();
294+
295+
appPage.assertCurrent();
296+
297+
events.expectLogin()
298+
.user(user.getId())
299+
.detail(Details.USERNAME, user.getUsername())
300+
.detail(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS)
301+
.detail(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true")
302+
.assertEvent();
303+
304+
// Re-authentication now with prompt=login. Passkeys login should be possible.
305+
oauth.loginForm()
306+
.prompt(OIDCLoginProtocol.PROMPT_VALUE_LOGIN)
307+
.open();
308+
WaitUtils.waitForPageToLoad();
309+
310+
appPage.assertCurrent();
311+
312+
events.expectLogin()
313+
.user(user.getId())
314+
.detail(Details.USERNAME, user.getUsername())
315+
.detail(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS)
316+
.detail(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true")
317+
.assertEvent();
318+
319+
logout();
320+
}
321+
}
266322
}

testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/passwordless/PasskeysUsernameFormTest.java

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import org.keycloak.models.Constants;
3535
import org.keycloak.models.credential.PasswordCredentialModel;
3636
import org.keycloak.models.credential.WebAuthnCredentialModel;
37+
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
3738
import org.keycloak.representations.idm.AuthenticationExecutionExportRepresentation;
3839
import org.keycloak.representations.idm.AuthenticationFlowRepresentation;
3940
import org.keycloak.representations.idm.CredentialRepresentation;
@@ -254,4 +255,68 @@ public void passwordLoginWithExternalKey() throws Exception {
254255
logout();
255256
}
256257
}
258+
259+
// Test that user is able to authenticate with passkeys even during re-authentication (For example when OIDC parameter prompt=login is used)
260+
@Test
261+
public void webauthnLoginWithDiscoverableKey_reauthentication() throws IOException {
262+
getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.PASSKEYS.getOptions());
263+
264+
// set passwordless policy for discoverable keys
265+
try (Closeable c = getWebAuthnRealmUpdater()
266+
.setWebAuthnPolicyRpEntityName("localhost")
267+
.setWebAuthnPolicyRequireResidentKey(Constants.WEBAUTHN_POLICY_OPTION_YES)
268+
.setWebAuthnPolicyUserVerificationRequirement(Constants.WEBAUTHN_POLICY_OPTION_REQUIRED)
269+
.setWebAuthnPolicyPasskeysEnabled(Boolean.TRUE)
270+
.update()) {
271+
272+
checkWebAuthnConfiguration(Constants.WEBAUTHN_POLICY_OPTION_YES, Constants.WEBAUTHN_POLICY_OPTION_REQUIRED);
273+
274+
registerDefaultUser();
275+
276+
UserRepresentation user = userResource().toRepresentation();
277+
MatcherAssert.assertThat(user, Matchers.notNullValue());
278+
279+
logout();
280+
281+
// remove the password, so passkeys are the only credential in the user
282+
final CredentialRepresentation passwordCredRep = userResource().credentials().stream()
283+
.filter(cred -> PasswordCredentialModel.TYPE.equals(cred.getType()))
284+
.findAny()
285+
.orElse(null);
286+
Assert.assertNotNull("User has no password credential", passwordCredRep);
287+
userResource().removeCredential(passwordCredRep.getId());
288+
289+
events.clear();
290+
291+
// the user should be automatically logged in using the discoverable key
292+
oauth.openLoginForm();
293+
WaitUtils.waitForPageToLoad();
294+
295+
appPage.assertCurrent();
296+
297+
events.expectLogin()
298+
.user(user.getId())
299+
.detail(Details.USERNAME, user.getUsername())
300+
.detail(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS)
301+
.detail(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true")
302+
.assertEvent();
303+
304+
// Re-authentication now with prompt=login. Passkeys login should be possible.
305+
oauth.loginForm()
306+
.prompt(OIDCLoginProtocol.PROMPT_VALUE_LOGIN)
307+
.open();
308+
WaitUtils.waitForPageToLoad();
309+
310+
appPage.assertCurrent();
311+
312+
events.expectLogin()
313+
.user(user.getId())
314+
.detail(Details.USERNAME, user.getUsername())
315+
.detail(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS)
316+
.detail(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true")
317+
.assertEvent();
318+
319+
logout();
320+
}
321+
}
257322
}

0 commit comments

Comments
 (0)