8000 Keep the account-linking page after a passkey error in first broker login by Yunaido · Pull Request #55077 · keycloak/keycloak · GitHub
[go: up one dir, main page]

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
8000
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,20 @@ protected Response challenge(AuthenticationFlowContext context, String error, St
form.setError(error);
}
}
return createLoginForm(form);
return createLoginForm(context, form);
}

/**
* Keeps the username of the existing user and the hidden registration link also on pages created without
* {@link #setupForm}, for example after a failed passkey authentication.
*/
@Override
protected Response createLoginForm(AuthenticationFlowContext context, LoginFormsProvider form) {
MultivaluedMap<String, String> formData = new MultivaluedHashMap<>();
getExistingUser(context).ifPresent(u -> formData.putSingle(AuthenticationManager.FORM_USERNAME, u.getUsername()));
return form.setFormData(formData)
.setAttribute(LoginFormsProvider.REGISTRATION_DISABLED, true)
.createLoginUsernamePassword();
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,23 @@ protected Response challenge(AuthenticationFlowContext context, String error, St
form.setError(error);
}
}
return createLoginForm(context, form);
}

/**
* Creates the login page of this authenticator from the given form. It is called by
* {@link #challenge(AuthenticationFlowContext, String, String)}, for example after an invalid username or password,
* and by {@link UsernamePasswordForm} when a passkey authentication fails.
* <p>
* The default implementation delegates to {@link #createLoginForm(LoginFormsProvider)}. Override this method
* instead of {@link #createLoginForm(LoginFormsProvider)} when the page needs the flow context, for example the
* authenticator configuration or the authentication session.
*
* @param context the authentication flow context
* @param form the login form, including any error that should be displayed
* @return the response with the login page
*/
protected Response createLoginForm(AuthenticationFlowContext context, LoginFormsProvider form) {
return createLoginForm(form);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public UsernamePasswordForm() {
}

public UsernamePasswordForm(KeycloakSession session) {
webauthnAuth = new WebAuthnConditionalUIAuthenticator(session, (context) -> createLoginForm(context.form()));
webauthnAuth = new WebAuthnConditionalUIAuthenticator(session, (context) -> createLoginForm(context, context.form()));
}

@Override
Expand Down
8000
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/*
* Copyright 2026 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.tests.webauthn.passwordless;

import org.keycloak.broker.oidc.OIDCIdentityProviderFactory;
import org.keycloak.models.Constants;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testframework.annotations.InjectRealm;
import org.keycloak.testframework.realm.ClientBuilder;
import org.keycloak.testframework.realm.IdentityProviderBuilder;
import org.keycloak.testframework.realm.ManagedRealm;
import org.keycloak.testframework.realm.RealmBuilder;
import org.keycloak.testframework.realm.RealmConfig;
import org.keycloak.testframework.realm.UserBuilder;
import org.keycloak.testframework.ui.annotations.InjectPage;
import org.keycloak.testframework.ui.page.IdpConfirmLinkPage;
import org.keycloak.tests.webauthn.AbstractWebAuthnVirtualTest;
import org.keycloak.tests.webauthn.authenticators.DefaultVirtualAuthOptions;

import org.hamcrest.MatcherAssert;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

/**
* Passkeys on the re-authentication page of the first broker login flow ({@code IdpUsernamePasswordForm}).
*/
public class PasskeysIdpUsernamePasswordFormTest extends AbstractWebAuthnVirtualTest {

private static final String IDP_ALIAS = "provider-idp";
private static final String IDP_CLIENT_ID = "broker-client";
private static final String IDP_CLIENT_SECRET = "broker-secret";

@InjectRealm(ref = "provider", config = ProviderRealmConfig.class)
ManagedRealm providerRealm;

@InjectPage
IdpConfirmLinkPage idpConfirmLinkPage;

@Override
public boolean isPasswordless() {
return true;
}

@Test
public void passkeyErrorKeepsUsernameAndHidesRegistration() {
// use a default resident key which is not shown in conditional UI
getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.DEFAULT_RESIDENT_KEY.getOptions());

// short timeout so the test doesn't wait long for the passkey sign-in to fail
managedRealm.updateWithCleanup(r -> r.webAuthn(true, builder ->
builder.rpEntityName("localhost")
.residentKey(Constants.WEBAUTHN_POLICY_OPTION_REQUIRED)
.userVerificationRequirement(Constants.WEBAUTHN_POLICY_OPTION_REQUIRED)
.passkeysEnabled(true)
.timeout(3)
));

// existing user with a passkey, the realm allows registration
registerDefaultUser();
UserRepresentation user = userResource().toRepresentation();
logout();

managedRealm.admin().identityProviders().create(IdentityProviderBuilder.create()
.providerId(OIDCIdentityProviderFactory.PROVIDER_ID)
.alias(IDP_ALIAS)
.attribute("clientId", IDP_CLIENT_ID)
.attribute("clientSecret", IDP_CLIENT_SECRET)
.attribute("authorizationUrl", providerRealm.getBaseUrl() + "/protocol/openid-connect/auth")
.attribute("tokenUrl", providerRealm.getBaseUrl() + "/protocol/openid-connect/token")
.build()).close();
managedRealm.cleanup().add(r -> r.identityProviders().get(IDP_ALIAS).remove());

// the same user logs in through the identity provider and adds it to the existing account
oAuthClient.openLoginForm();
loginPage.clickSocial(IDP_ALIAS);
loginPage.fillLogin(USERNAME, PASSWORD);
loginPage.submit();
idpConfirmLinkPage.assertCurrent();
idpConfirmLinkPage.clickLinkAccount();

// no SMTP server is configured, so the existing account is verified by re-authentication
loginPage.assertCurrent();
Assertions.assertEquals(user.getUsername(), loginPage.getUsername());
Assertions.assertFalse(loginPage.isRegisterLinkPresent());

// remove the authenticator so the passkey sign-in fails with NotAllowedError, as when the user cancels it
getVirtualAuthManager().removeAuthenticator();
webAuthnLoginPage.clickAuthenticate();
driver.waiting().until(d -> loginPage.getErrorMessage().isPresent());
loginPage.assertCurrent();
MatcherAssert.assertThat(loginPage.getErrorMessage().orElse(null), Matchers.containsString("The Passkey operation was not allowed or timed out."));
Assertions.assertEquals(user.getUsername(), loginPage.getUsername());
Assertions.assertFalse(loginPage.isRegisterLinkPresent());

// the account is still linked by re-authentication with the password
loginPage.fillPassword(PASSWORD);
loginPage.submit();
Assertions.assertTrue(oAuthClient.parseLoginResponse().isSuccess());
}

public static class ProviderRealmConfig implements RealmConfig {

@Override
public RealmBuilder configure(RealmBuilder realm) {
return realm.clients(ClientBuilder.create(IDP_CLIENT_ID)
.secret(IDP_CLIENT_SECRET)
.redirectUris("*"))
.users(UserBuilder.create(USERNAME)
.name("firstName", "lastName")
.email(EMAIL)
.emailVerified(true)
.password(PASSWORD));
}
}
}
0