diff --git a/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java index 508e9c63612..3e7000e3fe0 100755 --- a/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java @@ -159,6 +159,7 @@ public class RealmRepresentation { protected List webAuthnPolicyPasswordlessAcceptableAaguids; protected List webAuthnPolicyPasswordlessExtraOrigins; protected Boolean webAuthnPolicyPasswordlessPasskeysEnabled; + protected String webAuthnPolicyPasswordlessMediation; // Client Policies/Profiles @@ -1286,6 +1287,14 @@ public class RealmRepresentation { this.webAuthnPolicyPasswordlessPasskeysEnabled = webAuthnPolicyPasswordlessPasskeysEnabled; } + public String getWebAuthnPolicyPasswordlessMediation() { + return webAuthnPolicyPasswordlessMediation; + } + + public void setWebAuthnPolicyPasswordlessMediation(String webAuthnPolicyPasswordlessMediation) { + this.webAuthnPolicyPasswordlessMediation = webAuthnPolicyPasswordlessMediation; + } + // Client Policies/Profiles @JsonIgnore diff --git a/docs/documentation/server_admin/topics/authentication/passkeys.adoc b/docs/documentation/server_admin/topics/authentication/passkeys.adoc index 3988c99b89d..633a75883ed 100644 --- a/docs/documentation/server_admin/topics/authentication/passkeys.adoc +++ b/docs/documentation/server_admin/topics/authentication/passkeys.adoc @@ -7,7 +7,9 @@ Passkey registration and authentication are performed using the same features of xref:webauthn_{context}[WebAuthn]. More specifically *Passkeys* are related to xref:_webauthn_loginless[LoginLess WebAuthn] as they try to avoid any password during login. Therefore, users of {project_name} can do Passkey registration and authentication by existing xref:webauthn_{context}[WebAuthn registration and authentication], using the *passwordless* variants. -The *Passkeys* feature has been integrated seamlessly in the default authentication forms in two different ways. When activated, both conditional UI and modal UI are available in the forms in which the username input is displayed (for example *Username Password Form* or *Username Form*). Besides, the password forms, when the username was already selected, always show the modal UI button to login by passkey if the current user has passwordless WebAuthn credentials associated. This way modal and conditional UI can be used to perform a complete login from scratch that needs username and password, and only modal UI is presented when the username is already selected in the authentication process (because of re-authentication or because the user was selected before in the process not using a passkey). +The *Passkeys* feature has been integrated seamlessly in the default authentication forms in two different ways. When activated, both conditional UI and modal UI are available in the forms in which the username input is displayed (for example *Username Password Form* or *Username Form*). +The exact behavior on page load — whether a passkey selection dialog appears automatically or credentials are offered only through browser autofill — is controlled by the *Passkey Mediation* setting in the *WebAuthn Passwordless Policy*. +Besides, the password forms, when the username was already selected, always show the modal UI button to login by passkey if the current user has passwordless WebAuthn credentials associated. This way modal and conditional UI can be used to perform a complete login from scratch that needs username and password, and only modal UI is presented when the username is already selected in the authentication process (because of re-authentication or because the user was selected before in the process not using a passkey). *Passkeys* have been added to the following authenticator implementations: @@ -51,6 +53,37 @@ The modal UI button is also presented in the password forms when the user is alr .Passkey Authentication with Modal UI using Chrome browser image:images/passkey-modal-ui.png[Passkey Authentication with Modal UI using Chrome browser] +[[_passkeys-mediation]] +==== Passkey Mediation + +The *Passkey Mediation* setting in the *WebAuthn Passwordless Policy* controls how the browser interacts with the user's passkeys when the login page loads. It maps directly to the https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/get#mediation[`mediation` parameter of the WebAuthn `navigator.credentials.get()` API]. + +.Passkey Mediation options +[cols="1,3",options="header"] +|=== +|Value |Behavior + +|`conditional` (default) +|No dialog is shown on page load. Passkeys are offered only through the browser's autofill dropdown when the user focuses the username input field. This is the least intrusive option and corresponds to the <<_passkeys-conditional-ui,Conditional UI>> behavior. If the browser does not support conditional mediation, the behaviour is the same as `none`. + +|`none` +|No automatic passkey action is taken on page load. The user can still authenticate using a passkey by clicking the *Sign in with Passkey* button. Use this option when you want passkeys to be available but prefer not to display any prompt automatically. + +|`optional` +|A passkey selection dialog is shown automatically on page load. The browser may skip the prompt if no credentials are found. Use this to invite the user to use a passkey without strictly enforcing it. + +|`required` +|A passkey selection dialog is shown immediately on page load. The user is forced to authenticate or manually dismiss the prompt to proceed. Use this when passkey verification is a mandatory requirement for the current action. + +|`silent` +|The browser attempts to authenticate without any user interaction or visible UI. Authentication fails silently if user interaction is required. This is intended for advanced scenarios and is unlikely to succeed for most passkey types. +|=== + +[NOTE] +==== +The behavior of the `mediation` option, although defined in the specification, can vary between browsers and authenticators. {project_name} simply passes this option during the registration process. The actual final behavior is beyond its control. For example, the support for `required` and `silent` mediation are known to be different among browsers. Refer to your target browser's documentation before relying on these values in production. +==== + ==== Setup Set up Passkey Authentication for the default forms as follows: @@ -61,4 +94,6 @@ Set up Passkey Authentication for the default forms as follows: + NOTE: Storage capacity is usually very limited on hardware passkeys meaning that you cannot store many discoverable credentials on your passkey. However, this limitation may be mitigated for instance if you use an Android phone backed by a Google account as a passkey device or an iPhone backed by Bitwarden. + -. In the *WebAuthn Passwordless Policy* tab, activate the *Enable Passkeys* option at the bottom. This switch is the one that really enables passkeys in the realm. \ No newline at end of file +. In the *WebAuthn Passwordless Policy* tab, activate the *Enable Passkeys* option at the bottom. This switch is the one that really enables passkeys in the realm. + +. Once *Enable Passkeys* is on, the *Passkey Mediation* select appears. Choose the value that best fits your use case. The default `conditional` value keeps behavior unobtrusive — passkeys are offered through autofill only. Switch to `optional` if you want the browser to automatically open a passkey selection dialog on page load while still allowing the user to dismiss it and fall back to autofill. See <<_passkeys-mediation,Passkey Mediation>> for a description of all available values. diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index 18de56f77be..45f487cea20 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -2296,6 +2296,12 @@ realmExplain=A realm manages a set of users, credentials, roles, and groups. A u inputHelperTextBefore=Helper text (above) the input field webAuthnPolicyExtraOrigins=Extra Origins webAuthnPolicyPasskeysEnabled=Enable Passkeys +webAuthnPolicyMediation=Passkey Mediation +mediation.conditional=Conditional (autofill only) +mediation.none=None (button only, no automatic prompt) +mediation.optional=Optional (show dialog on page load) +mediation.required=Required (force immediate dialog) +mediation.silent=Silent (no user interaction) samlSignatureKeyName=SAML signature key name validateUsersDn=You must enter users DN importError=Could not import certificate {{error}} @@ -3071,6 +3077,7 @@ generatedUserInfoHelp=See the example User Info, which will be provided by the U dynamicScopeFormat=Dynamic scope format webAuthnPolicyExtraOriginsHelp=The list of extra origins for non-web applications. webAuthnPolicyPasskeysEnabledHelp=Enable passkeys (conditional UI) authentication in the username forms. +webAuthnPolicyMediationHelp=Controls how the browser presents the passkey selection dialog when the login page loads. updatePermissionSuccess=Successfully updated the permission idpLinkSuccess=Identity provider has been linked removeAnnotationText=Remove annotation diff --git a/js/apps/admin-ui/src/authentication/policies/WebauthnPolicy.tsx b/js/apps/admin-ui/src/authentication/policies/WebauthnPolicy.tsx index e7d1d761073..210471d892e 100644 --- a/js/apps/admin-ui/src/authentication/policies/WebauthnPolicy.tsx +++ b/js/apps/admin-ui/src/authentication/policies/WebauthnPolicy.tsx @@ -64,6 +64,14 @@ const USER_VERIFY = [ "discouraged", ] as const; +const MEDIATION_OPTIONS = [ + "conditional", + "none", + "optional", + "required", + "silent", +] as const; + type WeauthnSelectProps = { name: string; label: string; @@ -118,6 +126,7 @@ export const WebauthnPolicy = ({ const { setValue, handleSubmit, + watch, formState: { isDirty }, } = form; @@ -265,13 +274,24 @@ export const WebauthnPolicy = ({ /> {isPasswordLess && isFeatureEnabled(Feature.Passkeys) && ( - + <> + + {watch(`${namePrefix}PasskeysEnabled`) && ( + + )} + )} diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java b/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java index f9ab54e6bbd..2603455ef18 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java @@ -1080,6 +1080,12 @@ public class RealmAdapter implements StorageProviderRealmModel, JpaModel importAuthenticationFlows(KeycloakSession session, RealmModel newRealm, RealmRepresentation rep) { diff --git a/server-spi-private/src/main/java/org/keycloak/models/WebAuthnPolicyTwoFactorDefaults.java b/server-spi-private/src/main/java/org/keycloak/models/WebAuthnPolicyTwoFactorDefaults.java index 014a0d5cbdf..a7151a95b62 100644 --- a/server-spi-private/src/main/java/org/keycloak/models/WebAuthnPolicyTwoFactorDefaults.java +++ b/server-spi-private/src/main/java/org/keycloak/models/WebAuthnPolicyTwoFactorDefaults.java @@ -46,6 +46,7 @@ public class WebAuthnPolicyTwoFactorDefaults extends WebAuthnPolicy { this.acceptableAaguids = Collections.emptyList(); this.extraOrigins = Collections.emptyList(); this.passkeysEnabled = null; + this.mediation = null; } @Override @@ -108,6 +109,11 @@ public class WebAuthnPolicyTwoFactorDefaults extends WebAuthnPolicy { throwReadOnlyException(); } + @Override + public void setMediation(String mediation) { + throwReadOnlyException(); + } + private void throwReadOnlyException() { throw new ReadOnlyException("Default WebAuthnPolicy!"); } diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java index e812e45e6e1..97717b5e94f 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java @@ -187,6 +187,7 @@ public class ModelToRepresentation { REALM_EXCLUDED_ATTRIBUTES.add("webAuthnPolicyAvoidSameAuthenticatorRegisterPasswordless"); REALM_EXCLUDED_ATTRIBUTES.add("webAuthnPolicyAcceptableAaguidsPasswordless"); REALM_EXCLUDED_ATTRIBUTES.add("webAuthnPolicyPasskeysEnabledPasswordless"); + REALM_EXCLUDED_ATTRIBUTES.add("webAuthnPolicyMediationPasswordless"); REALM_EXCLUDED_ATTRIBUTES.add(Constants.CLIENT_POLICIES); REALM_EXCLUDED_ATTRIBUTES.add(Constants.CLIENT_PROFILES); @@ -589,6 +590,7 @@ public class ModelToRepresentation { rep.setWebAuthnPolicyPasswordlessAcceptableAaguids(webAuthnPolicy.getAcceptableAaguids()); rep.setWebAuthnPolicyPasswordlessExtraOrigins(webAuthnPolicy.getExtraOrigins()); rep.setWebAuthnPolicyPasswordlessPasskeysEnabled(webAuthnPolicy.isPasskeysEnabled()); + rep.setWebAuthnPolicyPasswordlessMediation(webAuthnPolicy.getMediation()); CibaConfig cibaPolicy = realm.getCibaPolicy(); Map attrMap = ofNullable(rep.getAttributes()).orElse(new HashMap<>()); diff --git a/server-spi/src/main/java/org/keycloak/models/WebAuthnPolicy.java b/server-spi/src/main/java/org/keycloak/models/WebAuthnPolicy.java index 0547a0e2aa3..7790bd22db4 100644 --- a/server-spi/src/main/java/org/keycloak/models/WebAuthnPolicy.java +++ b/server-spi/src/main/java/org/keycloak/models/WebAuthnPolicy.java @@ -43,6 +43,7 @@ public class WebAuthnPolicy implements Serializable { protected List acceptableAaguids; protected List extraOrigins; protected Boolean passkeysEnabled; // only used for passwordless + protected String mediation; // only used for passwordless public WebAuthnPolicy() { } @@ -149,4 +150,12 @@ public class WebAuthnPolicy implements Serializable { public void setPasskeysEnabled(Boolean passkeysEnabled) { this.passkeysEnabled = passkeysEnabled; } + + public String getMediation() { + return mediation; + } + + public void setMediation(String mediation) { + this.mediation = mediation; + } } diff --git a/services/src/main/java/org/keycloak/WebAuthnConstants.java b/services/src/main/java/org/keycloak/WebAuthnConstants.java index 96046742dd1..cf834100aa5 100644 --- a/services/src/main/java/org/keycloak/WebAuthnConstants.java +++ b/services/src/main/java/org/keycloak/WebAuthnConstants.java @@ -46,6 +46,7 @@ public interface WebAuthnConstants { String USER_VERIFICATION = "userVerification"; String TRANSPORTS = "transports"; String ENABLE_WEBAUTHN_CONDITIONAL_UI = "enableWebAuthnConditionalUI"; + String MEDIATION = "mediation"; String IS_SET_RETRY = "isSetRetry"; String SHOULD_DISPLAY_AUTHENTICATORS = "shouldDisplayAuthenticators"; 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 459a5ca1d86..72e080e2349 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 @@ -120,6 +120,7 @@ public class WebAuthnAuthenticator implements Authenticator, CredentialValidator String userVerificationRequirement = policy.getUserVerificationRequirement(); form.setAttribute(WebAuthnConstants.USER_VERIFICATION, userVerificationRequirement); form.setAttribute(WebAuthnConstants.SHOULD_DISPLAY_AUTHENTICATORS, shouldDisplayAuthenticators(context)); + form.setAttribute(WebAuthnConstants.MEDIATION, policy.getMediation()); return form; } diff --git a/test-framework/core/src/main/java/org/keycloak/testframework/realm/RealmBuilder.java b/test-framework/core/src/main/java/org/keycloak/testframework/realm/RealmBuilder.java index 23e5af5c7fa..984858e93dd 100644 --- a/test-framework/core/src/main/java/org/keycloak/testframework/realm/RealmBuilder.java +++ b/test-framework/core/src/main/java/org/keycloak/testframework/realm/RealmBuilder.java @@ -464,6 +464,11 @@ public class RealmBuilder { return this; } + public RealmBuilder webAuthnPolicyPasswordlessMediation(String mediation) { + rep.setWebAuthnPolicyPasswordlessMediation(mediation); + return this; + } + public RealmBuilder webAuthnPolicyAcceptableAaguids(List aaguids) { rep.setWebAuthnPolicyAcceptableAaguids(aaguids); return this; diff --git a/tests/webauthn/src/test/java/org/keycloak/tests/webauthn/passwordless/PasskeysUsernameFormTest.java b/tests/webauthn/src/test/java/org/keycloak/tests/webauthn/passwordless/PasskeysUsernameFormTest.java index 182e8652e75..315840bf421 100644 --- a/tests/webauthn/src/test/java/org/keycloak/tests/webauthn/passwordless/PasskeysUsernameFormTest.java +++ b/tests/webauthn/src/test/java/org/keycloak/tests/webauthn/passwordless/PasskeysUsernameFormTest.java @@ -40,6 +40,8 @@ import org.hamcrest.MatcherAssert; import org.hamcrest.Matchers; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.openqa.selenium.By; import org.openqa.selenium.NoSuchElementException; @@ -69,8 +71,9 @@ public class PasskeysUsernameFormTest extends AbstractWebAuthnVirtualTest { return true; } - @Test - public void webauthnLoginWithDiscoverableKey() { + @ParameterizedTest + @ValueSource(strings = {"conditional", "optional"}) + public void webauthnLoginWithDiscoverableKey(String mediation) { getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.PASSKEYS.getOptions()); // set passwordless policy for discoverable keys @@ -78,7 +81,8 @@ public class PasskeysUsernameFormTest extends AbstractWebAuthnVirtualTest { managedRealm.updateWithCleanup(r -> r.webAuthnPolicyPasswordlessRpEntityName("localhost") .webAuthnPolicyPasswordlessRequireResidentKey(Constants.WEBAUTHN_POLICY_OPTION_YES) .webAuthnPolicyPasswordlessUserVerificationRequirement(Constants.WEBAUTHN_POLICY_OPTION_REQUIRED) - .webAuthnPolicyPasswordlessPasskeysEnabled(Boolean.TRUE)); + .webAuthnPolicyPasswordlessPasskeysEnabled(Boolean.TRUE) + .webAuthnPolicyPasswordlessMediation(mediation)); checkWebAuthnConfiguration(Constants.WEBAUTHN_POLICY_OPTION_YES, Constants.WEBAUTHN_POLICY_OPTION_REQUIRED); registerDefaultUser(); diff --git a/tests/webauthn/src/test/java/org/keycloak/tests/webauthn/passwordless/PasskeysUsernamePasswordFormTest.java b/tests/webauthn/src/test/java/org/keycloak/tests/webauthn/passwordless/PasskeysUsernamePasswordFormTest.java index 81eecb2c6fb..6c24dc81032 100644 --- a/tests/webauthn/src/test/java/org/keycloak/tests/webauthn/passwordless/PasskeysUsernamePasswordFormTest.java +++ b/tests/webauthn/src/test/java/org/keycloak/tests/webauthn/passwordless/PasskeysUsernamePasswordFormTest.java @@ -35,6 +35,8 @@ import org.hamcrest.MatcherAssert; import org.hamcrest.Matchers; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.openqa.selenium.By; import org.openqa.selenium.NoSuchElementException; @@ -58,8 +60,9 @@ public class PasskeysUsernamePasswordFormTest extends AbstractWebAuthnVirtualTes return true; } - @Test - public void webauthnLoginWithDiscoverableKey() { + @ParameterizedTest + @ValueSource(strings = {"conditional", "optional"}) + public void webauthnLoginWithDiscoverableKey(String mediation) { getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.PASSKEYS.getOptions()); // set passwordless policy for discoverable keys @@ -67,7 +70,8 @@ public class PasskeysUsernamePasswordFormTest extends AbstractWebAuthnVirtualTes managedRealm.updateWithCleanup(r -> r.webAuthnPolicyPasswordlessRpEntityName("localhost") .webAuthnPolicyPasswordlessRequireResidentKey(null) .webAuthnPolicyPasswordlessUserVerificationRequirement(null) - .webAuthnPolicyPasswordlessPasskeysEnabled(Boolean.TRUE)); + .webAuthnPolicyPasswordlessPasskeysEnabled(Boolean.TRUE) + .webAuthnPolicyPasswordlessMediation(mediation)); checkWebAuthnConfiguration(Constants.WEBAUTHN_POLICY_OPTION_YES, Constants.WEBAUTHN_POLICY_OPTION_REQUIRED); diff --git a/themes/src/main/resources/theme/base/login/passkeys.ftl b/themes/src/main/resources/theme/base/login/passkeys.ftl index 24db6a1cef1..68883db0665 100644 --- a/themes/src/main/resources/theme/base/login/passkeys.ftl +++ b/themes/src/main/resources/theme/base/login/passkeys.ftl @@ -25,7 +25,8 @@ challenge : ${challenge?c}, userVerification : ${userVerification?c}, rpId : ${rpId?c}, - createTimeout : ${createTimeout?c} + createTimeout : ${createTimeout?c}, + mediation : ${(mediation!'conditional')?c}, }; document.addEventListener("DOMContentLoaded", (event) => initAuthenticate({errmsg : ${msg("passkey-unsupported-browser-text")?c}, ...args})); diff --git a/themes/src/main/resources/theme/base/login/resources/js/passkeysConditionalAuth.js b/themes/src/main/resources/theme/base/login/resources/js/passkeysConditionalAuth.js index 1b0e96adfcc..c63232deb7e 100644 --- a/themes/src/main/resources/theme/base/login/resources/js/passkeysConditionalAuth.js +++ b/themes/src/main/resources/theme/base/login/resources/js/passkeysConditionalAuth.js @@ -1,80 +1,86 @@ -import { base64url } from "rfc4648"; -import { returnSuccess, signal } from "./webauthnAuthenticate.js"; +import { doAuthenticate, returnSuccess } from "./webauthnAuthenticate.js"; -export function initAuthenticate(input, availableCallback = (available) => {}) { +const PASSKEY_MODAL_DISMISSED = 'kc_passkey_modal_dismissed'; + +/** + * Returns the current cookie KC_AUTH_SESSION_HASH value if present. + * Undefined if not present. + */ +function getModalDismissedHash() { + for (const cookie of document.cookie.split(';')) { + const [key, value] = cookie.trim().split('='); + if (key === 'KC_AUTH_SESSION_HASH' && value) { + return value; + } + } + return undefined; +} + +/** + * Entry point for passkey authentication on page load. + * + * Calls navigator.credentials.get() once with the mediation value configured + * in the WebAuthn Passwordless Policy (conditional/none/optional/required/silent). + * For "none", unsupported browsers, or an already-identified user, nothing is + * attempted automatically — the user can always initiate via the button. + * + * For modal mediations (optional/required), the dialog is shown at most once + * per authentication session: if the user dismisses it, it will not reappear + * on subsequent page loads (e.g. after a failed password attempt). + */ +export async function initAuthenticate(input, availableCallback = () => {}) { // Check if WebAuthn is supported by this browser if (!window.PublicKeyCredential) { // Fail silently as WebAuthn Conditional UI is not required return; } - if (input.isUserIdentified || typeof PublicKeyCredential.isConditionalMediationAvailable === "undefined") { + + const mediation = input.mediation ?? 'conditional'; + + if (input.isUserIdentified || mediation === 'none') { availableCallback(false); - } else { - tryAutoFillUI(input, availableCallback); - } -} - -function doAuthenticate(input) { - // Check if WebAuthn is supported by this browser - if (!window.PublicKeyCredential) { - // Fail silently as WebAuthn Conditional UI is not required return; } - const publicKey = { - rpId : input.rpId, - challenge: base64url.parse(input.challenge, { loose: true }) - }; - - publicKey.allowCredentials = !input.isUserIdentified ? [] : getAllowCredentials(); - - if (input.createTimeout !== 0) { - publicKey.timeout = input.createTimeout * 1000; - } - - if (input.userVerification !== 'not specified') { - publicKey.userVerification = input.userVerification; - } - - return navigator.credentials.get({ - publicKey: publicKey, - signal: signal(), - ...input.additionalOptions - }); -} - -async function tryAutoFillUI(input, availableCallback = (available) => {}) { - const isConditionalMediationAvailable = await PublicKeyCredential.isConditionalMediationAvailable(); - if (isConditionalMediationAvailable) { + // The isConditionalMediationAvailable() check is only relevant for + // conditional (autofill) mediation — other modes do not depend on it. + if (mediation === 'conditional') { + if (typeof PublicKeyCredential.isConditionalMediationAvailable === 'undefined') { + availableCallback(false); + return; + } + const isAvailable = await PublicKeyCredential.isConditionalMediationAvailable(); + if (!isAvailable) { + // Treat unavailable conditional UI the same as 'none' + availableCallback(false); + return; + } availableCallback(true); - input.additionalOptions = { mediation: 'conditional'}; - try { - const result = await doAuthenticate(input); - returnSuccess(result); - } catch { - // Fail silently as WebAuthn Conditional UI is not required - } } else { availableCallback(false); } -} -function getAllowCredentials() { - const allowCredentials = []; - const authnUse = document.forms['authn_select'].authn_use_chk; - if (authnUse !== undefined) { - if (authnUse.length === undefined) { - allowCredentials.push({ - id: base64url.parse(authnUse.value, {loose: true}), - type: 'public-key', - }); - } else { - authnUse.forEach((entry) => - allowCredentials.push({ - id: base64url.parse(entry.value, {loose: true}), - type: 'public-key', - })); + // For modal mediations, skip if the user already dismissed the dialog in + // this authentication session — avoids re-interrupting on every page load. + const modalDismissedHash = getModalDismissedHash(); + if ((!modalDismissedHash || modalDismissedHash === sessionStorage.getItem(PASSKEY_MODAL_DISMISSED)) && + (mediation === 'optional' || mediation === 'required')) { + return; + } + + try { + const result = await doAuthenticate({ + ...input, + allowCredentials: [], + additionalOptions: { mediation }, + }); + if (result) returnSuccess(result); + } catch (err) { + // If the user explicitly dismissed the modal, remember it so it is not + // shown again during the same authentication session. + if ((mediation === 'optional' || mediation === 'required') && + (err?.name === 'NotAllowedError' || err?.name === 'AbortError')) { + sessionStorage.setItem(PASSKEY_MODAL_DISMISSED, modalDismissedHash); } } - return allowCredentials; } 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 a5f3adcb763..ca989242888 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 @@ -16,70 +16,75 @@ export function signal() { } export async function authenticateByWebAuthn(input) { - if (!input.isUserIdentified) { - try { - const result = await doAuthenticate([], input.challenge, input.userVerification, input.rpId, input.createTimeout, input.errmsg); - returnSuccess(result); - } catch (error) { - returnFailure(error); - } - return; - } - checkAllowCredentials(input.challenge, input.userVerification, input.rpId, input.createTimeout, input.errmsg); -} - -async function checkAllowCredentials(challenge, userVerification, rpId, createTimeout, errmsg) { - const allowCredentials = []; - const authnUse = document.forms['authn_select'].authn_use_chk; - if (authnUse !== undefined) { - if (authnUse.length === undefined) { - allowCredentials.push({ - id: base64url.parse(authnUse.value, {loose: true}), - type: 'public-key', - }); - } else { - authnUse.forEach((entry) => - allowCredentials.push({ - id: base64url.parse(entry.value, {loose: true}), - type: 'public-key', - })); - } - } + const allowCredentials = input.isUserIdentified ? getAllowCredentials() : []; try { - const result = await doAuthenticate(allowCredentials, challenge, userVerification, rpId, createTimeout, errmsg); - returnSuccess(result); + const result = await doAuthenticate({ ...input, allowCredentials }); + if (result) returnSuccess(result); } catch (error) { returnFailure(error); } } -function doAuthenticate(allowCredentials, challenge, userVerification, rpId, createTimeout, errmsg) { +/** + * Reads the allowed credentials from the hidden authn_select form. + * Exported so that passkeysConditionalAuth.js can use them as well. + */ +export function getAllowCredentials() { + const allowCredentials = []; + const authnUse = document.forms['authn_select']?.authn_use_chk; + if (authnUse !== undefined) { + if (authnUse.length === undefined) { + allowCredentials.push({ + id: base64url.parse(authnUse.value, { loose: true }), + type: 'public-key', + }); + } else { + authnUse.forEach((entry) => + allowCredentials.push({ + id: base64url.parse(entry.value, { loose: true }), + type: 'public-key', + })); + } + } + return allowCredentials; +} + +/** + * Core function for navigator.credentials.get(). + * Exported so that passkeysConditionalAuth.js does not need its own copy. + * + * input: { challenge, userVerification, rpId, createTimeout, errmsg, + * allowCredentials?: PublicKeyCredentialDescriptor[], + * additionalOptions?: object ← e.g. { mediation: "conditional" | "optional" | "required" | "silent" } } + */ +export function doAuthenticate(input) { // Check if WebAuthn is supported by this browser if (!window.PublicKeyCredential) { - returnFailure(errmsg); + returnFailure(input.errmsg); return; } const publicKey = { - rpId : rpId, - challenge: base64url.parse(challenge, { loose: true }) + rpId: input.rpId, + challenge: base64url.parse(input.challenge, { loose: true }), }; - if (createTimeout !== 0) { - publicKey.timeout = createTimeout * 1000; + if (input.createTimeout !== 0) { + publicKey.timeout = input.createTimeout * 1000; } - if (allowCredentials.length) { - publicKey.allowCredentials = allowCredentials; + if (input.allowCredentials !== undefined) { + publicKey.allowCredentials = input.allowCredentials; } - if (userVerification !== 'not specified') { - publicKey.userVerification = userVerification; + if (input.userVerification !== 'not specified') { + publicKey.userVerification = input.userVerification; } return navigator.credentials.get({ publicKey: publicKey, - signal: signal() + signal: signal(), + ...input.additionalOptions, }); }