From d3e065591ba352a6336b53b501cc51303602e3ce Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Fri, 10 May 2024 21:12:35 +0200 Subject: [PATCH] Add webauthn-register.ftl page --- src/bin/keycloakify/generateFtl/pageId.ts | 1 + src/login/Fallback.tsx | 3 + src/login/kcContext/KcContext.ts | 19 ++ src/login/pages/WebauthnAuthenticate.tsx | 2 +- src/login/pages/WebauthnRegister.tsx | 285 ++++++++++++++++++++++ 5 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 src/login/pages/WebauthnRegister.tsx diff --git a/src/bin/keycloakify/generateFtl/pageId.ts b/src/bin/keycloakify/generateFtl/pageId.ts index 3e4084fd..e2f6b82c 100644 --- a/src/bin/keycloakify/generateFtl/pageId.ts +++ b/src/bin/keycloakify/generateFtl/pageId.ts @@ -3,6 +3,7 @@ export const loginThemePageIds = [ "login-username.ftl", "login-password.ftl", "webauthn-authenticate.ftl", + "webauthn-register.ftl", "register.ftl", "register-user-profile.ftl", "info.ftl", diff --git a/src/login/Fallback.tsx b/src/login/Fallback.tsx index 864cac1f..b0bb4d83 100644 --- a/src/login/Fallback.tsx +++ b/src/login/Fallback.tsx @@ -19,6 +19,7 @@ const LoginOtp = lazy(() => import("keycloakify/login/pages/LoginOtp")); const LoginPassword = lazy(() => import("keycloakify/login/pages/LoginPassword")); const LoginUsername = lazy(() => import("keycloakify/login/pages/LoginUsername")); const WebauthnAuthenticate = lazy(() => import("keycloakify/login/pages/WebauthnAuthenticate")); +const WebauthnRegister = lazy(() => import("keycloakify/login/pages/WebauthnRegister")); const LoginUpdatePassword = lazy(() => import("keycloakify/login/pages/LoginUpdatePassword")); const LoginUpdateProfile = lazy(() => import("keycloakify/login/pages/LoginUpdateProfile")); const LoginIdpLinkConfirm = lazy(() => import("keycloakify/login/pages/LoginIdpLinkConfirm")); @@ -70,6 +71,8 @@ export default function Fallback(props: FallbackProps) { return ; case "webauthn-authenticate.ftl": return ; + case "webauthn-register.ftl": + return ; case "login-update-password.ftl": return ; case "login-update-profile.ftl": diff --git a/src/login/kcContext/KcContext.ts b/src/login/kcContext/KcContext.ts index 59840222..557741b0 100644 --- a/src/login/kcContext/KcContext.ts +++ b/src/login/kcContext/KcContext.ts @@ -23,6 +23,7 @@ export type KcContext = | KcContext.LoginOtp | KcContext.LoginUsername | KcContext.WebauthnAuthenticate + | KcContext.WebauthnRegister | KcContext.LoginPassword | KcContext.LoginUpdatePassword | KcContext.LoginUpdateProfile @@ -362,6 +363,24 @@ export declare namespace KcContext { }; } + export type WebauthnRegister = Common & { + pageId: "webauthn-register.ftl"; + challenge: string; + userid: string; + username: string; + signatureAlgorithms: string[]; + rpEntityName: string; + rpId: string; + attestationConveyancePreference: string; + authenticatorAttachment: string; + requireResidentKey: string; + userVerificationRequirement: string; + createTimeout: string; + excludeCredentialIds: string; + isSetRetry?: boolean; + isAppInitiatedAction?: boolean; + }; + export type LoginUpdatePassword = Common & { pageId: "login-update-password.ftl"; username: string; diff --git a/src/login/pages/WebauthnAuthenticate.tsx b/src/login/pages/WebauthnAuthenticate.tsx index a8a126b8..582d89df 100644 --- a/src/login/pages/WebauthnAuthenticate.tsx +++ b/src/login/pages/WebauthnAuthenticate.tsx @@ -235,7 +235,7 @@ export default function WebauthnAuthenticate(props: PageProps { assert("webAuthnAuthenticate" in window); - assert(window.webAuthnAuthenticate instanceof Function); + assert(typeof window.webAuthnAuthenticate === "function"); window.webAuthnAuthenticate(); }} autoFocus diff --git a/src/login/pages/WebauthnRegister.tsx b/src/login/pages/WebauthnRegister.tsx new file mode 100644 index 00000000..6df1c4f9 --- /dev/null +++ b/src/login/pages/WebauthnRegister.tsx @@ -0,0 +1,285 @@ +import { useEffect } from "react"; +import { clsx } from "keycloakify/tools/clsx"; +import type { PageProps } from "keycloakify/login/pages/PageProps"; +import { useGetClassName } from "keycloakify/login/lib/useGetClassName"; +import { assert } from "tsafe/assert"; +import { createUseInsertScriptTags } from "keycloakify/tools/useInsertScriptTags"; +import type { KcContext } from "../kcContext"; +import type { I18n } from "../i18n"; + +const { useInsertScriptTags } = createUseInsertScriptTags(); + +export default function WebauthnRegister(props: PageProps, I18n>) { + const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; + + const { getClassName } = useGetClassName({ doUseDefaultCss, classes }); + + const { + url, + challenge, + userid, + username, + signatureAlgorithms, + rpEntityName, + rpId, + attestationConveyancePreference, + authenticatorAttachment, + requireResidentKey, + userVerificationRequirement, + createTimeout, + excludeCredentialIds, + isSetRetry, + isAppInitiatedAction + } = kcContext; + + const { msg, msgStr } = i18n; + + const { insertScriptTags } = useInsertScriptTags({ + "scriptTags": [ + { + "type": "text/javascript", + "src": `${url.resourcesCommonPath}/node_modules/jquery/dist/jquery.min.js` + }, + { + "type": "text/javascript", + "src": `${url.resourcesPath}/js/base64url.js` + }, + { + "type": "text/javascript", + "textContent": ` + function registerSecurityKey() { + + // Check if WebAuthn is supported by this browser + if (!window.PublicKeyCredential) { + $("#error").val("${msgStr("webauthn-unsupported-browser-text")}"); + $("#register").submit(); + return; + } + + // mandatory parameters + let challenge = "${challenge}"; + let userid = "${userid}"; + let username = "${username}"; + + let signatureAlgorithms =${JSON.stringify(signatureAlgorithms)}; + let pubKeyCredParams = getPubKeyCredParams(signatureAlgorithms); + + let rpEntityName = "${rpEntityName}"; + let rp = {name: rpEntityName}; + + let publicKey = { + challenge: base64url.decode(challenge, {loose: true}), + rp: rp, + user: { + id: base64url.decode(userid, {loose: true}), + name: username, + displayName: username + }, + pubKeyCredParams: pubKeyCredParams, + }; + + // optional parameters + let rpId = "${rpId}"; + publicKey.rp.id = rpId; + + let attestationConveyancePreference = "${attestationConveyancePreference}"; + if (attestationConveyancePreference !== 'not specified') publicKey.attestation = attestationConveyancePreference; + + let authenticatorSelection = {}; + let isAuthenticatorSelectionSpecified = false; + + let authenticatorAttachment = "${authenticatorAttachment}"; + if (authenticatorAttachment !== 'not specified') { + authenticatorSelection.authenticatorAttachment = authenticatorAttachment; + isAuthenticatorSelectionSpecified = true; + } + + let requireResidentKey = "${requireResidentKey}"; + if (requireResidentKey !== 'not specified') { + if (requireResidentKey === 'Yes') + authenticatorSelection.requireResidentKey = true; + else + authenticatorSelection.requireResidentKey = false; + isAuthenticatorSelectionSpecified = true; + } + + let userVerificationRequirement = "${userVerificationRequirement}"; + if (userVerificationRequirement !== 'not specified') { + authenticatorSelection.userVerification = userVerificationRequirement; + isAuthenticatorSelectionSpecified = true; + } + + if (isAuthenticatorSelectionSpecified) publicKey.authenticatorSelection = authenticatorSelection; + + let createTimeout = ${createTimeout}; + if (createTimeout !== 0) publicKey.timeout = createTimeout * 1000; + + let excludeCredentialIds = "${excludeCredentialIds}"; + let excludeCredentials = getExcludeCredentials(excludeCredentialIds); + if (excludeCredentials.length > 0) publicKey.excludeCredentials = excludeCredentials; + + navigator.credentials.create({publicKey}) + .then(function (result) { + window.result = result; + let clientDataJSON = result.response.clientDataJSON; + let attestationObject = result.response.attestationObject; + let publicKeyCredentialId = result.rawId; + + $("#clientDataJSON").val(base64url.encode(new Uint8Array(clientDataJSON), {pad: false})); + $("#attestationObject").val(base64url.encode(new Uint8Array(attestationObject), {pad: false})); + $("#publicKeyCredentialId").val(base64url.encode(new Uint8Array(publicKeyCredentialId), {pad: false})); + + if (typeof result.response.getTransports === "function") { + let transports = result.response.getTransports(); + if (transports) { + $("#transports").val(getTransportsAsString(transports)); + } + } else { + console.log("Your browser is not able to recognize supported transport media for the authenticator."); + } + + let initLabel = "WebAuthn Authenticator (Default Label)"; + let labelResult = window.prompt("Please input your registered authenticator's label", initLabel); + if (labelResult === null) labelResult = initLabel; + $("#authenticatorLabel").val(labelResult); + + $("#register").submit(); + + }) + .catch(function (err) { + $("#error").val(err); + $("#register").submit(); + + }); + } + + function getPubKeyCredParams(signatureAlgorithmsList) { + let pubKeyCredParams = []; + if (signatureAlgorithmsList === []) { + pubKeyCredParams.push({type: "public-key", alg: -7}); + return pubKeyCredParams; + } + + for (let i = 0; i < signatureAlgorithmsList.length; i++) { + pubKeyCredParams.push({ + type: "public-key", + alg: signatureAlgorithmsList[i] + }); + } + return pubKeyCredParams; + } + + function getExcludeCredentials(excludeCredentialIds) { + let excludeCredentials = []; + if (excludeCredentialIds === "") return excludeCredentials; + + let excludeCredentialIdsList = excludeCredentialIds.split(','); + + for (let i = 0; i < excludeCredentialIdsList.length; i++) { + excludeCredentials.push({ + type: "public-key", + id: base64url.decode(excludeCredentialIdsList[i], + {loose: true}) + }); + } + return excludeCredentials; + } + + function getTransportsAsString(transportsList) { + if (transportsList === '' || transportsList.constructor !== Array) return ""; + + let transportsString = ""; + + for (let i = 0; i < transportsList.length; i++) { + transportsString += transportsList[i] + ","; + } + + return transportsString.slice(0, -1); + } + ` + } + ] + }); + + useEffect(() => { + insertScriptTags(); + }, []); + + return ( + + ); +} + +function LogoutOtherSessions(props: { i18n: I18n; getClassName: ReturnType["getClassName"] }) { + const { getClassName, i18n } = props; + + const { msg } = i18n; + + return ( +
+
+
+ +
+
+
+ ); +}