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 @@ -49,6 +49,7 @@
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.http.HttpRequest;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
Expand Down Expand Up @@ -87,12 +88,16 @@
import com.webauthn4j.verifier.attestation.statement.u2f.FIDOU2FAttestationStatementVerifier;
import com.webauthn4j.verifier.attestation.trustworthiness.certpath.CertPathTrustworthinessVerifier;
import com.webauthn4j.verifier.attestation.trustworthiness.self.DefaultSelfAttestationTrustworthinessVerifier;
import com.webauthn4j.verifier.exception.UserNotPresentException;
import com.webauthn4j.verifier.exception.UserNotVerifiedException;
import org.jboss.logging.Logger;

import static org.keycloak.WebAuthnConstants.REG_ERR_DETAIL_LABEL;
import static org.keycloak.WebAuthnConstants.REG_ERR_LABEL;
import static org.keycloak.services.messages.Messages.WEBAUTHN_ERROR_REGISTER_VERIFICATION;
import static org.keycloak.services.messages.Messages.WEBAUTHN_ERROR_REGISTRATION;
import static org.keycloak.services.messages.Messages.WEBAUTHN_ERROR_USER_NOT_PRESENT;
import static org.keycloak.services.messages.Messages.WEBAUTHN_ERROR_USER_NOT_VERIFIED;
import static org.keycloak.services.messages.Messages.WEBAUTHN_REGISTER_TITLE;

