diff --git a/package.json b/package.json index 7118ff4c..706984c7 100755 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "path-browserify": "^1.0.1", "powerhooks": "^0.20.20", "react-markdown": "^5.0.3", + "rfc4648": "^1.5.2", "scripting-tools": "^0.19.13", "tsafe": "^1.1.1", "tss-react": "^4.3.4", diff --git a/src/bin/keycloakify/generateFtl/generateFtl.ts b/src/bin/keycloakify/generateFtl/generateFtl.ts index 5f7b3263..d600bc60 100644 --- a/src/bin/keycloakify/generateFtl/generateFtl.ts +++ b/src/bin/keycloakify/generateFtl/generateFtl.ts @@ -15,6 +15,7 @@ export const pageIds = [ "login.ftl", "login-username.ftl", "login-password.ftl", + "webauthn-authenticate.ftl", "register.ftl", "register-user-profile.ftl", "info.ftl", diff --git a/src/lib/components/KcApp.tsx b/src/lib/components/KcApp.tsx index f8be458d..0a3dbc09 100644 --- a/src/lib/components/KcApp.tsx +++ b/src/lib/components/KcApp.tsx @@ -15,6 +15,7 @@ const Terms = lazy(() => import("./Terms")); const LoginOtp = lazy(() => import("./LoginOtp")); const LoginPassword = lazy(() => import("./LoginPassword")); const LoginUsername = lazy(() => import("./LoginUsername")); +const WebauthnAuthenticate = lazy(() => import("./WebauthnAuthenticate")); const LoginUpdatePassword = lazy(() => import("./LoginUpdatePassword")); const LoginUpdateProfile = lazy(() => import("./LoginUpdateProfile")); const LoginIdpLinkConfirm = lazy(() => import("./LoginIdpLinkConfirm")); @@ -73,6 +74,8 @@ const KcApp = memo( return ; case "login-password.ftl": return ; + case "webauthn-authenticate.ftl": + return ; case "login-update-password.ftl": return ; case "login-update-profile.ftl": diff --git a/src/lib/components/KcProps.ts b/src/lib/components/KcProps.ts index c38cc5d1..03551616 100644 --- a/src/lib/components/KcProps.ts +++ b/src/lib/components/KcProps.ts @@ -84,6 +84,7 @@ export type KcProps = KcPropsGeneric< | "kcFormSocialAccountDoubleListClass" | "kcFormSocialAccountListLinkClass" | "kcWebAuthnKeyIcon" + | "kcWebAuthnDefaultIcon" | "kcFormClass" | "kcFormGroupErrorClass" | "kcLabelClass" @@ -105,12 +106,16 @@ export type KcProps = KcPropsGeneric< | "kcSrOnlyClass" | "kcSelectAuthListClass" | "kcSelectAuthListItemClass" + | "kcSelectAuthListItemFillClass" | "kcSelectAuthListItemInfoClass" | "kcSelectAuthListItemLeftClass" | "kcSelectAuthListItemBodyClass" | "kcSelectAuthListItemDescriptionClass" | "kcSelectAuthListItemHeadingClass" | "kcSelectAuthListItemHelpTextClass" + | "kcSelectAuthListItemIconPropertyClass" + | "kcSelectAuthListItemIconClass" + | "kcSelectAuthListItemTitle" | "kcAuthenticatorDefaultClass" | "kcAuthenticatorPasswordClass" | "kcAuthenticatorOTPClass" @@ -138,6 +143,7 @@ export const defaultKcProps = { "kcFormSocialAccountDoubleListClass": ["login-pf-social-double-col"], "kcFormSocialAccountListLinkClass": ["login-pf-social-link"], "kcWebAuthnKeyIcon": ["pficon", "pficon-key"], + "kcWebAuthnDefaultIcon": ["pficon", "pficon-key"], "kcFormClass": ["form-horizontal"], "kcFormGroupErrorClass": ["has-error"], @@ -173,6 +179,10 @@ export const defaultKcProps = { // css classes for select-authenticator form "kcSelectAuthListClass": ["list-group", "list-view-pf"], "kcSelectAuthListItemClass": ["list-group-item", "list-view-pf-stacked"], + "kcSelectAuthListItemFillClass": ["pf-l-split__item", "pf-m-fill"], + "kcSelectAuthListItemIconPropertyClass": ["fa-2x", "select-auth-box-icon-properties"], + "kcSelectAuthListItemIconClass": ["pf-l-split__item", "select-auth-box-icon"], + "kcSelectAuthListItemTitle": ["select-auth-box-paragraph"], "kcSelectAuthListItemInfoClass": ["list-view-pf-main-info"], "kcSelectAuthListItemLeftClass": ["list-view-pf-left"], "kcSelectAuthListItemBodyClass": ["list-view-pf-body"], diff --git a/src/lib/components/WebauthnAuthenticate.tsx b/src/lib/components/WebauthnAuthenticate.tsx new file mode 100644 index 00000000..08d7ea3d --- /dev/null +++ b/src/lib/components/WebauthnAuthenticate.tsx @@ -0,0 +1,204 @@ +import React, { useRef, useState, memo } from "react"; +import Template from "./Template"; +import type { KcProps } from "./KcProps"; +import type { KcContextBase } from "../getKcContext/KcContextBase"; +import { useCssAndCx } from "../tools/useCssAndCx"; +import type { I18n, MessageKeyBase } from "../i18n"; +import { base64url } from "rfc4648"; +import { useConstCallback } from "powerhooks/useConstCallback"; + +const WebauthnAuthenticate = memo( + ({ + kcContext, + i18n, + doFetchDefaultThemeResources = true, + ...props + }: { kcContext: KcContextBase.WebauthnAuthenticate; i18n: I18n; doFetchDefaultThemeResources?: boolean } & KcProps) => { + const { url } = kcContext; + + const { msg, msgStr } = i18n; + + const { authenticators, challenge, shouldDisplayAuthenticators, userVerification, rpId } = kcContext; + const createTimeout = Number(kcContext.createTimeout); + const isUserIdentified = kcContext.isUserIdentified == "true"; + + const { cx } = useCssAndCx(); + + const webAuthnAuthenticate = useConstCallback(async () => { + if (!isUserIdentified) { + return; + } + const allowCredentials = authenticators.authenticators.map( + authenticator => + ({ + id: base64url.parse(authenticator.credentialId, { loose: true }), + type: "public-key" + } as PublicKeyCredentialDescriptor) + ); + // Check if WebAuthn is supported by this browser + if (!window.PublicKeyCredential) { + setError(msgStr("webauthn-unsupported-browser-text")); + submitForm(); + return; + } + + const publicKey: PublicKeyCredentialRequestOptions = { + rpId, + challenge: base64url.parse(challenge, { loose: true }) + }; + + if (createTimeout !== 0) { + publicKey.timeout = createTimeout * 1000; + } + + if (allowCredentials.length) { + publicKey.allowCredentials = allowCredentials; + } + + if (userVerification !== "not specified") { + publicKey.userVerification = userVerification; + } + + try { + const resultRaw = await navigator.credentials.get({ publicKey }); + if (!resultRaw || resultRaw.type != "public-key") return; + const result = resultRaw as PublicKeyCredential; + if (!("authenticatorData" in result.response)) return; + const response = result.response as AuthenticatorAssertionResponse; + const clientDataJSON = response.clientDataJSON; + const authenticatorData = response.authenticatorData; + const signature = response.signature; + + setClientDataJSON(base64url.stringify(new Uint8Array(clientDataJSON), { pad: false })); + setAuthenticatorData(base64url.stringify(new Uint8Array(authenticatorData), { pad: false })); + setSignature(base64url.stringify(new Uint8Array(signature), { pad: false })); + setCredentialId(result.id); + setUserHandle(base64url.stringify(new Uint8Array(response.userHandle!), { pad: false })); + submitForm(); + } catch (err) { + setError(String(err)); + submitForm(); + } + }); + + const webAuthForm = useRef(null); + const submitForm = useConstCallback(() => { + webAuthForm.current!.submit(); + }); + + const [clientDataJSON, setClientDataJSON] = useState(""); + const [authenticatorData, setAuthenticatorData] = useState(""); + const [signature, setSignature] = useState(""); + const [credentialId, setCredentialId] = useState(""); + const [userHandle, setUserHandle] = useState(""); + const [error, setError] = useState(""); + + return ( +