mirror of
https://github.com/keycloak/keycloak.git
synced 2026-05-28 04:13:22 -04:00
Enable to set mediation property for WebAuthn passwordless authentication (#46960)
possible values: conditional, optional, required, silent conditional remains the default to not break the current behavior when optional or required and the user dismissed the modal, it will stay hidden for this auth-session, can still be opened by button adjusted all related resources, like JS files (also consolidated duplicated logic), Java classes and freemarker template tests extended passkey documentation extended/updated closes #46959 Signed-off-by: Niko Köbler <niko@n-k.de>
This commit is contained in:
parent
a8aaed2904
commit
e5ca2a6709
18 changed files with 255 additions and 120 deletions
|
|
@ -159,6 +159,7 @@ public class RealmRepresentation {
|
|||
protected List<String> webAuthnPolicyPasswordlessAcceptableAaguids;
|
||||
protected List<String> 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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
. 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
|||
/>
|
||||
</FormGroup>
|
||||
{isPasswordLess && isFeatureEnabled(Feature.Passkeys) && (
|
||||
<SwitchControl
|
||||
name={`${namePrefix}PasskeysEnabled`}
|
||||
label={t("webAuthnPolicyPasskeysEnabled")}
|
||||
labelIcon={t("webAuthnPolicyPasskeysEnabledHelp")}
|
||||
labelOn={t("on")}
|
||||
labelOff={t("off")}
|
||||
/>
|
||||
<>
|
||||
<SwitchControl
|
||||
name={`${namePrefix}PasskeysEnabled`}
|
||||
label={t("webAuthnPolicyPasskeysEnabled")}
|
||||
labelIcon={t("webAuthnPolicyPasskeysEnabledHelp")}
|
||||
labelOn={t("on")}
|
||||
labelOff={t("off")}
|
||||
/>
|
||||
{watch(`${namePrefix}PasskeysEnabled`) && (
|
||||
<WebauthnSelect
|
||||
name={`${namePrefix}Mediation`}
|
||||
label={t("webAuthnPolicyMediation")}
|
||||
labelIcon={t("webAuthnPolicyMediationHelp")}
|
||||
options={MEDIATION_OPTIONS}
|
||||
labelPrefix="mediation"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</FormProvider>
|
||||
|
||||
|
|
|
|||
|
|
@ -1080,6 +1080,12 @@ public class RealmAdapter implements StorageProviderRealmModel, JpaModel<RealmEn
|
|||
: defaultConfig.isPasskeysEnabled();
|
||||
policy.setPasskeysEnabled(passKeysEnabled);
|
||||
|
||||
String mediation = getAttribute(RealmAttributes.WEBAUTHN_POLICY_MEDIATION + attributePrefix);
|
||||
if (mediation == null || mediation.isEmpty()) {
|
||||
mediation = defaultConfig.getMediation();
|
||||
}
|
||||
policy.setMediation(mediation);
|
||||
|
||||
return policy;
|
||||
}
|
||||
|
||||
|
|
@ -1138,6 +1144,13 @@ public class RealmAdapter implements StorageProviderRealmModel, JpaModel<RealmEn
|
|||
} else {
|
||||
removeAttribute(RealmAttributes.WEBAUTHN_POLICY_PASSKEYS_ENABLED + attributePrefix);
|
||||
}
|
||||
|
||||
String mediation = policy.getMediation();
|
||||
if (mediation != null && !mediation.isBlank()) {
|
||||
setAttribute(RealmAttributes.WEBAUTHN_POLICY_MEDIATION + attributePrefix, mediation);
|
||||
} else {
|
||||
removeAttribute(RealmAttributes.WEBAUTHN_POLICY_MEDIATION + attributePrefix);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ public interface RealmAttributes {
|
|||
String WEBAUTHN_POLICY_ACCEPTABLE_AAGUIDS = "webAuthnPolicyAcceptableAaguids";
|
||||
String WEBAUTHN_POLICY_EXTRA_ORIGINS = "webAuthnPolicyExtraOrigins";
|
||||
String WEBAUTHN_POLICY_PASSKEYS_ENABLED = "webAuthnPolicyPasskeysEnabled";
|
||||
String WEBAUTHN_POLICY_MEDIATION = "webAuthnPolicyMediation";
|
||||
|
||||
String ADMIN_EVENTS_EXPIRATION = "adminEventsExpiration";
|
||||
|
||||
|
|
|
|||
|
|
@ -1427,6 +1427,12 @@ public class DefaultExportImportManager implements ExportImportManager {
|
|||
}
|
||||
webAuthnPolicy.setPasskeysEnabled(webAuthnPolicyPasswordlessPasskeysEnabled);
|
||||
|
||||
String webAuthnPolicyPasswordlessMediation = rep.getWebAuthnPolicyPasswordlessMediation();
|
||||
if (webAuthnPolicyPasswordlessMediation == null || webAuthnPolicyPasswordlessMediation.isEmpty()) {
|
||||
webAuthnPolicyPasswordlessMediation = defaultConfig.getMediation();
|
||||
}
|
||||
webAuthnPolicy.setMediation(webAuthnPolicyPasswordlessMediation);
|
||||
|
||||
return webAuthnPolicy;
|
||||
}
|
||||
public static Map<String, String> importAuthenticationFlows(KeycloakSession session, RealmModel newRealm, RealmRepresentation rep) {
|
||||
|
|
|
|||
|
|
@ -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!");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, String> attrMap = ofNullable(rep.getAttributes()).orElse(new HashMap<>());
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ public class WebAuthnPolicy implements Serializable {
|
|||
protected List<String> acceptableAaguids;
|
||||
protected List<String> 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -464,6 +464,11 @@ public class RealmBuilder {
|
|||
return this;
|
||||
}
|
||||
|
||||
public RealmBuilder webAuthnPolicyPasswordlessMediation(String mediation) {
|
||||
rep.setWebAuthnPolicyPasswordlessMediation(mediation);
|
||||
return this;
|
||||
}
|
||||
|
||||
public RealmBuilder webAuthnPolicyAcceptableAaguids(List<String> aaguids) {
|
||||
rep.setWebAuthnPolicyAcceptableAaguids(aaguids);
|
||||
return this;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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}));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue