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 @@ -122,7 +122,7 @@ The configurable items and their description are as follows:
|If enabled, {project_name} cannot re-register an already registered WebAuthn authenticator.

|Acceptable AAGUIDs
|The white list of AAGUIDs which a WebAuthn authenticator must register against.
|The list of allowed AAGUIDs which a WebAuthn authenticator must register against. An AAGUID (Authenticator Attestation Global Unique Identifier) is a 128-bit identifier indicating the authenticator's type (e.g., make and model). This option needs the **Attestation conveyance preference** to be configured (normally `Direct`) to ensure a trusted AAGUID is passed. Default attestation `None` is not reliable, and can anonymize the AAGUID to zero value. If you setup **Acceptable AAGUIDs** only those authenticators are valid to register new WebAuthn credentials.

|===

Expand Down
12 changes: 12 additions & 0 deletions docs/documentation/upgrading/topics/changes/changes-26_6_2.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
== Notable changes

Notable changes may include internal behavior changes that prevent common misconfigurations, bugs that are fixed, or changes to simplify running {project_name}.
It also lists significant changes to internal APIs.

=== WebAuthn acceptable AAGUIDs option restricts authenticators strictly

The WebAuthn policy presents the option **Acceptable AAGUIDs** to restrict the authenticators that are allowed to register new credentials. The AAGUID (Authenticator Attestation Global Unique Identifier) is an identifier for the authenticator's type (e.g., make and model). This option requires the **Attestation conveyance preference** to be configured too (normally `Direct`), in order to force the authenticator to include the attestation inside the registration data.

Since this release, when this option is setup, the attestation is required to be present and signed with a valid certificate for the {project_name} trust-store. The `None` attestation format is explicitly not permitted. Previously, there were some corner cases in which a self attestation was accepted. The change is expected to be harmless, but maybe there are combinations of authenticators and WebAuthn policies that can present issues.

