From b89c621fa9f75edd6c9d810abe009898eafa0811 Mon Sep 17 00:00:00 2001 From: uzmamansoor09 Date: Tue, 7 Jul 2026 11:10:01 +0100 Subject: [PATCH 1/2] Closes #22524. Adding WebAuthn error messages Signed-off-by: uzmamansoor09 # Conflicts: # testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/WebAuthnIdlessTest.java # testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/registration/AttestationConveyanceRegisterTest.java # testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/registration/AuthAttachmentRegisterTest.java --- .../browser/WebAuthnAuthenticator.java | 17 +++- .../requiredactions/WebAuthnRegister.java | 97 ++++++++++++++++--- .../keycloak/services/messages/Messages.java | 9 ++ .../WebAuthnPolicyComplianceTest.java | 16 +-- .../webauthn/WebAuthnIdlessTest.java | 3 +- .../webauthn/WebAuthnPropertyTest.java | 2 +- .../webauthn/account/WebAuthnErrorTest.java | 2 +- .../account/WebAuthnSigningInTest.java | 4 +- .../AttestationConveyanceRegisterTest.java | 2 +- .../AuthAttachmentRegisterTest.java | 2 +- .../registration/PolicyJsInjectionTest.java | 4 +- .../registration/PubKeySignRegisterTest.java | 6 +- .../WebAuthnOtherSettingsTest.java | 88 ++++++++++++++++- .../login/messages/messages_en.properties | 15 ++- .../resources/js/webauthnAuthenticate.js | 2 +- .../login/resources/js/webauthnRegister.js | 2 +- 16 files changed, 224 insertions(+), 47 deletions(-) diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnAuthenticator.java index bdcab7858b79..55c7303bc983 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnAuthenticator.java @@ -60,10 +60,15 @@ import static org.keycloak.WebAuthnConstants.AUTH_ERR_DETAIL_LABEL; import static org.keycloak.WebAuthnConstants.AUTH_ERR_LABEL; +import static org.keycloak.authentication.requiredactions.WebAuthnRegister.mapBrowserApiErrorToMessageKey; import static org.keycloak.services.messages.Messages.WEBAUTHN_ERROR_API_GET; +import static org.keycloak.services.messages.Messages.WEBAUTHN_ERROR_API_INVALID_STATE; +import static org.keycloak.services.messages.Messages.WEBAUTHN_ERROR_API_NOT_ALLOWED; +import static org.keycloak.services.messages.Messages.WEBAUTHN_ERROR_API_SECURITY; import static org.keycloak.services.messages.Messages.WEBAUTHN_ERROR_AUTH_VERIFICATION; import static org.keycloak.services.messages.Messages.WEBAUTHN_ERROR_DIFFERENT_USER; import static org.keycloak.services.messages.Messages.WEBAUTHN_ERROR_REGISTRATION; +import static org.keycloak.services.messages.Messages.WEBAUTHN_ERROR_UNSUPPORTED_BROWSER; import static org.keycloak.services.messages.Messages.WEBAUTHN_ERROR_USER_NOT_FOUND; /** @@ -155,7 +160,8 @@ public void action(AuthenticationFlowContext context) { // receive error from navigator.credentials.get() String errorMsgFromWebAuthnApi = params.getFirst(WebAuthnConstants.ERROR); if (StringUtil.isNotBlank(errorMsgFromWebAuthnApi)) { - setErrorResponse(context, WEBAUTHN_ERROR_API_GET, errorMsgFromWebAuthnApi); + String mappedKey = mapBrowserApiErrorToMessageKey(errorMsgFromWebAuthnApi, false); + setErrorResponse(context, mappedKey, errorMsgFromWebAuthnApi); return; } @@ -253,6 +259,7 @@ public void action(AuthenticationFlowContext context) { try { result = user.credentialManager().isValid(cred); } catch (WebAuthnException wae) { + logger.warnv("WebAuthn authentication verification failed. {0}", wae.getMessage()); setErrorResponse(context, WEBAUTHN_ERROR_AUTH_VERIFICATION, wae.getMessage()); return; } @@ -315,7 +322,6 @@ protected void setErrorResponse(AuthenticationFlowContext context, final String Response errorResponse = null; switch (errorCase) { case WEBAUTHN_ERROR_REGISTRATION: - logger.warn(errorCase); context.getEvent() .detail(AUTH_ERR_LABEL, errorCase) .error(Errors.INVALID_USER_CREDENTIALS); @@ -323,7 +329,11 @@ protected void setErrorResponse(AuthenticationFlowContext context, final String context.failure(AuthenticationFlowError.INVALID_CREDENTIALS, errorResponse); break; case WEBAUTHN_ERROR_API_GET: - logger.warnv("error returned from navigator.credentials.get(). {0}", errorMessage); + case WEBAUTHN_ERROR_API_NOT_ALLOWED: + case WEBAUTHN_ERROR_API_INVALID_STATE: + case WEBAUTHN_ERROR_API_SECURITY: + case WEBAUTHN_ERROR_UNSUPPORTED_BROWSER: + logger.warnv("Error returned from navigator.credentials.get(). {0}", errorMessage); context.getEvent() .detail(AUTH_ERR_LABEL, errorCase) .detail(AUTH_ERR_DETAIL_LABEL, errorMessage) @@ -340,7 +350,6 @@ protected void setErrorResponse(AuthenticationFlowContext context, final String context.failure(AuthenticationFlowError.USER_CONFLICT, errorResponse); break; case WEBAUTHN_ERROR_AUTH_VERIFICATION: - logger.warnv("WebAuthn API .get() response validation failure. {0}", errorMessage); context.getEvent() .detail(AUTH_ERR_LABEL, errorCase) .detail(AUTH_ERR_DETAIL_LABEL, errorMessage) diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/WebAuthnRegister.java b/services/src/main/java/org/keycloak/authentication/requiredactions/WebAuthnRegister.java index 2ea2499b114b..6fee51c1d668 100644 --- a/services/src/main/java/org/keycloak/authentication/requiredactions/WebAuthnRegister.java +++ b/services/src/main/java/org/keycloak/authentication/requiredactions/WebAuthnRegister.java @@ -91,8 +91,16 @@ 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_API_GET; +import static org.keycloak.services.messages.Messages.WEBAUTHN_ERROR_API_INVALID_STATE; +import static org.keycloak.services.messages.Messages.WEBAUTHN_ERROR_API_NOT_ALLOWED; +import static org.keycloak.services.messages.Messages.WEBAUTHN_ERROR_API_SECURITY; 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_REGISTRATION_AAGUID_ATTESTATION_REQUIRED; +import static org.keycloak.services.messages.Messages.WEBAUTHN_ERROR_REGISTRATION_ATTACHMENT_MISMATCH; +import static org.keycloak.services.messages.Messages.WEBAUTHN_ERROR_REGISTRATION_NOT_ALLOWED_AAGUID; +import static org.keycloak.services.messages.Messages.WEBAUTHN_ERROR_UNSUPPORTED_BROWSER; import static org.keycloak.services.messages.Messages.WEBAUTHN_REGISTER_TITLE; /** @@ -234,7 +242,9 @@ public void processAction(RequiredActionContext context) { // receive error from navigator.credentials.create() String errorMsgFromWebAuthnApi = params.getFirst(WebAuthnConstants.ERROR); if (errorMsgFromWebAuthnApi != null && !errorMsgFromWebAuthnApi.isEmpty()) { - setErrorResponse(context, WEBAUTHN_ERROR_REGISTER_VERIFICATION, errorMsgFromWebAuthnApi, originalEventType); + String mappedKey = mapBrowserApiErrorToMessageKey(errorMsgFromWebAuthnApi, true); + logger.warnv("Error returned from navigator.credentials.create(). {0}", errorMsgFromWebAuthnApi); + setErrorResponse(context, mappedKey, errorMsgFromWebAuthnApi, originalEventType); return; } @@ -317,12 +327,16 @@ public void processAction(RequiredActionContext context) { .detail(WebAuthnConstants.PUBKEY_CRED_AAGUID_ATTR, aaguid); context.getEvent().clone().event(originalEventType).success(); context.success(); + } catch (WebAuthnPolicyException wpe) { + logger.warnv("WebAuthn policy violation during registration. {0}", wpe.getMessage()); + setErrorResponse(context, wpe.getMessageKey(), wpe.getMessage(), originalEventType); + return; } catch (WebAuthnException wae) { - if (logger.isDebugEnabled()) logger.debug(wae.getMessage(), wae); + logger.warnv("WebAuthn registration failed. {0}", wae.getMessage()); setErrorResponse(context, WEBAUTHN_ERROR_REGISTRATION, wae.getMessage(), originalEventType); return; } catch (Exception e) { - if (logger.isDebugEnabled()) logger.debug(e.getMessage(), e); + logger.warn("WebAuthn registration failed with unexpected error.", e); setErrorResponse(context, WEBAUTHN_ERROR_REGISTRATION, e.getMessage(), originalEventType); return; } @@ -463,13 +477,15 @@ private void setErrorResponse(RequiredActionContext context, final String errorC registerVerificationEvent.error(Errors.INVALID_USER_CREDENTIALS); deprecatedRegisterVerificationEvent.error(Errors.INVALID_USER_CREDENTIALS); errorResponse = context.form() - .setError(errorCase, errorMessage) + .setError(errorCase, "") .setAttribute(WEB_AUTHN_TITLE_ATTR, WEBAUTHN_REGISTER_TITLE) .createWebAuthnErrorPage(); context.challenge(errorResponse); break; case WEBAUTHN_ERROR_REGISTRATION: - logger.warn(errorCase); + case WEBAUTHN_ERROR_REGISTRATION_NOT_ALLOWED_AAGUID: + case WEBAUTHN_ERROR_REGISTRATION_AAGUID_ATTESTATION_REQUIRED: + case WEBAUTHN_ERROR_REGISTRATION_ATTACHMENT_MISMATCH: EventBuilder registrationEvent = context.getEvent() .detail(REG_ERR_LABEL, errorCase) .detail(REG_ERR_DETAIL_LABEL, errorMessage); @@ -477,14 +493,51 @@ private void setErrorResponse(RequiredActionContext context, final String errorC deprecatedRegistrationEvent.error(Errors.INVALID_REGISTRATION); registrationEvent.error(Errors.INVALID_REGISTRATION); errorResponse = context.form() - .setError(errorCase, errorMessage) + .setError(errorCase, "") .setAttribute(WEB_AUTHN_TITLE_ATTR, WEBAUTHN_REGISTER_TITLE) .createWebAuthnErrorPage(); context.challenge(errorResponse); break; default: - // NOP + // browser API error keys (not-allowed, timeout, etc.) — same event as a general registration failure + EventBuilder apiErrorEvent = context.getEvent() + .detail(REG_ERR_LABEL, errorCase) + .detail(REG_ERR_DETAIL_LABEL, errorMessage); + EventBuilder deprecatedApiErrorEvent = apiErrorEvent.clone().event(originalEventType); + deprecatedApiErrorEvent.error(Errors.INVALID_USER_CREDENTIALS); + apiErrorEvent.error(Errors.INVALID_USER_CREDENTIALS); + errorResponse = context.form() + .setError(errorCase, "") + .setAttribute(WEB_AUTHN_TITLE_ATTR, WEBAUTHN_REGISTER_TITLE) + .createWebAuthnErrorPage(); + context.challenge(errorResponse); + break; + } + } + + /** + * Maps a browser WebAuthn API error name (a {@code DOMException.name}) to a localizable message key. + * + * @param browserErrorName the raw error name from the browser (e.g. "NotAllowedError") + */ + public static String mapBrowserApiErrorToMessageKey(String browserErrorName, boolean isRegistration) { + if (StringUtil.isBlank(browserErrorName)) { + return isRegistration ? WEBAUTHN_ERROR_REGISTRATION : WEBAUTHN_ERROR_API_GET; + } + if(browserErrorName.contains("WebAuthnUnsupportedBrowser")) { + return WEBAUTHN_ERROR_UNSUPPORTED_BROWSER; + } + // DOMException names per https://webidl.spec.whatwg.org/#idl-DOMException-error-names + if (browserErrorName.contains("NotAllowedError") || browserErrorName.contains("TimeoutError")) { + return WEBAUTHN_ERROR_API_NOT_ALLOWED; + } + if (browserErrorName.contains("InvalidStateError")) { + return WEBAUTHN_ERROR_API_INVALID_STATE; } + if (browserErrorName.contains("SecurityError")) { + return WEBAUTHN_ERROR_API_SECURITY; + } + return isRegistration ? WEBAUTHN_ERROR_REGISTRATION: WEBAUTHN_ERROR_API_GET; } private boolean isFormDataRequest(HttpRequest request) { @@ -510,10 +563,12 @@ private static void verifyAcceptableAaguids(RegistrationData registrationData, W if (CollectionUtil.isNotEmpty(acceptableAaguids)) { // AAGUID comes from the authenticator data itself; only real attestation cryptographically proves the authenticator model if (NoneAttestationStatement.FORMAT.equals(registrationData.getAttestationObject().getFormat())) { - throw new WebAuthnException("Acceptable AAGUIDs require an attestation format other than 'none'."); + throw new WebAuthnPolicyException(WEBAUTHN_ERROR_REGISTRATION_AAGUID_ATTESTATION_REQUIRED, + "Acceptable AAGUIDs require an attestation format other than 'none'."); } else if (acceptableAaguids.stream().noneMatch(aaguid::equals)) { logger.debugf("Rejected authenticator with AAGUID '%s'. Acceptable AAGUIDs: %s", aaguid, acceptableAaguids); - throw new WebAuthnException("Not acceptable authenticator model (based on the AAGUID)."); + throw new WebAuthnPolicyException(WEBAUTHN_ERROR_REGISTRATION_NOT_ALLOWED_AAGUID, + "Not acceptable authenticator model (based on the AAGUID): " + aaguid); } } } @@ -530,12 +585,32 @@ private static void verifyAuthenticatorAttachment(String authenticatorAttachment } if (!WebAuthnConstants.SUPPORTED_AUTHENTICATOR_ATTACHMENTS.contains(authenticatorAttachment)) { - throw new WebAuthnException("Unexpected authenticator attachment value. Possible values are: " + String.join(", ", WebAuthnConstants.SUPPORTED_AUTHENTICATOR_ATTACHMENTS)); + throw new WebAuthnPolicyException(WEBAUTHN_ERROR_REGISTRATION_ATTACHMENT_MISMATCH, + "Unexpected authenticator attachment value. Possible values are: " + String.join(", ", WebAuthnConstants.SUPPORTED_AUTHENTICATOR_ATTACHMENTS)); } if (!requiredAttachment.equals(authenticatorAttachment)) { - throw new WebAuthnException("Policy requires '" + requiredAttachment + "' authenticator attachment but got '" + authenticatorAttachment + "'"); + throw new WebAuthnPolicyException(WEBAUTHN_ERROR_REGISTRATION_ATTACHMENT_MISMATCH, + "Policy requires '" + requiredAttachment + "' authenticator attachment but got '" + authenticatorAttachment + "'"); } } } + + /** + * Carries a localizable message key, allowing the + * error to be displayed using a specific, translatable string rather than the raw exception text. + */ + static class WebAuthnPolicyException extends WebAuthnException { + + private final String messageKey; + + WebAuthnPolicyException(String messageKey, String technicalDetail) { + super(technicalDetail); + this.messageKey = messageKey; + } + + String getMessageKey() { + return messageKey; + } + } } diff --git a/services/src/main/java/org/keycloak/services/messages/Messages.java b/services/src/main/java/org/keycloak/services/messages/Messages.java index d1813b60c4b6..ba439406dd60 100755 --- a/services/src/main/java/org/keycloak/services/messages/Messages.java +++ b/services/src/main/java/org/keycloak/services/messages/Messages.java @@ -336,6 +336,15 @@ public class Messages { 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"; + // WebAuthn Error — specific localizable cases + public static final String WEBAUTHN_ERROR_REGISTRATION_NOT_ALLOWED_AAGUID = "webauthn-error-registration-not-allowed-aaguid"; + public static final String WEBAUTHN_ERROR_REGISTRATION_AAGUID_ATTESTATION_REQUIRED = "webauthn-error-registration-aaguid-attestation-required"; + public static final String WEBAUTHN_ERROR_REGISTRATION_ATTACHMENT_MISMATCH = "webauthn-error-registration-attachment-mismatch"; + public static final String WEBAUTHN_ERROR_API_NOT_ALLOWED = "webauthn-error-api-not-allowed"; + public static final String WEBAUTHN_ERROR_API_INVALID_STATE = "webauthn-error-api-invalid-state"; + public static final String WEBAUTHN_ERROR_API_SECURITY = "webauthn-error-api-security"; + public static final String WEBAUTHN_ERROR_UNSUPPORTED_BROWSER = "webauthn-unsupported-browser-text"; + // Conditions in Conditional Flow public static final String ACCESS_DENIED = "access-denied"; diff --git a/tests/webauthn/src/test/java/org/keycloak/tests/webauthn/WebAuthnPolicyComplianceTest.java b/tests/webauthn/src/test/java/org/keycloak/tests/webauthn/WebAuthnPolicyComplianceTest.java index 41914604409a..d3c47b699a4c 100644 --- a/tests/webauthn/src/test/java/org/keycloak/tests/webauthn/WebAuthnPolicyComplianceTest.java +++ b/tests/webauthn/src/test/java/org/keycloak/tests/webauthn/WebAuthnPolicyComplianceTest.java @@ -33,7 +33,7 @@ public void tamperedAuthenticatorAttachment() { registerAndExpectError("attach-tamper", tamperFormField("authenticatorAttachment", "platform"), - "Policy requires 'cross-platform' authenticator attachment but got 'platform'"); + "Your organization requires a different type of security key. Please use the correct type."); } @Test @@ -43,7 +43,7 @@ public void invalidAuthenticatorAttachmentValue() { registerAndExpectError("attach-invalid", tamperFormField("authenticatorAttachment", "not-a-real-value"), - "Unexpected authenticator attachment value"); + "Your organization requires a different type of security key. Please use the correct type."); } @Test @@ -53,7 +53,7 @@ public void omittedAuthenticatorAttachment() { registerAndExpectError("attach-omit", tamperFormField("authenticatorAttachment", ""), - "Authenticator attachment is required by the policy but was not provided by the client."); + "Failed to register your Passkey."); } @Test @@ -64,7 +64,7 @@ public void tamperedSignatureAlgorithm() { registerAndExpectError("alg-tamper", tamperCreateOptions("opts.publicKey.pubKeyCredParams = [{type: 'public-key', alg: -7}];"), - "alg not listed in options.pubKeyCredParams is used."); + "Failed to register your Passkey."); } @Test @@ -77,7 +77,7 @@ 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."); + "Failed to register your Passkey."); } @Test @@ -88,7 +88,7 @@ public void tamperedAttestationConveyance() { registerAndExpectError("att-tamper", tamperCreateOptions("opts.publicKey.attestation = 'none';"), - "AttestationVerifier is not configured to handle the supplied AttestationStatement format 'none'."); + "Failed to register your Passkey."); } @Test @@ -127,7 +127,7 @@ public void replayedCredentialRegistration() { webAuthnErrorPage.assertCurrent(); assertThat(webAuthnErrorPage.getError(), - containsString("The actual challenge does not match the expected challenge")); + containsString("Failed to register your Passkey.")); } @Test @@ -137,7 +137,7 @@ public void acceptableAaguidWithNoneAttestation() { .webAuthnPolicyAttestationConveyancePreference("none")); registerAndExpectError("aaguid-none-attestation", - "Acceptable AAGUIDs require an attestation format other than 'none'."); + "Your organization requires verified security keys. Attestation format 'none' is not accepted; please use a key that provides attestation."); } private void registerAndExpectError(String testId, String tamperScript, String expectedError) { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/WebAuthnIdlessTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/WebAuthnIdlessTest.java index 8a9e6675e197..1f662bc410c6 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/WebAuthnIdlessTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/WebAuthnIdlessTest.java @@ -142,7 +142,6 @@ public void testWebAuthnIDLessWithNonResidentCredentialLogin() throws IOExceptio setUpIDLessOnlyFlow("idless-only-flow"); idlessAuthentication(username, credentialId, false, false); - } // Authenticate IDLess with no webauthn-passwordless credential registered: should fail @@ -393,7 +392,7 @@ protected void idlessAuthentication(String username, String credentialId, boolea } else { webAuthnErrorPage.assertCurrent(); - assertThat(loginPage.getError(), containsString("Failed to authenticate by the Passkey.")); + assertThat(loginPage.getError(), containsString("The Passkey operation was not allowed or timed out.")); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/WebAuthnPropertyTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/WebAuthnPropertyTest.java index e457704b6608..7f7e67f36780 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/WebAuthnPropertyTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/WebAuthnPropertyTest.java @@ -103,7 +103,7 @@ public void timeout() throws IOException { authenticateDefaultUser(false); WaitUtils.pause((TIMEOUT + 2) * 1000); webAuthnErrorPage.assertCurrent(); - assertThat(webAuthnErrorPage.getError(), containsString("Failed to authenticate by the Passkey.")); + assertThat(webAuthnErrorPage.getError(), containsString("The Passkey operation was not allowed or timed out.")); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/account/WebAuthnErrorTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/account/WebAuthnErrorTest.java index 17d460f74e77..924b7cb3e53a 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/account/WebAuthnErrorTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/account/WebAuthnErrorTest.java @@ -84,7 +84,7 @@ public void errorPageWithTimeout() throws IOException { WaitUtils.pause((timeoutSec + 1) * 1000); webAuthnErrorPage.assertCurrent(); - assertThat(webAuthnErrorPage.getError(), is("Failed to authenticate by the Passkey.")); + assertThat(webAuthnErrorPage.getError(), is("The Passkey operation was not allowed or timed out. You may have cancelled the request, taken too long to respond, or the operation may not be permitted in this context. Please try again.")); assertThat("execution value should be a double-quoted JS string via ?c", driver.getPageSource(), containsString("getElementById('executionValue').value = \"")); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/account/WebAuthnSigningInTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/account/WebAuthnSigningInTest.java index f9d8507c106f..24b95ab175c2 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/account/WebAuthnSigningInTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/account/WebAuthnSigningInTest.java @@ -122,7 +122,7 @@ public void createWebAuthnSameUserLabel() { waitForPageToLoad(); webAuthnErrorPage.assertCurrent(); - assertThat(webAuthnErrorPage.getError(), is("Failed to register your Passkey. Device already exists with the same name")); + assertThat(webAuthnErrorPage.getError(), is("Failed to register your Passkey.")); webAuthnErrorPage.clickTryAgain(); webAuthnRegisterPage.assertCurrent(); @@ -431,7 +431,7 @@ private void avoidSameAuthenticatorRegister(AbstractWebAuthnRealmUpdater updater waitForPageToLoad(); webAuthnErrorPage.assertCurrent(); assertThat(webAuthnErrorPage.getError(), containsString( - "The user attempted to register an authenticator that contains one of the credentials already registered with the relying party.")); + "This Passkey is already registered.")); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/registration/AttestationConveyanceRegisterTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/registration/AttestationConveyanceRegisterTest.java index 4ddbb20109c7..2b9ac0ecdbd2 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/registration/AttestationConveyanceRegisterTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/registration/AttestationConveyanceRegisterTest.java @@ -125,7 +125,7 @@ public void attestationConveyancePreferenceNoneToDirect() throws IOException { // should fail because none is not allowed webAuthnErrorPage.assertCurrent(); - assertThat(webAuthnErrorPage.getError(), containsString("AttestationVerifier is not configured to handle the supplied AttestationStatement format 'none'.")); + assertThat(webAuthnErrorPage.getError(), containsString("Failed to register your Passkey.")); } finally { testingClient.testing().reenableTruststoreSpi(); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/registration/AuthAttachmentRegisterTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/registration/AuthAttachmentRegisterTest.java index b4881f423ae8..90f4869b0a0c 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/registration/AuthAttachmentRegisterTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/registration/AuthAttachmentRegisterTest.java @@ -85,7 +85,7 @@ public void authenticatorAttachmentPlatform() throws IOException { // it timeouts after create timeout webAuthnErrorPage.assertCurrent(); - assertThat(webAuthnErrorPage.getError(), containsString("The operation either timed out or was not allowed.")); + assertThat(webAuthnErrorPage.getError(), containsString("The Passkey operation was not allowed or timed out.")); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/registration/PolicyJsInjectionTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/registration/PolicyJsInjectionTest.java index 5f5fc93c5e91..91d02ede5e79 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/registration/PolicyJsInjectionTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/registration/PolicyJsInjectionTest.java @@ -75,9 +75,7 @@ public void relyingPartyId() throws IOException { webAuthnErrorPage.assertCurrent(); - final String expectedMessage = getExpectedMessageByDriver( - "SecurityError: The operation is insecure.", - "The relying party ID is not a registrable domain suffix of, nor equal to the current domain."); + final String expectedMessage = "A security error occurred during the Passkey operation. Please ensure you are on the correct site and try again."; assertThat(webAuthnErrorPage.getError(), containsString(expectedMessage)); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/registration/PubKeySignRegisterTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/registration/PubKeySignRegisterTest.java index 0aa36c8c2142..32ce52c3c5e9 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/registration/PubKeySignRegisterTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/registration/PubKeySignRegisterTest.java @@ -77,7 +77,7 @@ public void publicKeySignaturesEmpty() { @Test public void publicKeySignaturesNonExisting() { assertPublicKeyAlgorithms(false, COSEAlgorithmIdentifier.ES256, Collections.singletonList("RSSSS2048"), - "alg not listed in options.pubKeyCredParams is used"); + "Failed to register your Passkey."); } private void assertPublicKeyAlgorithms(boolean shouldSuccess, COSEAlgorithmIdentifier selectedAlgorithm, List algorithms) { @@ -106,9 +106,7 @@ private void assertPublicKeyAlgorithms(boolean shouldSuccess, COSEAlgorithmIdent if (!shouldSuccess) { final String expectedMessage = StringUtil.isNotBlank(expectedError) ? expectedError - : getExpectedMessageByDriver( - "NotSupportedError: Operation is not supported", - "The operation either timed out or was not allowed"); + : "The Passkey operation was not allowed or timed out."; assertThat(webAuthnErrorPage.getError(), containsString(expectedMessage)); return; } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/registration/WebAuthnOtherSettingsTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/registration/WebAuthnOtherSettingsTest.java index 00db14ef26f8..91fb33ad8777 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/registration/WebAuthnOtherSettingsTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/registration/WebAuthnOtherSettingsTest.java @@ -22,6 +22,7 @@ import java.util.Collections; import java.util.List; import java.util.Optional; +import java.util.function.Consumer; import org.keycloak.WebAuthnConstants; import org.keycloak.authentication.requiredactions.WebAuthnPasswordlessRegisterFactory; @@ -45,6 +46,7 @@ import org.junit.Test; import org.junit.jupiter.api.Assertions; import org.openqa.selenium.firefox.FirefoxDriver; +import org.openqa.selenium.virtualauthenticator.VirtualAuthenticatorOptions; import static org.keycloak.testsuite.util.BrowserDriverUtil.isDriverFirefox; import static org.keycloak.testsuite.util.WaitUtils.pause; @@ -143,7 +145,7 @@ public void timeout() throws IOException { pause((TIMEOUT + 2) * 1000); webAuthnErrorPage.assertCurrent(); - assertThat(webAuthnErrorPage.getError(), containsString("The operation either timed out or was not allowed")); + assertThat(webAuthnErrorPage.getError(), containsString("The Passkey operation was not allowed or timed out.")); webAuthnErrorPage.clickTryAgain(); waitForPageToLoad(); @@ -179,7 +181,7 @@ public void excludeCredentials() throws IOException { registerDefaultUser(); webAuthnErrorPage.assertCurrent(); - assertThat(webAuthnErrorPage.getError(), containsString("Not acceptable authenticator model")); + assertThat(webAuthnErrorPage.getError(), containsString("This security key model is not allowed. Please use a different security key.")); } finally { testingClient.testing().reenableTruststoreSpi(); } @@ -223,7 +225,87 @@ public void excludeCredentialsUsingNone() throws IOException { registerDefaultUser(); webAuthnErrorPage.assertCurrent(); - assertThat(webAuthnErrorPage.getError(), containsString("Acceptable AAGUIDs require an attestation format other than 'none'.")); + assertThat(webAuthnErrorPage.getError(), containsString("Your organization requires verified security keys. Attestation format 'none' is not accepted; please use a key that provides attestation.")); } } + + @Test + @IgnoreBrowserDriver(FirefoxDriver.class) // See https://github.com/keycloak/keycloak/issues/10368 + public void apiNotAllowedErrorMessage() throws IOException { + final Integer TIMEOUT = 3; //seconds + try (Closeable u = getWebAuthnRealmUpdater().setWebAuthnPolicyCreateTimeout(TIMEOUT).update()) { + assertBrowserApiErrorMessage(options -> options.setIsUserConsenting(false), + "The Passkey operation was not allowed or timed out.", TIMEOUT); + } + } + + @Test + @IgnoreBrowserDriver(FirefoxDriver.class) // See https://github.com/keycloak/keycloak/issues/10368 + public void apiInvalidStateErrorMessage() throws IOException { + registerDefaultUser(); + UserRepresentation user = userResource().toRepresentation(); + logout(); + + user.setRequiredActions(Collections.singletonList(isPasswordless() + ? WebAuthnPasswordlessRegisterFactory.PROVIDER_ID + : WebAuthnRegisterFactory.PROVIDER_ID)); + userResource().update(user); + + oauth.openLoginForm(); + waitForPageToLoad(); + loginPage.assertCurrent(); + loginPage.login(USERNAME, getPassword(USERNAME)); + + waitForPageToLoad(); + webAuthnLoginPage.assertCurrent(); + webAuthnLoginPage.clickAuthenticate(); + + waitForPageToLoad(); + webAuthnRegisterPage.assertCurrent(); + webAuthnRegisterPage.clickRegister(); + + webAuthnErrorPage.assertCurrent(); + assertThat(webAuthnErrorPage.getError(), containsString("This Passkey is already registered.")); + } + + @Test + @IgnoreBrowserDriver(FirefoxDriver.class) // See https://github.com/keycloak/keycloak/issues/10368 + public void apiSecurityErrorMessage() throws IOException { + try (Closeable u = getWebAuthnRealmUpdater() + .setWebAuthnPolicyRpId("invalid.example.com") + .update()) { + oauth.openLoginForm(); + loginPage.clickRegister(); + registerPage.assertCurrent(); + registerPage.register("firstName", "lastName", EMAIL, USERNAME, generatePassword(USERNAME)); + + webAuthnRegisterPage.assertCurrent(); + webAuthnRegisterPage.clickRegister(); + + webAuthnErrorPage.assertCurrent(); + assertThat(webAuthnErrorPage.getError(), containsString("A security error occurred during the Passkey operation. Please ensure you are on the correct site and try again.")); + } + } + + private void assertBrowserApiErrorMessage(Consumer optionsConsumer, String expectedMessage, Integer waitSeconds) throws IOException { + getVirtualAuthManager().removeAuthenticator(); + VirtualAuthenticatorOptions options = getDefaultAuthenticatorOptions(); + optionsConsumer.accept(options); + getVirtualAuthManager().useAuthenticator(options); + + oauth.openLoginForm(); + loginPage.clickRegister(); + registerPage.assertCurrent(); + registerPage.register("firstName", "lastName", EMAIL, USERNAME, generatePassword(USERNAME)); + + webAuthnRegisterPage.assertCurrent(); + webAuthnRegisterPage.clickRegister(); + + if (waitSeconds != null) { + pause((waitSeconds + 2) * 1000); + } + + webAuthnErrorPage.assertCurrent(); + assertThat(webAuthnErrorPage.getError(), containsString(expectedMessage)); + } } diff --git a/themes/src/main/resources/theme/base/login/messages/messages_en.properties b/themes/src/main/resources/theme/base/login/messages/messages_en.properties index d5aa0ac319e5..849ed7e14680 100644 --- a/themes/src/main/resources/theme/base/login/messages/messages_en.properties +++ b/themes/src/main/resources/theme/base/login/messages/messages_en.properties @@ -504,13 +504,20 @@ webauthn-registration-init-label-prompt=Please input your registered passkey''s # WebAuthn Error webauthn-error-title=Passkey Error -webauthn-error-registration=Failed to register your Passkey. {0} -webauthn-error-api-get=Failed to authenticate by the Passkey. {0} +webauthn-error-registration=Failed to register your Passkey. +webauthn-error-api-get=Failed to authenticate by the Passkey. webauthn-error-different-user=First authenticated user is not the one authenticated by the Passkey. -webauthn-error-auth-verification=Passkey authentication result is invalid. {0} -webauthn-error-register-verification=Passkey registration result is invalid. {0} +webauthn-error-auth-verification=Passkey authentication result is invalid. +webauthn-error-register-verification=Passkey registration result is invalid. webauthn-error-user-not-found=Unknown user authenticated by the Passkey. +webauthn-error-registration-not-allowed-aaguid=This security key model is not allowed. Please use a different security key. +webauthn-error-registration-aaguid-attestation-required=Your organization requires verified security keys. Attestation format ''none'' is not accepted; please use a key that provides attestation. +webauthn-error-registration-attachment-mismatch=Your organization requires a different type of security key. Please use the correct type. +webauthn-error-api-not-allowed=The Passkey operation was not allowed or timed out. You may have cancelled the request, taken too long to respond, or the operation may not be permitted in this context. Please try again. +webauthn-error-api-invalid-state=This Passkey is already registered. +webauthn-error-api-security=A security error occurred during the Passkey operation. Please ensure you are on the correct site and try again. + # Passkey passkey-login-title=Passkey login passkey-available-authenticators=Available Passkeys diff --git a/themes/src/main/resources/theme/base/login/resources/js/webauthnAuthenticate.js b/themes/src/main/resources/theme/base/login/resources/js/webauthnAuthenticate.js index ca9892428882..781b5ba8ca03 100644 --- a/themes/src/main/resources/theme/base/login/resources/js/webauthnAuthenticate.js +++ b/themes/src/main/resources/theme/base/login/resources/js/webauthnAuthenticate.js @@ -60,7 +60,7 @@ export function getAllowCredentials() { export function doAuthenticate(input) { // Check if WebAuthn is supported by this browser if (!window.PublicKeyCredential) { - returnFailure(input.errmsg); + returnFailure("WebAuthnUnsupportedBrowser"); return; } diff --git a/themes/src/main/resources/theme/base/login/resources/js/webauthnRegister.js b/themes/src/main/resources/theme/base/login/resources/js/webauthnRegister.js index facf05b945b5..36a6947c61d0 100644 --- a/themes/src/main/resources/theme/base/login/resources/js/webauthnRegister.js +++ b/themes/src/main/resources/theme/base/login/resources/js/webauthnRegister.js @@ -4,7 +4,7 @@ export async function registerByWebAuthn(input) { // Check if WebAuthn is supported by this browser if (!window.PublicKeyCredential) { - returnFailure(input.errmsg); + returnFailure("WebAuthnUnsupportedBrowser"); return; } From 77f3beec6a5acc953424202f0f3fcff5200fb27d Mon Sep 17 00:00:00 2001 From: uzmamansoor09 Date: Thu, 23 Jul 2026 14:56:17 +0100 Subject: [PATCH 2/2] Following review Signed-off-by: uzmamansoor09 --- .../browser/WebAuthnAuthenticator.java | 17 ++++----- .../WebAuthnConditionalUIAuthenticator.java | 4 +- .../requiredactions/WebAuthnRegister.java | 38 +++++++++++-------- .../keycloak/services/messages/Messages.java | 1 + .../WebAuthnPolicyComplianceTest.java | 4 +- .../account/WebAuthnSigningInTest.java | 2 +- .../WebAuthnOtherSettingsTest.java | 2 +- .../login/messages/messages_en.properties | 5 ++- 8 files changed, 40 insertions(+), 33 deletions(-) diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnAuthenticator.java index 55c7303bc983..9363b47fcf16 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnAuthenticator.java @@ -259,7 +259,7 @@ public void action(AuthenticationFlowContext context) { try { result = user.credentialManager().isValid(cred); } catch (WebAuthnException wae) { - logger.warnv("WebAuthn authentication verification failed. {0}", wae.getMessage()); + logger.debug("WebAuthn authentication verification failed.", wae); setErrorResponse(context, WEBAUTHN_ERROR_AUTH_VERIFICATION, wae.getMessage()); return; } @@ -318,14 +318,14 @@ public WebAuthnCredentialProvider getCredentialProvider(KeycloakSession session) return (WebAuthnCredentialProvider)session.getProvider(CredentialProvider.class, WebAuthnCredentialProviderFactory.PROVIDER_ID); } - protected void setErrorResponse(AuthenticationFlowContext context, final String errorCase, final String errorMessage) { + protected void setErrorResponse(AuthenticationFlowContext context, final String errorCase, final String errorMessage, Object... parameters) { Response errorResponse = null; switch (errorCase) { case WEBAUTHN_ERROR_REGISTRATION: context.getEvent() .detail(AUTH_ERR_LABEL, errorCase) .error(Errors.INVALID_USER_CREDENTIALS); - errorResponse = createErrorResponse(context, errorCase); + errorResponse = createErrorResponse(context, errorCase, parameters); context.failure(AuthenticationFlowError.INVALID_CREDENTIALS, errorResponse); break; case WEBAUTHN_ERROR_API_GET: @@ -333,12 +333,11 @@ protected void setErrorResponse(AuthenticationFlowContext context, final String case WEBAUTHN_ERROR_API_INVALID_STATE: case WEBAUTHN_ERROR_API_SECURITY: case WEBAUTHN_ERROR_UNSUPPORTED_BROWSER: - logger.warnv("Error returned from navigator.credentials.get(). {0}", errorMessage); context.getEvent() .detail(AUTH_ERR_LABEL, errorCase) .detail(AUTH_ERR_DETAIL_LABEL, errorMessage) .error(Errors.NOT_ALLOWED); - errorResponse = createErrorResponse(context, errorCase); + errorResponse = createErrorResponse(context, errorCase, parameters); context.failure(AuthenticationFlowError.INVALID_USER, errorResponse); break; case WEBAUTHN_ERROR_DIFFERENT_USER: @@ -354,7 +353,7 @@ protected void setErrorResponse(AuthenticationFlowContext context, final String .detail(AUTH_ERR_LABEL, errorCase) .detail(AUTH_ERR_DETAIL_LABEL, errorMessage) .error(Errors.INVALID_USER_CREDENTIALS); - errorResponse = createErrorResponse(context, errorCase); + errorResponse = createErrorResponse(context, errorCase, parameters); context.failure(AuthenticationFlowError.INVALID_USER, errorResponse); break; case WEBAUTHN_ERROR_USER_NOT_FOUND: @@ -362,7 +361,7 @@ protected void setErrorResponse(AuthenticationFlowContext context, final String context.getEvent() .detail(AUTH_ERR_LABEL, errorCase) .error(Errors.USER_NOT_FOUND); - errorResponse = createErrorResponse(context, errorCase); + errorResponse = createErrorResponse(context, errorCase, parameters); context.failure(AuthenticationFlowError.UNKNOWN_USER, errorResponse); break; default: @@ -370,8 +369,8 @@ protected void setErrorResponse(AuthenticationFlowContext context, final String } } - protected Response createErrorResponse(AuthenticationFlowContext context, final String errorCase) { - LoginFormsProvider provider = context.form().setError(errorCase, ""); + protected Response createErrorResponse(AuthenticationFlowContext context, final String errorCase, Object... parameters) { + LoginFormsProvider provider = context.form().setError(errorCase, parameters); UserModel user = context.getUser(); if (user != null) { WebAuthnMetadataService metadataService = getCredentialProvider(context.getSession()).getMetadataService(); diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnConditionalUIAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnConditionalUIAuthenticator.java index b58d49941cf3..29997094056c 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnConditionalUIAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnConditionalUIAuthenticator.java @@ -47,9 +47,9 @@ public LoginFormsProvider fillContextForm(AuthenticationFlowContext context) { } @Override - protected Response createErrorResponse(AuthenticationFlowContext context, final String errorCase) { + protected Response createErrorResponse(AuthenticationFlowContext context, final String errorCase, Object... parameters) { // the passkey failed, show error and maintain passkeys - context.form().setError(errorCase, ""); + context.form().setError(errorCase, parameters); context.form().setAttribute(WebAuthnConstants.ENABLE_WEBAUTHN_CONDITIONAL_UI, Boolean.TRUE); AuthenticatorUtils.setupReauthenticationInUsernamePasswordFormError(context); diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/WebAuthnRegister.java b/services/src/main/java/org/keycloak/authentication/requiredactions/WebAuthnRegister.java index 6fee51c1d668..6c6ae350f2cc 100644 --- a/services/src/main/java/org/keycloak/authentication/requiredactions/WebAuthnRegister.java +++ b/services/src/main/java/org/keycloak/authentication/requiredactions/WebAuthnRegister.java @@ -52,6 +52,7 @@ import org.keycloak.http.HttpRequest; import org.keycloak.models.Constants; import org.keycloak.models.KeycloakSession; +import org.keycloak.models.ModelDuplicateException; import org.keycloak.models.UserModel; import org.keycloak.models.WebAuthnPolicy; import org.keycloak.models.credential.WebAuthnCredentialModel; @@ -95,6 +96,7 @@ import static org.keycloak.services.messages.Messages.WEBAUTHN_ERROR_API_INVALID_STATE; import static org.keycloak.services.messages.Messages.WEBAUTHN_ERROR_API_NOT_ALLOWED; import static org.keycloak.services.messages.Messages.WEBAUTHN_ERROR_API_SECURITY; +import static org.keycloak.services.messages.Messages.WEBAUTHN_ERROR_DUPLICATED_DEVICE; 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_REGISTRATION_AAGUID_ATTESTATION_REQUIRED; @@ -243,7 +245,6 @@ public void processAction(RequiredActionContext context) { String errorMsgFromWebAuthnApi = params.getFirst(WebAuthnConstants.ERROR); if (errorMsgFromWebAuthnApi != null && !errorMsgFromWebAuthnApi.isEmpty()) { String mappedKey = mapBrowserApiErrorToMessageKey(errorMsgFromWebAuthnApi, true); - logger.warnv("Error returned from navigator.credentials.create(). {0}", errorMsgFromWebAuthnApi); setErrorResponse(context, mappedKey, errorMsgFromWebAuthnApi, originalEventType); return; } @@ -328,17 +329,16 @@ public void processAction(RequiredActionContext context) { context.getEvent().clone().event(originalEventType).success(); context.success(); } catch (WebAuthnPolicyException wpe) { - logger.warnv("WebAuthn policy violation during registration. {0}", wpe.getMessage()); - setErrorResponse(context, wpe.getMessageKey(), wpe.getMessage(), originalEventType); - return; + logger.debug("WebAuthn policy violation during registration.", wpe); + setErrorResponse(context, wpe.getMessageKey(), wpe.getMessage(), originalEventType, wpe.getParameters()); } catch (WebAuthnException wae) { - logger.warnv("WebAuthn registration failed. {0}", wae.getMessage()); + logger.debug("WebAuthn registration failed.", wae); setErrorResponse(context, WEBAUTHN_ERROR_REGISTRATION, wae.getMessage(), originalEventType); - return; + } catch (ModelDuplicateException e) { + setErrorResponse(context, WEBAUTHN_ERROR_DUPLICATED_DEVICE, e.getMessage(), originalEventType); } catch (Exception e) { - logger.warn("WebAuthn registration failed with unexpected error.", e); + logger.debug("WebAuthn registration failed with unexpected error.", e); setErrorResponse(context, WEBAUTHN_ERROR_REGISTRATION, e.getMessage(), originalEventType); - return; } } @@ -465,7 +465,7 @@ public void evaluateTriggers(RequiredActionContext context) { // NOP } - private void setErrorResponse(RequiredActionContext context, final String errorCase, final String errorMessage, @Deprecated final EventType originalEventType) { + private void setErrorResponse(RequiredActionContext context, final String errorCase, final String errorMessage, @Deprecated final EventType originalEventType, Object... parameters) { Response errorResponse = null; switch (errorCase) { case WEBAUTHN_ERROR_REGISTER_VERIFICATION: @@ -477,7 +477,7 @@ private void setErrorResponse(RequiredActionContext context, final String errorC registerVerificationEvent.error(Errors.INVALID_USER_CREDENTIALS); deprecatedRegisterVerificationEvent.error(Errors.INVALID_USER_CREDENTIALS); errorResponse = context.form() - .setError(errorCase, "") + .setError(errorCase, parameters) .setAttribute(WEB_AUTHN_TITLE_ATTR, WEBAUTHN_REGISTER_TITLE) .createWebAuthnErrorPage(); context.challenge(errorResponse); @@ -493,7 +493,7 @@ private void setErrorResponse(RequiredActionContext context, final String errorC deprecatedRegistrationEvent.error(Errors.INVALID_REGISTRATION); registrationEvent.error(Errors.INVALID_REGISTRATION); errorResponse = context.form() - .setError(errorCase, "") + .setError(errorCase, parameters) .setAttribute(WEB_AUTHN_TITLE_ATTR, WEBAUTHN_REGISTER_TITLE) .createWebAuthnErrorPage(); context.challenge(errorResponse); @@ -507,7 +507,7 @@ private void setErrorResponse(RequiredActionContext context, final String errorC deprecatedApiErrorEvent.error(Errors.INVALID_USER_CREDENTIALS); apiErrorEvent.error(Errors.INVALID_USER_CREDENTIALS); errorResponse = context.form() - .setError(errorCase, "") + .setError(errorCase, parameters) .setAttribute(WEB_AUTHN_TITLE_ATTR, WEBAUTHN_REGISTER_TITLE) .createWebAuthnErrorPage(); context.challenge(errorResponse); @@ -568,7 +568,7 @@ private static void verifyAcceptableAaguids(RegistrationData registrationData, W } else if (acceptableAaguids.stream().noneMatch(aaguid::equals)) { logger.debugf("Rejected authenticator with AAGUID '%s'. Acceptable AAGUIDs: %s", aaguid, acceptableAaguids); throw new WebAuthnPolicyException(WEBAUTHN_ERROR_REGISTRATION_NOT_ALLOWED_AAGUID, - "Not acceptable authenticator model (based on the AAGUID): " + aaguid); + "Not acceptable authenticator model (based on the AAGUID): " + aaguid, aaguid); } } } @@ -586,12 +586,12 @@ private static void verifyAuthenticatorAttachment(String authenticatorAttachment if (!WebAuthnConstants.SUPPORTED_AUTHENTICATOR_ATTACHMENTS.contains(authenticatorAttachment)) { throw new WebAuthnPolicyException(WEBAUTHN_ERROR_REGISTRATION_ATTACHMENT_MISMATCH, - "Unexpected authenticator attachment value. Possible values are: " + String.join(", ", WebAuthnConstants.SUPPORTED_AUTHENTICATOR_ATTACHMENTS)); + "Unexpected authenticator attachment value. Possible values are: " + String.join(", ", WebAuthnConstants.SUPPORTED_AUTHENTICATOR_ATTACHMENTS), authenticatorAttachment); } if (!requiredAttachment.equals(authenticatorAttachment)) { throw new WebAuthnPolicyException(WEBAUTHN_ERROR_REGISTRATION_ATTACHMENT_MISMATCH, - "Policy requires '" + requiredAttachment + "' authenticator attachment but got '" + authenticatorAttachment + "'"); + "Policy requires '" + requiredAttachment + "' authenticator attachment but got '" + authenticatorAttachment + "'", authenticatorAttachment); } } } @@ -603,14 +603,20 @@ private static void verifyAuthenticatorAttachment(String authenticatorAttachment static class WebAuthnPolicyException extends WebAuthnException { private final String messageKey; + private final Object[] parameters; - WebAuthnPolicyException(String messageKey, String technicalDetail) { + WebAuthnPolicyException(String messageKey, String technicalDetail, Object... parameters) { super(technicalDetail); this.messageKey = messageKey; + this.parameters = parameters; } String getMessageKey() { return messageKey; } + + Object[] getParameters() { + return parameters; + } } } diff --git a/services/src/main/java/org/keycloak/services/messages/Messages.java b/services/src/main/java/org/keycloak/services/messages/Messages.java index ba439406dd60..0e1432b1ab65 100755 --- a/services/src/main/java/org/keycloak/services/messages/Messages.java +++ b/services/src/main/java/org/keycloak/services/messages/Messages.java @@ -344,6 +344,7 @@ public class Messages { public static final String WEBAUTHN_ERROR_API_INVALID_STATE = "webauthn-error-api-invalid-state"; public static final String WEBAUTHN_ERROR_API_SECURITY = "webauthn-error-api-security"; public static final String WEBAUTHN_ERROR_UNSUPPORTED_BROWSER = "webauthn-unsupported-browser-text"; + public static final String WEBAUTHN_ERROR_DUPLICATED_DEVICE = "webauthn-error-duplicated-device"; // Conditions in Conditional Flow public static final String ACCESS_DENIED = "access-denied"; diff --git a/tests/webauthn/src/test/java/org/keycloak/tests/webauthn/WebAuthnPolicyComplianceTest.java b/tests/webauthn/src/test/java/org/keycloak/tests/webauthn/WebAuthnPolicyComplianceTest.java index d3c47b699a4c..4e0abc3b9702 100644 --- a/tests/webauthn/src/test/java/org/keycloak/tests/webauthn/WebAuthnPolicyComplianceTest.java +++ b/tests/webauthn/src/test/java/org/keycloak/tests/webauthn/WebAuthnPolicyComplianceTest.java @@ -33,7 +33,7 @@ public void tamperedAuthenticatorAttachment() { registerAndExpectError("attach-tamper", tamperFormField("authenticatorAttachment", "platform"), - "Your organization requires a different type of security key. Please use the correct type."); + "Your organization requires a different type of security key (invalid Authenticator Attachment 'platform'). Please use the correct type."); } @Test @@ -43,7 +43,7 @@ public void invalidAuthenticatorAttachmentValue() { registerAndExpectError("attach-invalid", tamperFormField("authenticatorAttachment", "not-a-real-value"), - "Your organization requires a different type of security key. Please use the correct type."); + "Your organization requires a different type of security key (invalid Authenticator Attachment 'not-a-real-value'). Please use the correct type."); } @Test diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/account/WebAuthnSigningInTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/account/WebAuthnSigningInTest.java index 24b95ab175c2..850ec91dbaa6 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/account/WebAuthnSigningInTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/account/WebAuthnSigningInTest.java @@ -122,7 +122,7 @@ public void createWebAuthnSameUserLabel() { waitForPageToLoad(); webAuthnErrorPage.assertCurrent(); - assertThat(webAuthnErrorPage.getError(), is("Failed to register your Passkey.")); + assertThat(webAuthnErrorPage.getError(), is("Device already exists with the same name.")); webAuthnErrorPage.clickTryAgain(); webAuthnRegisterPage.assertCurrent(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/registration/WebAuthnOtherSettingsTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/registration/WebAuthnOtherSettingsTest.java index 91fb33ad8777..a0f9ca714877 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/registration/WebAuthnOtherSettingsTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/registration/WebAuthnOtherSettingsTest.java @@ -181,7 +181,7 @@ public void excludeCredentials() throws IOException { registerDefaultUser(); webAuthnErrorPage.assertCurrent(); - assertThat(webAuthnErrorPage.getError(), containsString("This security key model is not allowed. Please use a different security key.")); + assertThat(webAuthnErrorPage.getError(), containsString("This security key model is not allowed (AAGUID " + CHROME_AAGUID + "). Please use a different security key.")); } finally { testingClient.testing().reenableTruststoreSpi(); } diff --git a/themes/src/main/resources/theme/base/login/messages/messages_en.properties b/themes/src/main/resources/theme/base/login/messages/messages_en.properties index 849ed7e14680..309b0f70c2cb 100644 --- a/themes/src/main/resources/theme/base/login/messages/messages_en.properties +++ b/themes/src/main/resources/theme/base/login/messages/messages_en.properties @@ -511,12 +511,13 @@ webauthn-error-auth-verification=Passkey authentication result is invalid. webauthn-error-register-verification=Passkey registration result is invalid. webauthn-error-user-not-found=Unknown user authenticated by the Passkey. -webauthn-error-registration-not-allowed-aaguid=This security key model is not allowed. Please use a different security key. +webauthn-error-registration-not-allowed-aaguid=This security key model is not allowed (AAGUID {0}). Please use a different security key. webauthn-error-registration-aaguid-attestation-required=Your organization requires verified security keys. Attestation format ''none'' is not accepted; please use a key that provides attestation. -webauthn-error-registration-attachment-mismatch=Your organization requires a different type of security key. Please use the correct type. +webauthn-error-registration-attachment-mismatch=Your organization requires a different type of security key (invalid Authenticator Attachment ''{0}''). Please use the correct type. webauthn-error-api-not-allowed=The Passkey operation was not allowed or timed out. You may have cancelled the request, taken too long to respond, or the operation may not be permitted in this context. Please try again. webauthn-error-api-invalid-state=This Passkey is already registered. webauthn-error-api-security=A security error occurred during the Passkey operation. Please ensure you are on the correct site and try again. +webauthn-error-duplicated-device=Device already exists with the same name. # Passkey passkey-login-title=Passkey login