/**
Expand Down Expand Up @@ -319,7 +324,7 @@ public void processAction(RequiredActionContext context) {
context.success();
} catch (WebAuthnException wae) {
if (logger.isDebugEnabled()) logger.debug(wae.getMessage(), wae);
setErrorResponse(context, WEBAUTHN_ERROR_REGISTRATION, wae.getMessage(), originalEventType);
setErrorResponse(context, WEBAUTHN_ERROR_REGISTRATION, wae.getMessage(), getWebAuthnErrorMessageKey(wae), originalEventType);
return;
} catch (Exception e) {
if (logger.isDebugEnabled()) logger.debug(e.getMessage(), e);
Expand Down Expand Up @@ -451,7 +456,25 @@ public void evaluateTriggers(RequiredActionContext context) {
// NOP
}

private static String getWebAuthnErrorMessageKey(WebAuthnException exception) {
// Only map exceptions that represent a single failure reason in WebAuthn4J. Other exception types can
// represent multiple reasons and cannot be translated to one accurate user-facing message.
if (exception instanceof UserNotPresentException) {
return WEBAUTHN_ERROR_USER_NOT_PRESENT;
Comment thread
AkashKumar7902 marked this conversation as resolved.
} else if (exception instanceof UserNotVerifiedException) {
return WEBAUTHN_ERROR_USER_NOT_VERIFIED;
}
return null;
}

private void setErrorResponse(RequiredActionContext context, final String errorCase, final String errorMessage, @Deprecated final EventType originalEventType) {
setErrorResponse(context, errorCase, errorMessage, null, originalEventType);
}

private void setErrorResponse(RequiredActionContext context, final String errorCase, final String errorMessage, final String userFacingErrorMessageKey,
@Deprecated final EventType originalEventType) {
LoginFormsProvider form = context.form();
String userFacingErrorMessage = userFacingErrorMessageKey == null ? errorMessage : form.getMessage(userFacingErrorMessageKey);
Response errorResponse = null;
switch (errorCase) {
case WEBAUTHN_ERROR_REGISTER_VERIFICATION:
Expand All @@ -462,7 +485,7 @@ private void setErrorResponse(RequiredActionContext context, final String errorC
EventBuilder deprecatedRegisterVerificationEvent = registerVerificationEvent.clone().event(originalEventType);
registerVerificationEvent.error(Errors.INVALID_USER_CREDENTIALS);
deprecatedRegisterVerificationEvent.error(Errors.INVALID_USER_CREDENTIALS);
errorResponse = context.form()
errorResponse = form
.setError(errorCase, errorMessage)
.setAttribute(WEB_AUTHN_TITLE_ATTR, WEBAUTHN_REGISTER_TITLE)
.createWebAuthnErrorPage();
Expand All @@ -476,8 +499,8 @@ private void setErrorResponse(RequiredActionContext context, final String errorC
EventBuilder deprecatedRegistrationEvent = registrationEvent.clone().event(originalEventType);
deprecatedRegistrationEvent.error(Errors.INVALID_REGISTRATION);
registrationEvent.error(Errors.INVALID_REGISTRATION);
errorResponse = context.form()
.setError(errorCase, errorMessage)
errorResponse = form
.setError(errorCase, userFacingErrorMessage)
.setAttribute(WEB_AUTHN_TITLE_ATTR, WEBAUTHN_REGISTER_TITLE)
.createWebAuthnErrorPage();
context.challenge(errorResponse);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,8 @@ public class Messages {
public static final String WEBAUTHN_ERROR_AUTH_VERIFICATION = "webauthn-error-auth-verification";
public static final String WEBAUTHN_ERROR_REGISTER_VERIFICATION = "webauthn-error-register-verification";
public static final String WEBAUTHN_ERROR_USER_NOT_FOUND = "webauthn-error-user-not-found";
public static final String WEBAUTHN_ERROR_USER_NOT_PRESENT = "webauthn-error-user-not-present";
public static final String WEBAUTHN_ERROR_USER_NOT_VERIFIED = "webauthn-error-user-not-verified";

// Conditions in Conditional Flow
public static final String ACCESS_DENIED = "access-denied";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
package org.keycloak.tests.webauthn;

import java.time.Duration;
import java.util.List;

import org.keycloak.common.util.SecretGenerator;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;

import com.webauthn4j.converter.AttestationObjectConverter;
import com.webauthn4j.converter.util.ObjectConverter;
import com.webauthn4j.data.attestation.AttestationObject;
import com.webauthn4j.data.attestation.authenticator.AuthenticatorData;
import com.webauthn4j.data.extension.authenticator.RegistrationExtensionAuthenticatorOutput;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.JavascriptExecutor;
import org.openqa.selenium.support.ui.WebDriverWait;

import static org.keycloak.WebAuthnConstants.AUTHENTICATOR_ATTACHMENT_CROSS_PLATFORM;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.is;

/**
* Tests that WebAuthn registration enforces realm policy server-side.
Expand Down Expand Up @@ -77,7 +85,46 @@ public void tamperedUserVerification() {
tamperCreateOptions(
"opts.publicKey.authenticatorSelection = opts.publicKey.authenticatorSelection || {};" +
"opts.publicKey.authenticatorSelection.userVerification = 'discouraged';"),
"Verifier is configured to check user verified, but UV flag in authenticatorData is not set.");
"User verification is required.");
}

@Test
public void tamperedUserPresence() {
String testId = "up-tamper";
String username = "policy-" + testId;
String email = "policy-" + testId + "@email";

managedRealm.updateWithCleanup(r -> r
.webAuthnPolicyAttestationConveyancePreference("none"));

oAuthClient.openRegistrationForm();
registerPage.assertCurrent();
registerPage.register("firstName", "lastName", email, username, PASSWORD);

webAuthnRegisterPage.assertCurrent();

((JavascriptExecutor) driver.driver()).executeScript(
"document.getElementById('register').addEventListener('submit', function(event) {" +
" event.preventDefault();" +
"}, { once: true });");

webAuthnRegisterPage.clickRegister();
webAuthnRegisterPage.registerWebAuthnCredential(SecretGenerator.getInstance().randomString(24));

String encodedAttestationObject = new WebDriverWait(driver.driver(), Duration.ofSeconds(10)).until(webDriver -> {
String value = (String) ((JavascriptExecutor) webDriver).executeScript(
"return document.getElementById('attestationObject').value;");
return value.isEmpty() ? null : value;
});
String tamperedAttestationObject = clearUserPresenceFlag(encodedAttestationObject);

((JavascriptExecutor) driver.driver()).executeScript(
"document.getElementById('attestationObject').value = arguments[0];" +
"document.getElementById('register').requestSubmit();",
tamperedAttestationObject);

webAuthnErrorPage.assertCurrent();
assertThat(webAuthnErrorPage.getError(), containsString("User presence is required."));
}

@Test
Expand Down Expand Up @@ -181,4 +228,22 @@ private static String tamperCreateOptions(String body) {
" return origCreate(opts);" +
"};";
}

private static String clearUserPresenceFlag(String encodedAttestationObject) {
AttestationObjectConverter converter = new AttestationObjectConverter(new ObjectConverter());
AttestationObject attestationObject = converter.convert(encodedAttestationObject);
AuthenticatorData<RegistrationExtensionAuthenticatorOutput> authenticatorData = attestationObject.getAuthenticatorData();
assertThat("Test credential must initially have user presence", authenticatorData.isFlagUP(), is(true));

AuthenticatorData<RegistrationExtensionAuthenticatorOutput> tamperedAuthenticatorData = new AuthenticatorData<>(
authenticatorData.getRpIdHash(),
(byte) (authenticatorData.getFlags() & ~AuthenticatorData.BIT_UP),
authenticatorData.getSignCount(),
authenticatorData.getAttestedCredentialData(),
authenticatorData.getExtensions());

return converter.convertToBase64urlString(new AttestationObject(
tamperedAuthenticatorData,
attestationObject.getAttestationStatement()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,8 @@ webauthn-error-different-user=First authenticated user is not the one authentica
webauthn-error-auth-verification=Passkey authentication result is invalid. {0}
webauthn-error-register-verification=Passkey registration result is invalid. {0}
webauthn-error-user-not-found=Unknown user authenticated by the Passkey.
webauthn-error-user-not-present=User presence is required.
webauthn-error-user-not-verified=User verification is required.

# Passkey
passkey-login-title=Passkey login
Expand Down
Loading