Passkeys: Supporting WebAuthn Conditional UI (#24305)

closes #24264

Signed-off-by: Takashi Norimatsu <takashi.norimatsu.ws@hitachi.com>
Signed-off-by: mposolda <mposolda@gmail.com>


Co-authored-by: mposolda <mposolda@gmail.com>
This commit is contained in:
Takashi Norimatsu 2024-05-16 14:58:43 +09:00 committed by GitHub
parent 89d7108558
commit b4e7d9b1aa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 441 additions and 19 deletions

View file

@ -111,7 +111,10 @@ public class Profile {
OID4VC_VCI("Support for the OID4VCI protocol as part of OID4VC.", Type.EXPERIMENTAL),
DECLARATIVE_UI("declarative ui spi", Type.EXPERIMENTAL),
ORGANIZATION("Organization support within realms", Type.EXPERIMENTAL),
PASSKEYS("Passkeys", Type.PREVIEW)
;
private final Type type;

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -12,3 +12,80 @@ Therefore, users of {project_name} can do Passkey registration and authenticatio
Both synced Passkeys and device-bound Passkeys can be used for both Same-Device and Cross-Device Authentication (CDA).
However, Passkeys operations success depends on the user's environment. Make sure which operations can succeed in https://passkeys.dev/device-support/[the environment].
====
[[_passkeys-conditional-ui]]
==== Passkey Authentication with Conditional UI
Passkey Authentication with Conditional UI can authenticate a user with its passkey in the same way as in xref:_webauthn_loginless[LoginLess WebAuthn].
This authentication shows a user a list of passkeys stored on a device where the user runs a browser.
Therefore, the user can select one of the passkeys in the list to authenticate them. Compared with xref:_webauthn_loginless[LoginLess WebAuthn], the authentication improves the user's experience of authentication.
[NOTE]
====
This authentication uses the https://github.com/w3c/webauthn/wiki/Explainer:-WebAuthn-Conditional-UI/[WebAuthn Conditional UI].
Therefore, this authentication success depends on the user's environment.
If the environment does not support WebAuthn Conditional UI, this authentication falls back to xref:_webauthn_loginless[LoginLess WebAuthn].
====
:tech_feature_name: Passkey Authentication
:tech_feature_setting: -Dkeycloak.profile.feature.passkeys=enabled
:tech_feature_id: passkeys
include::../templates/techpreview.adoc[]
.Procedure
===== Setup
Set up Passkey Authentication with Conditional UI as follows:
. (if not already present) Register a new required action for WebAuthn passwordless support. Use the steps described in <<_webauthn-register, Enable WebAuthn Authenticator Registration>>. Register the `Webauthn Register Passwordless` action.
. Configure the `WebAuthn Passwordless Policy`. Perform the configuration in the Admin Console, `Authentication` section, in the tab `Policies` -> `WebAuthn Passwordless Policy`. Set *User Verification Requirement* to *required* and *Require discoverable credential* to *Yes* when you configure the policy for loginless scenario. Note that since there is no dedicated Loginless policy, it is impossible to mix authentication scenarios with user verification=no/discoverable credential=no and loginless scenarios (user verification=yes/discoverable credential=yes).
+
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.
. Configure the authentication flow. Create a new authentication flow, add the *Passkeys Conditional UI Authenticator* execution and set the Requirement setting of the execution to *Required*.
+
The final configuration of the flow looks similar to this:
image:images/passkey-conditional-ui-flow.png[Passkey Authentication with Conditional UI flow flow]
. Bind the flow above as a *browser* authentication flow in the realm as described in the <<_webauthn-register, WebAuthn section above>>.
The authentication flow above requires that user must already have passkey credential on his or her account to be able to log in. This requirement means that all users in the realm must have passkeys already set.
That can be achieved for instance by enabling user registration as described below.
===== Setup of the registration for passkeys conditional UI
. Enable <<con-user-registration_{context}, registration>> for your realm
. In the <<configuring-authentication_{context},authentication flows>> of the realm, select flow *registration* and switch the authenticator *Password validation* to *Disabled*.
This means that newly registered users will not be required to create the passwords in this example setup. Users must always use passkeys instead of passwords.
. Return to the *Required actions* sub-tab of the tab *Authentication* tab and find the `Webauthn Register Passwordless` action and mark it with *Set as default action*.
This means that it would be added to all new users after their registration.
The alternative to the registration flow setup is to add the required action `WebAuthn Register Passwordless` to a user who is already known to {project_name}. The user with the required action configured will have to authenticate (with a username/password for example) and will then be prompted to register a passkey to be used for loginless authentication.
[NOTE]
====
We plan to improve the usability and allow integration of conditional passkeys with the existing authenticators and forms such as the default username / password form.
====
[NOTE]
====
From https://www.w3.org/TR/webauthn-3/[Web Authn Level 3], *Resident Key* was replaced with *Discoverable Credential*.
====
If a user's browser supports https://github.com/w3c/webauthn/wiki/Explainer:-WebAuthn-Conditional-UI/[WebAuthn Conditional UI], the follwing screen is shown.
.Passkey Authentication with Conditional UI
image:images/passkey-conditional-ui-authentication.png[Passkey Authentication with Conditional UI]
When the user clicks the *Select your passkey* textbox, a list of passkeys stored on a device where the user runs a browse is shown as follows.
.Passkey Authentication with Conditional UI Autofill
image:images/passkey-conditional-ui-autofill.png[Passkey Authentication with Conditional UI Autofill]
If a user's browser does not support https://github.com/w3c/webauthn/wiki/Explainer:-WebAuthn-Conditional-UI/[WebAuthn Conditional UI], the authenticaion falls back to the xref:_webauthn_loginless[LoginLess WebAuthn] as follows.
.Passkey Authentication with Conditional UI falling back to LoginLess WebAuthn
image:images/passkey-conditional-ui-fallback-authentication.png[Passkey Authentication with Conditional UI falling back to LoginLess WebAuthn]

View file

@ -0,0 +1,43 @@
/*
* Copyright 2023 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
/**
* @author <a href="mailto:takashi.norimatsu.ws@hitachi.com">Takashi Norimatsu</a>
*/
package org.keycloak.authentication.authenticators.browser;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.models.KeycloakSession;
import jakarta.ws.rs.core.Response;
public class PasskeysConditionalUIAuthenticator extends WebAuthnPasswordlessAuthenticator {
public PasskeysConditionalUIAuthenticator(KeycloakSession session) {
super(session);
}
@Override
public void authenticate(AuthenticationFlowContext context) {
super.authenticate(context);
Response challenge = context.form()
.createForm("login-passkeys-conditional-authenticate.ftl");
context.challenge(challenge);
}
}

View file

@ -0,0 +1,63 @@
/*
* Copyright 2023 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package org.keycloak.authentication.authenticators.browser;
import org.keycloak.Config;
import org.keycloak.authentication.Authenticator;
import org.keycloak.common.Profile;
import org.keycloak.models.KeycloakSession;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
/**
* @author <a href="mailto:takashi.norimatsu.ws@hitachi.com">Takashi Norimatsu</a>
*/
public class PasskeysConditionalUIAuthenticatorFactory extends WebAuthnPasswordlessAuthenticatorFactory implements EnvironmentDependentProviderFactory {
public static final String PROVIDER_ID = "passkeys-authenticator";
@Override
public String getDisplayType() {
return "Passkeys Conditional UI Authenticator";
}
@Override
public String getHelpText() {
return "Authenticator for Passkeys with conditional UI. A list of passkeys stored on a device where a browser is running is automatically shown. Due to characteristics of conditional UI, it is used for login-less authentication.";
}
@Override
public Authenticator create(KeycloakSession session) {
return new PasskeysConditionalUIAuthenticator(session);
}
@Override
public void init(Config.Scope config) {
}
@Override
public boolean isSupported(Config.Scope config) {
return Profile.isFeatureEnabled(Profile.Feature.PASSKEYS);
}
@Override
public String getId() {
return PROVIDER_ID;
}
}

View file

@ -53,4 +53,5 @@ org.keycloak.authentication.authenticators.access.DenyAccessAuthenticatorFactory
org.keycloak.authentication.authenticators.access.AllowAccessAuthenticatorFactory
org.keycloak.authentication.authenticators.sessionlimits.UserSessionLimitsAuthenticatorFactory
org.keycloak.authentication.authenticators.browser.RecoveryAuthnCodesFormAuthenticatorFactory
org.keycloak.organization.authentication.authenticators.browser.OrganizationAuthenticatorFactory
org.keycloak.organization.authentication.authenticators.browser.OrganizationAuthenticatorFactory
org.keycloak.authentication.authenticators.browser.PasskeysConditionalUIAuthenticatorFactory

View file

@ -37,17 +37,22 @@ import static org.keycloak.testsuite.util.UIUtils.getTextFromElementOrNull;
*/
public class WebAuthnAuthenticatorsList {
@FindBy(className = "kc-webauthn-authenticator")
@FindBy(xpath = "//div[contains(@id,'kc-webauthn-authenticator-item-')]")
private List<WebElement> authenticators;
public List<WebAuthnAuthenticatorItem> getItems() {
try {
List<WebAuthnAuthenticatorItem> items = new ArrayList<>();
for (WebElement auth : authenticators) {
String name = getTextFromElementOrNull(() -> auth.findElement(By.className("kc-webauthn-authenticator-label")));
String createdAt = getTextFromElementOrNull(() -> auth.findElement(By.className("kc-webauthn-authenticator-created")));
String createdAtLabel = getTextFromElementOrNull(() -> auth.findElement(By.className("kc-webauthn-authenticator-created-label")));
String transport = getTextFromElementOrNull(() -> auth.findElement(By.className("kc-webauthn-authenticator-transport")));
for (int i = 0; i < authenticators.size(); i++) {
WebElement auth = authenticators.get(i);
final String nameId = "kc-webauthn-authenticator-label-" + i;
String name = getTextFromElementOrNull(() -> auth.findElement(By.id(nameId)));
final String createdAtId = "kc-webauthn-authenticator-created-" + i;
String createdAt = getTextFromElementOrNull(() -> auth.findElement(By.id(createdAtId)));
final String createdAtLabelId = "kc-webauthn-authenticator-createdlabel-" + i;
String createdAtLabel = getTextFromElementOrNull(() -> auth.findElement(By.id(createdAtLabelId)));
final String transportId = "kc-webauthn-authenticator-transport-" + i;
String transport = getTextFromElementOrNull(() -> auth.findElement(By.id(transportId)));
items.add(new WebAuthnAuthenticatorItem(name, createdAt, createdAtLabel, transport));
}
return items;

View file

@ -35,7 +35,7 @@ public class WebAuthnLoginPage extends LanguageComboboxAwarePage {
@FindBy(id = "authenticateWebAuthnButton")
private WebElement authenticateButton;
@FindBy(className = "kc-webauthn-authenticator-label")
@FindBy(xpath = "//div[contains(@id,'kc-webauthn-authenticator-label-')]")
private List<WebElement> authenticatorsLabels;
@Page

View file

@ -0,0 +1,143 @@
<#import "template.ftl" as layout>
<@layout.registrationLayout displayInfo=(realm.registrationAllowed && !registrationDisabled??); section>
<#if section = "title">
title
<#elseif section = "header">
${kcSanitize(msg("passkey-login-title"))?no_esc}
<#elseif section = "form">
<form id="webauth" action="${url.loginAction}" method="post">
<input type="hidden" id="clientDataJSON" name="clientDataJSON"/>
<input type="hidden" id="authenticatorData" name="authenticatorData"/>
<input type="hidden" id="signature" name="signature"/>
<input type="hidden" id="credentialId" name="credentialId"/>
<input type="hidden" id="userHandle" name="userHandle"/>
<input type="hidden" id="error" name="error"/>
</form>
<div class="${properties.kcFormGroupClass!} no-bottom-margin">
<#if authenticators??>
<form id="authn_select" class="${properties.kcFormClass!}">
<#list authenticators.authenticators as authenticator>
<input type="hidden" name="authn_use_chk" value="${authenticator.credentialId}"/>
</#list>
</form>
<#if shouldDisplayAuthenticators?? && shouldDisplayAuthenticators>
<#if authenticators.authenticators?size gt 1>
<p class="${properties.kcSelectAuthListItemTitle!}">${kcSanitize(msg("passkey-available-authenticators"))?no_esc}</p>
</#if>
<div class="${properties.kcFormClass!}">
<#list authenticators.authenticators as authenticator>
<div id="kc-webauthn-authenticator-item-${authenticator?index}" class="${properties.kcSelectAuthListItemClass!}">
<div class="${properties.kcSelectAuthListItemIconClass!}">
<i class="${(properties['${authenticator.transports.iconClass}'])!'${properties.kcWebAuthnDefaultIcon!}'} ${properties.kcSelectAuthListItemIconPropertyClass!}"></i>
</div>
<div class="${properties.kcSelectAuthListItemBodyClass!}">
<div id="kc-webauthn-authenticator-label-${authenticator?index}"
class="${properties.kcSelectAuthListItemHeadingClass!}">
${kcSanitize(msg('${authenticator.label}'))?no_esc}
</div>
<#if authenticator.transports?? && authenticator.transports.displayNameProperties?has_content>
<div id="kc-webauthn-authenticator-transport-${authenticator?index}"
class="${properties.kcSelectAuthListItemDescriptionClass!}">
<#list authenticator.transports.displayNameProperties as nameProperty>
<span>${kcSanitize(msg('${nameProperty!}'))?no_esc}</span>
<#if nameProperty?has_next>
<span>, </span>
</#if>
</#list>
</div>
</#if>
<div class="${properties.kcSelectAuthListItemDescriptionClass!}">
<span id="kc-webauthn-authenticator-createdlabel-${authenticator?index}">
${kcSanitize(msg('passkey-createdAt-label'))?no_esc}
</span>
<span id="kc-webauthn-authenticator-created-${authenticator?index}">
${kcSanitize(authenticator.createdAt)?no_esc}
</span>
</div>
</div>
<div class="${properties.kcSelectAuthListItemFillClass!}"></div>
</div>
</#list>
</div>
</#if>
</#if>
<div id="kc-form">
<div id="kc-form-wrapper">
<#if realm.password>
<form id="kc-form-login" onsubmit="login.disabled = true; return true;" action="${url.loginAction}" method="post" style="display:none">
<#if !usernameHidden??>
<div class="${properties.kcFormGroupClass!}">
<label for="username" class="${properties.kcLabelClass!}">${msg("passkey-autofill-select")}</label>
<input tabindex="1" id="username"
aria-invalid="<#if messagesPerField.existsError('username')>true</#if>"
class="${properties.kcInputClass!}" name="username"
value="${(login.username!'')}"
autocomplete="username webauthn"
type="text" autofocus autocomplete="off"/>
<#if messagesPerField.existsError('username')>
<span id="input-error-username" class="${properties.kcInputErrorMessageClass!}" aria-live="polite">
${kcSanitize(messagesPerField.get('username'))?no_esc}
</span>
</#if>
</div>
</#if>
</form>
</#if>
<div id="kc-form-passkey-button" class="${properties.kcFormButtonsClass!}" style="display:none">
<input id="authenticateWebAuthnButton" type="button" onclick="doAuthenticate([], "${rpId}", "${challenge}", ${isUserIdentified}, ${createTimeout}, "${userVerification}", "${msg("passkey-unsupported-browser-text")?no_esc}")" autofocus="autofocus"
value="${kcSanitize(msg("passkey-doAuthenticate"))}"
class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}"/>
</div>
<div id="kc-form-passkey-button" class="${properties.kcFormButtonsClass!}" style="display:none">
<input id="authenticateWebAuthnButton" type="button" autofocus="autofocus"
value="${kcSanitize(msg("passkey-doAuthenticate"))}"
class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}"/>
</div>
</div>
</div>
</div>
<script type="module">
import { authenticateByWebAuthn } from "${url.resourcesPath}/js/webauthnAuthenticate.js";
import { initAuthenticate } from "${url.resourcesPath}/js/passkeysConditionalAuth.js";
const authButton = document.getElementById('authenticateWebAuthnButton');
const input = {
isUserIdentified : ${isUserIdentified},
challenge : '${challenge}',
userVerification : '${userVerification}',
rpId : '${rpId}',
createTimeout : ${createTimeout},
errmsg : "${msg("webauthn-unsupported-browser-text")?no_esc}"
};
authButton.addEventListener("click", () => {
authenticateByWebAuthn(input);
});
const args = {
isUserIdentified : ${isUserIdentified},
challenge : '${challenge}',
userVerification : '${userVerification}',
rpId : '${rpId}',
createTimeout : ${createTimeout},
errmsg : "${msg("passkey-unsupported-browser-text")?no_esc}"
};
document.addEventListener("DOMContentLoaded", (event) => initAuthenticate(args));
</script>
<#elseif section = "info">
<#if realm.registrationAllowed && !registrationDisabled??>
<div id="kc-registration">
<span>${msg("noAccount")} <a tabindex="6" href="${url.registrationUrl}">${msg("doRegister")}</a></span>
</div>
</#if>
</#if>
</@layout.registrationLayout>

View file

@ -480,6 +480,14 @@ webauthn-error-auth-verification=Passkey authentication result is invalid.<br/>
webauthn-error-register-verification=Passkey registration result is invalid.<br/> {0}
webauthn-error-user-not-found=Unknown user authenticated by the Passkey.
# Passkey
passkey-login-title=Passkey login
passkey-available-authenticators=Available Passkeys
passkey-unsupported-browser-text=Passkey is not supported by this browser. Try another one or contact your administrator.
passkey-doAuthenticate=Sign in with Passkey
passkey-createdAt-label=Created
passkey-autofill-select=Select your passkey
# Identity provider
identity-provider-redirector=Connect with another Identity Provider
identity-provider-login-label=Or sign in with

View file

@ -0,0 +1,79 @@
import { base64url } from "rfc4648";
import { returnSuccess, returnFailure } from "./webauthnAuthenticate.js";
export function initAuthenticate(input) {
// Check if WebAuthn is supported by this browser
if (!window.PublicKeyCredential) {
returnFailure(input.errmsg);
return;
}
if (input.isUserIdentified || typeof PublicKeyCredential.isConditionalMediationAvailable === "undefined") {
document.getElementById("kc-form-passkey-button").style.display = 'block';
} else {
tryAutoFillUI(input);
}
}
function doAuthenticate(input) {
// Check if WebAuthn is supported by this browser
if (!window.PublicKeyCredential) {
returnFailure(input.errmsg);
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,
...input.additionalOptions
});
}
async function tryAutoFillUI(input) {
const isConditionalMediationAvailable = await PublicKeyCredential.isConditionalMediationAvailable();
if (isConditionalMediationAvailable) {
document.getElementById("kc-form-login").style.display = "block";
input.additionalOptions = { mediation: 'conditional'};
try {
const result = await doAuthenticate(input);
returnSuccess(result);
} catch (error) {
returnFailure(error);
}
} else {
document.getElementById("kc-form-passkey-button").style.display = 'block';
}
}
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;
}

View file

@ -65,7 +65,7 @@ function doAuthenticate(allowCredentials, challenge, userVerification, rpId, cre
return navigator.credentials.get({publicKey});
}
function returnSuccess(result) {
export function returnSuccess(result) {
document.getElementById("clientDataJSON").value = base64url.stringify(new Uint8Array(result.response.clientDataJSON), { pad: false });
document.getElementById("authenticatorData").value = base64url.stringify(new Uint8Array(result.response.authenticatorData), { pad: false });
document.getElementById("signature").value = base64url.stringify(new Uint8Array(result.response.signature), { pad: false });
@ -76,7 +76,7 @@ function returnSuccess(result) {
document.getElementById("webauth").submit();
}
function returnFailure(err) {
export function returnFailure(err) {
document.getElementById("error").value = err;
document.getElementById("webauth").submit();
}

View file

@ -1,5 +1,5 @@
<#import "template.ftl" as layout>
<@layout.registrationLayout displayMessage=!messagesPerField.existsError('username') displayInfo=(realm.password && realm.registrationAllowed && !registrationDisabled??); section>
<@layout.registrationLayout displayInfo=(realm.registrationAllowed && !registrationDisabled??); section>
<#if section = "title">
title
<#elseif section = "header">
@ -30,19 +30,19 @@
<div class="${properties.kcFormClass!}">
<#list authenticators.authenticators as authenticator>
<div class="kc-webauthn-authenticator ${properties.kcSelectAuthListItemClass!}">
<div id="kc-webauthn-authenticator-item-${authenticator?index}" class="${properties.kcSelectAuthListItemClass!}">
<div class="${properties.kcSelectAuthListItemIconClass!}">
<i class="${(properties['${authenticator.transports.iconClass}'])!'${properties.kcWebAuthnDefaultIcon!}'} ${properties.kcSelectAuthListItemIconPropertyClass!}"></i>
</div>
<div class="${properties.kcSelectAuthListItemBodyClass!}">
<div
class=" kc-webauthn-authenticator-label ${properties.kcSelectAuthListItemHeadingClass!}">
<div id="kc-webauthn-authenticator-label-${authenticator?index}"
class="${properties.kcSelectAuthListItemHeadingClass!}">
${kcSanitize(msg('${authenticator.label}'))?no_esc}
</div>
<#if authenticator.transports?? && authenticator.transports.displayNameProperties?has_content>
<div
class="kc-webauthn-authenticator-transport ${properties.kcSelectAuthListItemDescriptionClass!}">
<div id="kc-webauthn-authenticator-transport-${authenticator?index}"
class="${properties.kcSelectAuthListItemDescriptionClass!}">
<#list authenticator.transports.displayNameProperties as nameProperty>
<span>${kcSanitize(msg('${nameProperty!}'))?no_esc}</span>
<#if nameProperty?has_next>
@ -53,10 +53,10 @@
</#if>
<div class="${properties.kcSelectAuthListItemDescriptionClass!}">
<span class="kc-webauthn-authenticator-created-label">
<span id="kc-webauthn-authenticator-createdlabel-${authenticator?index}">
${kcSanitize(msg('webauthn-createdAt-label'))?no_esc}
</span>
<span class="kc-webauthn-authenticator-created">
<span id="kc-webauthn-authenticator-created-${authenticator?index}">
${kcSanitize(authenticator.createdAt)?no_esc}
</span>
</div>
@ -93,7 +93,7 @@
</script>
<#elseif section = "info">
<#if realm.password && realm.registrationAllowed && !registrationDisabled??>
<#if realm.registrationAllowed && !registrationDisabled??>
<div id="kc-registration">
<span>${msg("noAccount")} <a tabindex="6" href="${url.registrationUrl}">${msg("doRegister")}</a></span>
</div>