Skip to content

Commit

Permalink
Passkeys: Supporting WebAuthn Conditional UI
Browse files Browse the repository at this point in the history
closes keycloak#24264

Signed-off-by: Takashi Norimatsu <[email protected]>
tnorimat committed Oct 25, 2023
1 parent 0477729 commit e4112f9
Showing 5 changed files with 313 additions and 1 deletion.
4 changes: 3 additions & 1 deletion common/src/main/java/org/keycloak/common/Profile.java
Original file line number Diff line number Diff line change
@@ -92,7 +92,9 @@ public enum Feature {

DPOP("OAuth 2.0 Demonstrating Proof-of-Possession at the Application Layer", Type.PREVIEW),

LINKEDIN_OAUTH("LinkedIn Social Identity Provider based on OAuth", Type.DEPRECATED);
LINKEDIN_OAUTH("LinkedIn Social Identity Provider based on OAuth", Type.DEPRECATED),

PASSKEYS("Passkeys", Type.PREVIEW);

private final Type type;
private final String label;
Original file line number Diff line number Diff line change
@@ -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:[email protected]">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("passkeys-condional-authenticate.ftl");
context.challenge(challenge);
}

}
Original file line number Diff line number Diff line change
@@ -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:[email protected]">Takashi Norimatsu</a>
*/
public class PasskeysConditionalUIAuthenticatorFactory extends WebAuthnPasswordlessAuthenticatorFactory implements EnvironmentDependentProviderFactory {

public static final String PROVIDER_ID = "passkeys-authenticator";

@Override
public String getDisplayType() {
return "Passkeys Authenticator";
}

@Override
public String getHelpText() {
return "Authenticator for Passkeys. Usually used for Passkeys two-factor authentication";
}

@Override
public Authenticator create(KeycloakSession session) {
return new PasskeysConditionalUIAuthenticator(session);
}

@Override
public void init(Config.Scope config) {
}

@Override
public boolean isSupported() {
return Profile.isFeatureEnabled(Profile.Feature.PASSKEYS);
}

@Override
public String getId() {
return PROVIDER_ID;
}

}
Original file line number Diff line number Diff line change
@@ -51,3 +51,4 @@ 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.authentication.authenticators.browser.PasskeysConditionalUIAuthenticatorFactory
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
<#import "template.ftl" as layout>
<@layout.registrationLayout displayMessage=!messagesPerField.existsError('username') displayInfo=(realm.password && realm.registrationAllowed && !registrationDisabled??); section>
<#if section = "title">
title
<#elseif section = "header">
${kcSanitize(msg("webauthn-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("webauthn-available-authenticators"))?no_esc}</p>
</#if>

<div class="${properties.kcFormClass!}">
<#list authenticators.authenticators as authenticator>
<div id="kc-webauthn-authenticator" 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"
class="${properties.kcSelectAuthListItemHeadingClass!}">
${kcSanitize(msg('${authenticator.label}'))?no_esc}
</div>

<#if authenticator.transports?? && authenticator.transports.displayNameProperties?has_content>
<div id="kc-webauthn-authenticator-transport"
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-created-label">
${kcSanitize(msg('webauthn-createdAt-label'))?no_esc}
</span>
<span id="kc-webauthn-authenticator-created">
${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!}"><#if !realm.loginWithEmailAllowed>${msg("username")}<#elseif !realm.registrationEmailAsUsername>${msg("usernameOrEmail")}<#else>${msg("email")}</#if></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>
</div>

<div id="kc-form-webauthn-button" class="${properties.kcFormButtonsClass!}" style="display:none">
<input id="authenticateWebAuthnButton" type="button" onclick="doAuthenticate([])" autofocus="autofocus"
value="${kcSanitize(msg("webauthn-doAuthenticate"))}"
class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}"/>
</div>
</div>

<script type="text/javascript" src="${url.resourcesCommonPath}/node_modules/jquery/dist/jquery.min.js"></script>
<script type="text/javascript" src="${url.resourcesPath}/js/base64url.js"></script>
<script type="text/javascript">
window.onload = function() {
// Check if WebAuthn is supported by this browser
if (!window.PublicKeyCredential) {
$("#error").val("${msg("webauthn-unsupported-browser-text")?no_esc}");
$("#webauth").submit();
return;
}
let isUserIdentified = ${isUserIdentified};
if (isUserIdentified || typeof PublicKeyCredential.isConditionalMediationAvailable === "undefined") {
document.getElementById("kc-form-webauthn-button").style.display = 'block';
} else {
tryAutoFillUI();
}
}
async function tryAutoFillUI() {
let isConditionalMediationAvailable = await PublicKeyCredential.isConditionalMediationAvailable();
if (isConditionalMediationAvailable) {
document.getElementById("kc-form-login").style.display = "block";
doAuthenticate({ mediation: 'conditional'});
}
}
async function doAuthenticate(additionalOptions) {
// Check if WebAuthn is supported by this browser
if (!window.PublicKeyCredential) {
$("#error").val("${msg("webauthn-unsupported-browser-text")?no_esc}");
$("#webauth").submit();
return;
}
let rpId = "${rpId}";
let challenge = "${challenge}";
let isUserIdentified = ${isUserIdentified};
let createTimeout = ${createTimeout};
let userVerification = "${userVerification}";
let publicKeyOptions = {};
publicKeyOptions.rpId = rpId;
publicKeyOptions.challenge = base64url.decode(challenge, { loose: true });
publicKeyOptions.allowCredentials = !isUserIdentified ? [] : getAllowCredentials();
if (createTimeout !== 0) publicKeyOptions.timeout = createTimeout * 1000;
if (userVerification !== 'not specified') publicKeyOptions.userVerification = userVerification;
const credential = await navigator.credentials.get({
publicKey: publicKeyOptions,
...additionalOptions
}).catch(handleError);
window.result = credential;
$("#clientDataJSON").val(base64url.encode(new Uint8Array(result.response.clientDataJSON), { pad: false }));
$("#authenticatorData").val(base64url.encode(new Uint8Array(result.response.authenticatorData), { pad: false }));
$("#signature").val(base64url.encode(new Uint8Array(result.response.signature), { pad: false }));
$("#credentialId").val(result.id);
if(result.response.userHandle) {
$("#userHandle").val(base64url.encode(new Uint8Array(result.response.userHandle), { pad: false }));
}
$("#webauth").submit();
}
function handleError(err) {
$("#error").val(err);
$("#webauth").submit();
}
function getAllowCredentials() {
let allowCredentials = [];
let authn_use = document.forms['authn_select'].authn_use_chk;
if (authn_use !== undefined) {
if (authn_use.length === undefined) {
allowCredentials.push({
id: base64url.decode(authn_use.value, {loose: true}),
type: 'public-key',
});
} else {
for (let i = 0; i < authn_use.length; i++) {
allowCredentials.push({
id: base64url.decode(authn_use[i].value, {loose: true}),
type: 'public-key',
});
}
}
}
return allowCredentials;
}
</script>
<#elseif section = "info">
<#if realm.password && 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>

0 comments on commit e4112f9

Please sign in to comment.