Merge pull request #185 from Mstrodl/feature/mstrodl/webauthn-authenticate
Add support for `webauthn-authenticate.ftl`
This commit is contained in:
commit
5e1c5b510b
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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 <LoginUsername {...{ kcContext, ...props }} />;
|
||||
case "login-password.ftl":
|
||||
return <LoginPassword {...{ kcContext, ...props }} />;
|
||||
case "webauthn-authenticate.ftl":
|
||||
return <WebauthnAuthenticate {...{ kcContext, ...props }} />;
|
||||
case "login-update-password.ftl":
|
||||
return <LoginUpdatePassword {...{ kcContext, ...props }} />;
|
||||
case "login-update-profile.ftl":
|
||||
|
@ -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"],
|
||||
|
204
src/lib/components/WebauthnAuthenticate.tsx
Normal file
204
src/lib/components/WebauthnAuthenticate.tsx
Normal file
@ -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<HTMLFormElement>(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 (
|
||||
<Template
|
||||
{...{ kcContext, i18n, doFetchDefaultThemeResources, ...props }}
|
||||
headerNode={msg("webauthn-login-title")}
|
||||
formNode={
|
||||
<div id="kc-form-webauthn" className={cx(props.kcFormClass)}>
|
||||
<form id="webauth" action={url.loginAction} ref={webAuthForm} method="post">
|
||||
<input type="hidden" id="clientDataJSON" name="clientDataJSON" value={clientDataJSON} />
|
||||
<input type="hidden" id="authenticatorData" name="authenticatorData" value={authenticatorData} />
|
||||
<input type="hidden" id="signature" name="signature" value={signature} />
|
||||
<input type="hidden" id="credentialId" name="credentialId" value={credentialId} />
|
||||
<input type="hidden" id="userHandle" name="userHandle" value={userHandle} />
|
||||
<input type="hidden" id="error" name="error" value={error} />
|
||||
</form>
|
||||
<div className={cx(props.kcFormGroupClass)}>
|
||||
{authenticators &&
|
||||
(() => (
|
||||
<form id="authn_select" className={cx(props.kcFormClass)}>
|
||||
{authenticators.authenticators.map(authenticator => (
|
||||
<input
|
||||
type="hidden"
|
||||
name="authn_use_chk"
|
||||
value={authenticator.credentialId}
|
||||
key={authenticator.credentialId}
|
||||
/>
|
||||
))}
|
||||
</form>
|
||||
))()}
|
||||
{authenticators &&
|
||||
shouldDisplayAuthenticators &&
|
||||
(() => (
|
||||
<>
|
||||
{authenticators.authenticators.length > 1 && (
|
||||
<p className={cx(props.kcSelectAuthListItemTitle)}>{msg("webauthn-available-authenticators")}</p>
|
||||
)}
|
||||
<div className={cx(props.kcFormClass)}>
|
||||
{authenticators.authenticators.map(authenticator => (
|
||||
<div id="kc-webauthn-authenticator" className={cx(props.kcSelectAuthListItemClass)}>
|
||||
<div className={cx(props.kcSelectAuthListItemIconClass)}>
|
||||
<i
|
||||
className={cx(
|
||||
props[authenticator.transports.iconClass] ?? props.kcWebAuthnDefaultIcon,
|
||||
props.kcSelectAuthListItemIconPropertyClass
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className={cx(props.kcSelectAuthListItemBodyClass)}>
|
||||
<div
|
||||
id="kc-webauthn-authenticator-label"
|
||||
className={cx(props.kcSelectAuthListItemHeadingClass)}
|
||||
>
|
||||
{authenticator.label}
|
||||
</div>
|
||||
|
||||
{authenticator.transports && authenticator.transports.displayNameProperties.length && (
|
||||
<div
|
||||
id="kc-webauthn-authenticator-transport"
|
||||
className={cx(props.kcSelectAuthListItemDescriptionClass)}
|
||||
>
|
||||
{authenticator.transports.displayNameProperties.map(
|
||||
(transport: MessageKeyBase, index: number) => (
|
||||
<>
|
||||
<span>{msg(transport)}</span>
|
||||
{index < authenticator.transports.displayNameProperties.length - 1 && (
|
||||
<span>{", "}</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={cx(props.kcSelectAuthListItemDescriptionClass)}>
|
||||
<span id="kc-webauthn-authenticator-created-label">
|
||||
{msg("webauthn-createdAt-label")}
|
||||
</span>
|
||||
<span id="kc-webauthn-authenticator-created">{authenticator.createdAt}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={cx(props.kcSelectAuthListItemFillClass)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
))()}
|
||||
<div id="kc-form-buttons" className={cx(props.kcFormButtonsClass)}>
|
||||
<input
|
||||
id="authenticateWebAuthnButton"
|
||||
type="button"
|
||||
onClick={webAuthnAuthenticate}
|
||||
autoFocus={true}
|
||||
value={msgStr("webauthn-doAuthenticate")}
|
||||
className={cx(
|
||||
props.kcButtonClass,
|
||||
props.kcButtonPrimaryClass,
|
||||
props.kcButtonBlockClass,
|
||||
props.kcButtonLargeClass
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default WebauthnAuthenticate;
|
@ -2,6 +2,7 @@ import type { PageId } from "../../bin/keycloakify/generateFtl";
|
||||
import { assert } from "tsafe/assert";
|
||||
import type { Equals } from "tsafe";
|
||||
import type { MessageKeyBase } from "../i18n";
|
||||
import type { KcTemplateClassKey } from "../components/KcProps";
|
||||
|
||||
type ExtractAfterStartingWith<Prefix extends string, StrEnum> = StrEnum extends `${Prefix}${infer U}` ? U : never;
|
||||
|
||||
@ -20,6 +21,7 @@ export type KcContextBase =
|
||||
| KcContextBase.Terms
|
||||
| KcContextBase.LoginOtp
|
||||
| KcContextBase.LoginUsername
|
||||
| KcContextBase.WebauthnAuthenticate
|
||||
| KcContextBase.LoginPassword
|
||||
| KcContextBase.LoginUpdatePassword
|
||||
| KcContextBase.LoginUpdateProfile
|
||||
@ -31,6 +33,16 @@ export type KcContextBase =
|
||||
| KcContextBase.UpdateUserProfile
|
||||
| KcContextBase.IdpReviewUserProfile;
|
||||
|
||||
export type WebauthnAuthenticator = {
|
||||
credentialId: string;
|
||||
transports: {
|
||||
iconClass: KcTemplateClassKey;
|
||||
displayNameProperties: MessageKeyBase[];
|
||||
};
|
||||
label: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export declare namespace KcContextBase {
|
||||
export type Common = {
|
||||
url: {
|
||||
@ -253,6 +265,24 @@ export declare namespace KcContextBase {
|
||||
};
|
||||
};
|
||||
|
||||
export type WebauthnAuthenticate = Common & {
|
||||
pageId: "webauthn-authenticate.ftl";
|
||||
authenticators: {
|
||||
authenticators: WebauthnAuthenticator[];
|
||||
};
|
||||
challenge: string;
|
||||
// I hate this:
|
||||
userVerification: UserVerificationRequirement | "not specified";
|
||||
rpId: string;
|
||||
createTimeout: string;
|
||||
isUserIdentified: "true" | "false";
|
||||
shouldDisplayAuthenticators: boolean;
|
||||
social: {
|
||||
displayInfo: boolean;
|
||||
};
|
||||
login: {};
|
||||
};
|
||||
|
||||
export type LoginUpdatePassword = Common & {
|
||||
pageId: "login-update-password.ftl";
|
||||
username: string;
|
||||
|
@ -393,6 +393,27 @@ export const kcContextMocks: KcContextBase[] = [
|
||||
},
|
||||
"login": {}
|
||||
}),
|
||||
id<KcContextBase.WebauthnAuthenticate>({
|
||||
...kcContextCommonMock,
|
||||
"pageId": "webauthn-authenticate.ftl",
|
||||
"url": loginUrl,
|
||||
"authenticators": {
|
||||
"authenticators": []
|
||||
},
|
||||
"realm": {
|
||||
...kcContextCommonMock.realm
|
||||
},
|
||||
"challenge": "",
|
||||
"userVerification": "not specified",
|
||||
"rpId": "",
|
||||
"createTimeout": "0",
|
||||
"isUserIdentified": "false",
|
||||
"shouldDisplayAuthenticators": false,
|
||||
"social": {
|
||||
"displayInfo": false
|
||||
},
|
||||
"login": {}
|
||||
}),
|
||||
id<KcContextBase.LoginUpdatePassword>({
|
||||
...kcContextCommonMock,
|
||||
"pageId": "login-update-password.ftl",
|
||||
|
@ -1765,6 +1765,11 @@ restore-cursor@^3.1.0:
|
||||
onetime "^5.1.0"
|
||||
signal-exit "^3.0.2"
|
||||
|
||||
rfc4648@^1.5.2:
|
||||
version "1.5.2"
|
||||
resolved "https://registry.yarnpkg.com/rfc4648/-/rfc4648-1.5.2.tgz#cf5dac417dd83e7f4debf52e3797a723c1373383"
|
||||
integrity sha512-tLOizhR6YGovrEBLatX1sdcuhoSCXddw3mqNVAcKxGJ+J0hFeJ+SjeWCv5UPA/WU3YzWPPuCVYgXBKZUPGpKtg==
|
||||
|
||||
rfdc@^1.3.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.0.tgz#d0b7c441ab2720d05dc4cf26e01c89631d9da08b"
|
||||
|
Loading…
x
Reference in New Issue
Block a user