Skip AIA for webauthn register if a crendential of teh correct type already exists

Closes #39191

Signed-off-by: rmartinc <rmartinc@redhat.com>
This commit is contained in:
rmartinc 2025-05-19 15:40:56 +02:00 committed by Marek Posolda
parent 72b0df7bee
commit 3c511635ba
7 changed files with 120 additions and 1 deletions

View File

@ -29,3 +29,7 @@ details, see link:{upgradingguide_link}[{upgradingguide_name}].
= Recovery Codes supported
In this release, the *Recovery Codes* two-factor authentication is promoted from preview to supported feature. For more information about the 2FA method, see the link:{adminguide_link}#_recovery-codes[Recovery Codes] chapter in the {adminguide_name}.
= New AIA action parameter `skip_if_exists` for WebAuthn register
Both WebAuthn Register actions (`webauthn-register` and `webauthn-register-passwordless`) now support a parameter `skip_if_exists` when initiated by the application (AIA). The parameter allows to skip the action if the user already has a credential of that type. For more information, see the link:{adminguide_link}#_webauthn_aia[Registering WebAuthn credentials using AIA] chapter in the {adminguide_name}.

View File

@ -191,6 +191,13 @@ If `WebAuthn Authenticator` is set up as required as shown in the first example,
After successful registration, the user's browser asks the user to enter the text of their WebAuthn authenticator's label.
[[_webauthn_aia]]
==== Registering WebAuthn credentials using AIA
WebAuthn credentials can also be registered for a user using <<con-aia_{context},Application Initiated Actions (AIA)>>. The actions *Webauthn Register* (`kc_action=webauthn-register`) and *Webauthn Register Passwordless* (`kc_action=webauthn-register-passwordless`) are available for the applications if enabled in the <<proc-setting-default-required-actions_{context}, Required actions tab>>.
Both required actions allow a parameter *skip_if_exists* that allows to skip the AIA execution if the user already has a credential of that type. The `kc_action_status` will be *success* if skipped. For example, adding the option to the common WebAuthn register action is just using the following query parameter `kc_action=webauthn-register:skip_if_exists`.
[[_webauthn_passwordless]]
==== Passwordless WebAuthn together with Two-Factor

View File

@ -87,6 +87,8 @@ public final class Constants {
public static final String KC_ACTION = "kc_action";
public static final String KC_ACTION_PARAMETER = "kc_action_parameter";
// parameter used by some actions to skip executing it if a credential for that type already exists for the user
public static final String KC_ACTION_PARAMETER_SKIP_IF_EXISTS = "skip_if_exists";
public static final String KC_ACTION_STATUS = "kc_action_status";
public static final String KC_ACTION_EXECUTING = "kc_action_executing";
/**

View File

@ -52,6 +52,7 @@ import org.keycloak.credential.WebAuthnCredentialProviderFactory;
import org.keycloak.crypto.Algorithm;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserModel;
import org.keycloak.models.WebAuthnPolicy;
@ -107,7 +108,14 @@ public class WebAuthnRegister implements RequiredActionProvider, CredentialRegis
@Override
public void requiredActionChallenge(RequiredActionContext context) {
String actionParameter = context.getAuthenticationSession().getClientNote(Constants.KC_ACTION_PARAMETER);
UserModel userModel = context.getUser();
if (Constants.KC_ACTION_PARAMETER_SKIP_IF_EXISTS.equals(actionParameter)
&& userModel.credentialManager().getStoredCredentialsByTypeStream(getCredentialType()).findAny().isPresent()) {
context.success();
return;
}
// Use standard UTF-8 charset to get bytes from string.
// Otherwise the platform's default charset is used and it might cause problems later when
// decoded on different system.

View File

@ -0,0 +1,67 @@
/*
* Copyright 2025 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;
import java.io.IOException;
import org.junit.Test;
import org.keycloak.models.Constants;
import org.keycloak.models.credential.WebAuthnCredentialModel;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.arquillian.annotation.IgnoreBrowserDriver;
import org.openqa.selenium.firefox.FirefoxDriver;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.greaterThan;
import static org.hamcrest.Matchers.is;
/**
*
* @author rmartinc
*/
public class AppInitiatedActionWebAuthnSkipIfExistsTest extends AppInitiatedActionWebAuthnTest {
@Override
public String getAiaAction() {
return WEB_AUTHN_REGISTER_PROVIDER + ":" + Constants.KC_ACTION_PARAMETER_SKIP_IF_EXISTS;
}
public String getCredentialType() {
return isPasswordless() ? WebAuthnCredentialModel.TYPE_PASSWORDLESS : WebAuthnCredentialModel.TYPE_TWOFACTOR;
}
@Test
@IgnoreBrowserDriver(FirefoxDriver.class) // See https://github.com/keycloak/keycloak/issues/10368
public void processSetupTwice() throws IOException {
testWebAuthnLogoutOtherSessions(false);
final long credentialsCount = ApiUtil.findUserByUsernameId(testRealm(), DEFAULT_USERNAME)
.credentials()
.stream()
.filter(c -> c.getType().equals(getCredentialType()))
.count();
assertThat(credentialsCount, greaterThan(0L));
// do a second AIA that should be skiped
doAIA();
assertKcActionStatus(SUCCESS);
assertThat(ApiUtil.findUserByUsernameId(testRealm(), DEFAULT_USERNAME)
.credentials()
.stream()
.filter(c -> c.getType().equals(getCredentialType()))
.count(), is(credentialsCount));
}
}

View File

@ -180,7 +180,7 @@ public class AppInitiatedActionWebAuthnTest extends AbstractAppInitiatedActionTe
testWebAuthnLogoutOtherSessions(false);
}
private void testWebAuthnLogoutOtherSessions(boolean logoutOtherSessions) throws IOException {
protected void testWebAuthnLogoutOtherSessions(boolean logoutOtherSessions) throws IOException {
UserResource testUser = testRealm().users().get(findUser(DEFAULT_USERNAME).getId());
// perform a login using normal user/password form to have an old session

View File

@ -0,0 +1,31 @@
/*
* Copyright 2025 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 org.keycloak.testsuite.webauthn.AppInitiatedActionWebAuthnSkipIfExistsTest;
/**
* @author rmartinc
*/
public class AppInitiatedActionPwdLessSkipIfExistsTest extends AppInitiatedActionWebAuthnSkipIfExistsTest {
@Override
protected boolean isPasswordless() {
return true;
}
}