See chapter link:{adminguide_link}#_webauthn-policy[Managing policy] in the {adminguide_name} for more information.
Original file line number Diff line number Diff line change
Expand Up @@ -607,7 +607,8 @@ regenerate=Regenerate
ignoreMissingGroups=Ignore missing groups
sslType.external=External requests
showMetaData=Show metadata
webAuthnPolicyAttestationConveyancePreferenceHelp=Communicates to an authenticator the preference of how to generate an attestation statement.
webAuthnPolicyAttestationConveyancePreferenceHelp=Communicates to an authenticator the preference of how to generate an attestation statement. None is used by specification if not specified.
acceptableAAGUIDsRequiresAttestation=Acceptable AAGUIDs require an attestation conveyance preference other than None
top-level-flow-type.basic-flow=Basic flow
groupRemoveError=Error removing group {error}
temporaryPasswordHelpText=If enabled, the user must change the password on the next login
Expand Down Expand Up @@ -1339,7 +1340,7 @@ revoke=Revoke
admin=Admin
syncUsersError=Could not sync users\: '{{error}}'
generatedAccessTokenHelp=See the example access token, which will be generated and sent to the client when the selected user is authenticated. You can see claims and roles that the token will contain based on the effective protocol mappers and role scope mappings and also based on the claims and roles assigned to the actual user.
webAuthnPolicyAcceptableAaguidsHelp=The list of allowed AAGUIDs of which an authenticator can be registered. An AAGUID is a 128-bit identifier indicating the authenticator's type (e.g., make and model).
webAuthnPolicyAcceptableAaguidsHelp=The list of allowed AAGUIDs of which an authenticator can be registered. An AAGUID is a 128-bit identifier indicating the authenticator's type (e.g., make and model). This option needs the Attestation conveyance preference to be configured (normally `Direct`) to ensure a trusted AAGUID is passed. Default attestation `None` is not reliable, and can anonymize the AAGUID to zero value.
keyPasswordHelp=Password for the private key
frontchannelLogout=Front channel logout
logoutConfirmation=Logout confirmation
Expand Down
21 changes: 19 additions & 2 deletions js/apps/admin-ui/src/authentication/policies/WebauthnPolicy.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
import type { FieldValues } from "react-hook-form";
import {
ActionGroup,
AlertVariant,
Expand All @@ -12,7 +13,7 @@ import {
} from "@patternfly/react-core";
import { QuestionCircleIcon } from "@patternfly/react-icons";
import { useEffect } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { FormProvider, useForm, Validate } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
HelpItem,
Expand Down Expand Up @@ -71,6 +72,7 @@ type WeauthnSelectProps = {
options: readonly string[];
labelPrefix?: string;
isMultiSelect?: boolean;
validate?: Validate<any, FieldValues>;
};

const WebauthnSelect = ({
Expand All @@ -80,6 +82,7 @@ const WebauthnSelect = ({
options,
labelPrefix,
isMultiSelect = false,
validate,
}: WeauthnSelectProps) => {
const { t } = useTranslation();
return (
Expand All @@ -88,7 +91,7 @@ const WebauthnSelect = ({
label={label}
labelIcon={labelIcon}
variant={isMultiSelect ? "typeaheadMulti" : "single"}
controller={{ defaultValue: options[0] }}
controller={{ defaultValue: options[0], rules: { validate: validate } }}
options={options.map((option) => ({
key: option,
value: labelPrefix ? t(`${labelPrefix}.${option}`) : option,
Expand Down Expand Up @@ -119,6 +122,7 @@ export const WebauthnPolicy = ({
setValue,
handleSubmit,
formState: { isDirty },
watch,
} = form;

const namePrefix = isPasswordLess
Expand All @@ -143,6 +147,7 @@ export const WebauthnPolicy = ({
};

const isFeatureEnabled = useIsFeatureEnabled();
const acceptableAAGUIDs = watch(`${namePrefix}AcceptableAaguids`, []);

return (
<PageSection variant="light">
Expand Down Expand Up @@ -187,6 +192,18 @@ export const WebauthnPolicy = ({
labelIcon={t("webAuthnPolicyAttestationConveyancePreferenceHelp")}
options={ATTESTATION_PREFERENCE}
labelPrefix="attestationPreference"
validate={(value) => {
const hasValidAAGUIDs = acceptableAAGUIDs.some(
(guid: string) => guid?.trim().length > 0,
);

if (
(value === "none" || value === "not specified") &&
hasValidAAGUIDs
) {
return t("acceptableAAGUIDsRequiresAttestation");
}
}}
Comment on lines +195 to +206
Copy link
Copy Markdown
Contributor

@mabartos mabartos Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rmartinc The emptiness check seems to be a little bit fragile. Would it be possible to use sth like this?

validate={(value) => {
  const hasValidAAGUIDs = acceptableAAGUIDs.some(guid => guid?.trim().length > 0);

  if ((value === "none" || value === "not specified") && hasValidAAGUIDs ) {
    return t("acceptableAAGUIDsRequiresAttestation");
  }
}}

cc: @edewit or do you know about a better approach? Thanks

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, much more robust! Done but with lint modifications...

/>
<WebauthnSelect
name={`${namePrefix}AuthenticatorAttachment`}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
import com.webauthn4j.data.attestation.authenticator.AttestedCredentialData;
import com.webauthn4j.data.attestation.statement.AttestationStatement;
import com.webauthn4j.data.attestation.statement.COSEAlgorithmIdentifier;
import com.webauthn4j.data.attestation.statement.NoneAttestationStatement;
import com.webauthn4j.data.client.Origin;
import com.webauthn4j.data.client.challenge.Challenge;
import com.webauthn4j.data.client.challenge.DefaultChallenge;
Expand Down Expand Up @@ -268,7 +269,7 @@ public void processAction(RequiredActionContext context) {
AuthenticatorUtil.logoutOtherSessions(context);
}

WebAuthnRegistrationManager webAuthnRegistrationManager = createWebAuthnRegistrationManager(policy.getAttestationConveyancePreference());
WebAuthnRegistrationManager webAuthnRegistrationManager = createWebAuthnRegistrationManager(policy);
try {
// parse
RegistrationData registrationData = webAuthnRegistrationManager.parse(registrationRequest);
Expand Down Expand Up @@ -318,11 +319,12 @@ public void processAction(RequiredActionContext context) {
* Create WebAuthnRegistrationManager instance
* Can be overridden in subclasses to customize the used attestation validators
*
* @param attestationPreference The attestation selected in the policy
* @param policy The webauthn policy defined
* @return webauthn4j WebAuthnRegistrationManager instance
*/
protected WebAuthnRegistrationManager createWebAuthnRegistrationManager(String attestationPreference) {
protected WebAuthnRegistrationManager createWebAuthnRegistrationManager(WebAuthnPolicy policy) {
List<AttestationStatementVerifier> verifiers = new ArrayList<>(6);
final String attestationPreference = policy.getAttestationConveyancePreference();
if (attestationPreference == null
|| Constants.DEFAULT_WEBAUTHN_POLICY_NOT_SPECIFIED.equals(attestationPreference)
|| AttestationConveyancePreference.NONE.getValue().equals(attestationPreference)) {
Expand All @@ -334,10 +336,15 @@ protected WebAuthnRegistrationManager createWebAuthnRegistrationManager(String a
verifiers.add(new AndroidSafetyNetAttestationStatementVerifier());
verifiers.add(new FIDOU2FAttestationStatementVerifier());

DefaultSelfAttestationTrustworthinessVerifier selfAttestationVerifier = new DefaultSelfAttestationTrustworthinessVerifier();
final List<String> acceptableAaguids = policy.getAcceptableAaguids();
// self attestation should be disabled to be sure the AAGUID can be trusted
selfAttestationVerifier.setSelfAttestationAllowed(acceptableAaguids == null || acceptableAaguids.isEmpty());

return new WebAuthnRegistrationManager(
verifiers,
this.certPathtrustVerifier,
new DefaultSelfAttestationTrustworthinessVerifier(),
selfAttestationVerifier,
Collections.emptyList(), // Custom Registration Verifier is not supported
new ObjectConverter()
);
Expand Down Expand Up @@ -406,20 +413,12 @@ private void showInfoAfterWebAuthnApiCreate(RegistrationData response) {
private void checkAcceptedAuthenticator(RegistrationData response, WebAuthnPolicy policy) throws Exception {
String aaguid = response.getAttestationObject().getAuthenticatorData().getAttestedCredentialData().getAaguid().toString();
List<String> acceptableAaguids = policy.getAcceptableAaguids();
boolean isAcceptedAuthenticator = false;
if (acceptableAaguids != null && !acceptableAaguids.isEmpty()) {
for(String acceptableAaguid : acceptableAaguids) {
if (aaguid.equals(acceptableAaguid)) {
isAcceptedAuthenticator = true;
break;
}
if (NoneAttestationStatement.FORMAT.equals(response.getAttestationObject().getFormat())) {
throw new WebAuthnException("Acceptable AAGUIDs require an attestation format other than 'none'.");
} else if (acceptableAaguids.stream().noneMatch(acceptableAaguid -> aaguid.equals(acceptableAaguid))) {
throw new WebAuthnException("not acceptable aaguid = " + aaguid);
}
} else {
// no accepted authenticators means accepting any kind of authenticator
isAcceptedAuthenticator = true;
}
if (!isAcceptedAuthenticator) {
throw new WebAuthnException("not acceptable aaguid = " + aaguid);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
package org.keycloak.tests.webauthn;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

Expand Down Expand Up @@ -53,7 +52,6 @@

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

import static org.keycloak.models.AuthenticationExecutionModel.Requirement.ALTERNATIVE;
Expand All @@ -80,15 +78,6 @@ public class WebAuthnRegisterAndLoginTest extends AbstractWebAuthnVirtualTest {
@InjectPage
SelectAuthenticatorPage selectAuthenticatorPage;

@BeforeEach
public void customizeWebAuthnTestRealm() {
List<String> acceptableAaguids = new ArrayList<>();
acceptableAaguids.add("00000000-0000-0000-0000-000000000000");
acceptableAaguids.add("6d44ba9b-f6ec-2e49-b930-0c8fe920cb73");

managedRealm.updateWithCleanup(r -> r.webAuthnPolicyAcceptableAaguids(acceptableAaguids));
}

@Test
public void registerUserSuccess() {
String username = "registerUserSuccess";
Expand Down Expand Up @@ -481,13 +470,12 @@ private List<CredentialRepresentation> getCredentials(String userId) {
}

private void updateRealmWithDefaultWebAuthnSettings() {
managedRealm.updateWithCleanup(r -> r.webAuthnPolicySignatureAlgorithms(List.of("ES256")));
managedRealm.updateWithCleanup(r -> r.webAuthnPolicyAttestationConveyancePreference("none"));
managedRealm.updateWithCleanup(r -> r.webAuthnPolicyAuthenticatorAttachment("cross-platform"));
managedRealm.updateWithCleanup(r -> r.webAuthnPolicyRequireResidentKey("No"));
managedRealm.updateWithCleanup(r -> r.webAuthnPolicyRpId(null));
managedRealm.updateWithCleanup(r -> r.webAuthnPolicyUserVerificationRequirement("preferred"));
managedRealm.updateWithCleanup(r -> r.webAuthnPolicyAcceptableAaguids(List.of(ALL_ZERO_AAGUID)));
managedRealm.updateWithCleanup(r -> r.webAuthnPolicySignatureAlgorithms(List.of("ES256"))
.webAuthnPolicyAttestationConveyancePreference("none")
.webAuthnPolicyAuthenticatorAttachment("cross-platform")
.webAuthnPolicyRequireResidentKey("No")
.webAuthnPolicyRpId(null)
.webAuthnPolicyUserVerificationRequirement("preferred"));
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ public abstract class AbstractWebAuthnVirtualTest extends AbstractChangeImported

protected static final String ALL_ZERO_AAGUID = "00000000-0000-0000-0000-000000000000";
protected static final String ALL_ONE_AAGUID = "11111111-1111-1111-1111-111111111111";
protected static final String CHROME_AAGUID = "01020304-0506-0708-0102-030405060708";
protected static final String USERNAME = "UserWebAuthn";
protected static final String EMAIL = "UserWebAuthn@email";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,19 +169,66 @@ public void acceptableAaguidsShouldBeEmptyOrNullByDefault() {
@Test
@IgnoreBrowserDriver(FirefoxDriver.class) // See https://github.com/keycloak/keycloak/issues/10368
public void excludeCredentials() throws IOException {
List<String> acceptableAaguids = Collections.singletonList(ALL_ONE_AAGUID);
List<String> acceptableAaguids = Collections.singletonList(ALL_ZERO_AAGUID);

try (Closeable u = getWebAuthnRealmUpdater()
.setWebAuthnPolicyAcceptableAaguids(acceptableAaguids)
.setWebAuthnPolicyAttestationConveyancePreference(AttestationConveyancePreference.DIRECT.getValue())
.update()) {
// webauthn virtual emulator in chrome sets a self signed certificate every time, truststore needs to be disabled
testingClient.testing().disableTruststoreSpi();

WebAuthnRealmData realmData = new WebAuthnRealmData(managedRealm.admin().toRepresentation(), isPasswordless());
assertThat(realmData.getAcceptableAaguids(), Matchers.contains(ALL_ONE_AAGUID));
assertThat(realmData.getAcceptableAaguids(), Matchers.contains(ALL_ZERO_AAGUID));

registerDefaultUser();

webAuthnErrorPage.assertCurrent();
assertThat(webAuthnErrorPage.getError(), allOf(containsString("not acceptable aaguid"), containsString(ALL_ZERO_AAGUID)));
assertThat(webAuthnErrorPage.getError(), allOf(containsString("not acceptable aaguid"), containsString(CHROME_AAGUID)));
} finally {
testingClient.testing().reenableTruststoreSpi();
}
}

@Test
@IgnoreBrowserDriver(FirefoxDriver.class) // See https://github.com/keycloak/keycloak/issues/10368
public void excludeCredentialsSuccess() throws IOException {
List<String> acceptableAaguids = Collections.singletonList(CHROME_AAGUID);

try (Closeable u = getWebAuthnRealmUpdater()
.setWebAuthnPolicyAcceptableAaguids(acceptableAaguids)
.setWebAuthnPolicyAttestationConveyancePreference(AttestationConveyancePreference.DIRECT.getValue())
.update()) {
// webauthn virtual emulator in chrome sets a self signed certificate every time, truststore needs to be disabled
testingClient.testing().disableTruststoreSpi();

WebAuthnRealmData realmData = new WebAuthnRealmData(managedRealm.admin().toRepresentation(), isPasswordless());
assertThat(realmData.getAcceptableAaguids(), Matchers.contains(CHROME_AAGUID));

registerDefaultUser();

appPage.assertCurrent();
} finally {
testingClient.testing().reenableTruststoreSpi();
}
}

@Test
@IgnoreBrowserDriver(FirefoxDriver.class) // See https://github.com/keycloak/keycloak/issues/10368
public void excludeCredentialsUsingNone() throws IOException {
List<String> acceptableAaguids = Collections.singletonList(ALL_ZERO_AAGUID);

try (Closeable u = getWebAuthnRealmUpdater()
.setWebAuthnPolicyAcceptableAaguids(acceptableAaguids)
.update()) {

WebAuthnRealmData realmData = new WebAuthnRealmData(managedRealm.admin().toRepresentation(), isPasswordless());
assertThat(realmData.getAcceptableAaguids(), Matchers.contains(ALL_ZERO_AAGUID));

registerDefaultUser();

webAuthnErrorPage.assertCurrent();
assertThat(webAuthnErrorPage.getError(), containsString("Acceptable AAGUIDs require an attestation format other than 'none'."));
}
}
}
Loading