forked from keycloak/keycloak
-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Loading status checks…
Passkeys: Supporting WebAuthn Conditional UI
closes keycloak#24264 Signed-off-by: Takashi Norimatsu <[email protected]>
Showing
5 changed files
with
313 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
43 changes: 43 additions & 0 deletions
43
...rg/keycloak/authentication/authenticators/browser/PasskeysConditionalUIAuthenticator.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
|
||
} |
63 changes: 63 additions & 0 deletions
63
...loak/authentication/authenticators/browser/PasskeysConditionalUIAuthenticatorFactory.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
203 changes: 203 additions & 0 deletions
203
themes/src/main/resources/theme/base/login/passkeys-condional-authenticate.ftl
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |