Merge pull request #185 from Mstrodl/feature/mstrodl/webauthn-authenticate
Add support for `webauthn-authenticate.ftl`
This commit is contained in:
@ -85,6 +85,7 @@
|
|||||||
"path-browserify": "^1.0.1",
|
"path-browserify": "^1.0.1",
|
||||||
"powerhooks": "^0.20.20",
|
"powerhooks": "^0.20.20",
|
||||||
"react-markdown": "^5.0.3",
|
"react-markdown": "^5.0.3",
|
||||||
|
"rfc4648": "^1.5.2",
|
||||||
"scripting-tools": "^0.19.13",
|
"scripting-tools": "^0.19.13",
|
||||||
"tsafe": "^1.1.1",
|
"tsafe": "^1.1.1",
|
||||||
"tss-react": "^4.3.4",
|
"tss-react": "^4.3.4",
|
||||||
|
@ -15,6 +15,7 @@ export const pageIds = [
|
|||||||
"login.ftl",
|
"login.ftl",
|
||||||
"login-username.ftl",
|
"login-username.ftl",
|
||||||
"login-password.ftl",
|
"login-password.ftl",
|
||||||
|
"webauthn-authenticate.ftl",
|
||||||
"register.ftl",
|
"register.ftl",
|
||||||
"register-user-profile.ftl",
|
"register-user-profile.ftl",
|
||||||
"info.ftl",
|
"info.ftl",
|
||||||
|
@ -15,6 +15,7 @@ const Terms = lazy(() => import("./Terms"));
|
|||||||
const LoginOtp = lazy(() => import("./LoginOtp"));
|
const LoginOtp = lazy(() => import("./LoginOtp"));
|
||||||
const LoginPassword = lazy(() => import("./LoginPassword"));
|
const LoginPassword = lazy(() => import("./LoginPassword"));
|
||||||
const LoginUsername = lazy(() => import("./LoginUsername"));
|
const LoginUsername = lazy(() => import("./LoginUsername"));
|
||||||
|
const WebauthnAuthenticate = lazy(() => import("./WebauthnAuthenticate"));
|
||||||
const LoginUpdatePassword = lazy(() => import("./LoginUpdatePassword"));
|
const LoginUpdatePassword = lazy(() => import("./LoginUpdatePassword"));
|
||||||
const LoginUpdateProfile = lazy(() => import("./LoginUpdateProfile"));
|
const LoginUpdateProfile = lazy(() => import("./LoginUpdateProfile"));
|
||||||
const LoginIdpLinkConfirm = lazy(() => import("./LoginIdpLinkConfirm"));
|
const LoginIdpLinkConfirm = lazy(() => import("./LoginIdpLinkConfirm"));
|
||||||
@ -73,6 +74,8 @@ const KcApp = memo(
|
|||||||
return <LoginUsername {...{ kcContext, ...props }} />;
|
return <LoginUsername {...{ kcContext, ...props }} />;
|
||||||
case "login-password.ftl":
|
case "login-password.ftl":
|
||||||
return <LoginPassword {...{ kcContext, ...props }} />;
|
return <LoginPassword {...{ kcContext, ...props }} />;
|
||||||
|
case "webauthn-authenticate.ftl":
|
||||||
|
return <WebauthnAuthenticate {...{ kcContext, ...props }} />;
|
||||||
case "login-update-password.ftl":
|
case "login-update-password.ftl":
|
||||||
return <LoginUpdatePassword {...{ kcContext, ...props }} />;
|
return <LoginUpdatePassword {...{ kcContext, ...props }} />;
|
||||||
case "login-update-profile.ftl":
|
case "login-update-profile.ftl":
|
||||||
|
@ -84,6 +84,7 @@ export type KcProps = KcPropsGeneric<
|
|||||||
| "kcFormSocialAccountDoubleListClass"
|
| "kcFormSocialAccountDoubleListClass"
|
||||||
| "kcFormSocialAccountListLinkClass"
|
| "kcFormSocialAccountListLinkClass"
|
||||||
| "kcWebAuthnKeyIcon"
|
| "kcWebAuthnKeyIcon"
|
||||||
|
| "kcWebAuthnDefaultIcon"
|
||||||
| "kcFormClass"
|
| "kcFormClass"
|
||||||
| "kcFormGroupErrorClass"
|
| "kcFormGroupErrorClass"
|
||||||
| "kcLabelClass"
|
| "kcLabelClass"
|
||||||
@ -105,12 +106,16 @@ export type KcProps = KcPropsGeneric<
|
|||||||
| "kcSrOnlyClass"
|
| "kcSrOnlyClass"
|
||||||
| "kcSelectAuthListClass"
|
| "kcSelectAuthListClass"
|
||||||
| "kcSelectAuthListItemClass"
|
| "kcSelectAuthListItemClass"
|
||||||
|
| "kcSelectAuthListItemFillClass"
|
||||||
| "kcSelectAuthListItemInfoClass"
|
| "kcSelectAuthListItemInfoClass"
|
||||||
| "kcSelectAuthListItemLeftClass"
|
| "kcSelectAuthListItemLeftClass"
|
||||||
| "kcSelectAuthListItemBodyClass"
|
| "kcSelectAuthListItemBodyClass"
|
||||||
| "kcSelectAuthListItemDescriptionClass"
|
| "kcSelectAuthListItemDescriptionClass"
|
||||||
| "kcSelectAuthListItemHeadingClass"
|
| "kcSelectAuthListItemHeadingClass"
|
||||||
| "kcSelectAuthListItemHelpTextClass"
|
| "kcSelectAuthListItemHelpTextClass"
|
||||||
|
| "kcSelectAuthListItemIconPropertyClass"
|
||||||
|
| "kcSelectAuthListItemIconClass"
|
||||||
|
| "kcSelectAuthListItemTitle"
|
||||||
| "kcAuthenticatorDefaultClass"
|
| "kcAuthenticatorDefaultClass"
|
||||||
| "kcAuthenticatorPasswordClass"
|
| "kcAuthenticatorPasswordClass"
|
||||||
| "kcAuthenticatorOTPClass"
|
| "kcAuthenticatorOTPClass"
|
||||||
@ -138,6 +143,7 @@ export const defaultKcProps = {
|
|||||||
"kcFormSocialAccountDoubleListClass": ["login-pf-social-double-col"],
|
"kcFormSocialAccountDoubleListClass": ["login-pf-social-double-col"],
|
||||||
"kcFormSocialAccountListLinkClass": ["login-pf-social-link"],
|
"kcFormSocialAccountListLinkClass": ["login-pf-social-link"],
|
||||||
"kcWebAuthnKeyIcon": ["pficon", "pficon-key"],
|
"kcWebAuthnKeyIcon": ["pficon", "pficon-key"],
|
||||||
|
"kcWebAuthnDefaultIcon": ["pficon", "pficon-key"],
|
||||||
|
|
||||||
"kcFormClass": ["form-horizontal"],
|
"kcFormClass": ["form-horizontal"],
|
||||||
"kcFormGroupErrorClass": ["has-error"],
|
"kcFormGroupErrorClass": ["has-error"],
|
||||||
@ -173,6 +179,10 @@ export const defaultKcProps = {
|
|||||||
// css classes for select-authenticator form
|
// css classes for select-authenticator form
|
||||||
"kcSelectAuthListClass": ["list-group", "list-view-pf"],
|
"kcSelectAuthListClass": ["list-group", "list-view-pf"],
|
||||||
"kcSelectAuthListItemClass": ["list-group-item", "list-view-pf-stacked"],
|
"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"],
|
"kcSelectAuthListItemInfoClass": ["list-view-pf-main-info"],
|
||||||
"kcSelectAuthListItemLeftClass": ["list-view-pf-left"],
|
"kcSelectAuthListItemLeftClass": ["list-view-pf-left"],
|
||||||
"kcSelectAuthListItemBodyClass": ["list-view-pf-body"],
|
"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 { assert } from "tsafe/assert";
|
||||||
import type { Equals } from "tsafe";
|
import type { Equals } from "tsafe";
|
||||||
import type { MessageKeyBase } from "../i18n";
|
import type { MessageKeyBase } from "../i18n";
|
||||||
|
import type { KcTemplateClassKey } from "../components/KcProps";
|
||||||
|
|
||||||
type ExtractAfterStartingWith<Prefix extends string, StrEnum> = StrEnum extends `${Prefix}${infer U}` ? U : never;
|
type ExtractAfterStartingWith<Prefix extends string, StrEnum> = StrEnum extends `${Prefix}${infer U}` ? U : never;
|
||||||
|
|
||||||
@ -20,6 +21,7 @@ export type KcContextBase =
|
|||||||
| KcContextBase.Terms
|
| KcContextBase.Terms
|
||||||
| KcContextBase.LoginOtp
|
| KcContextBase.LoginOtp
|
||||||
| KcContextBase.LoginUsername
|
| KcContextBase.LoginUsername
|
||||||
|
| KcContextBase.WebauthnAuthenticate
|
||||||
| KcContextBase.LoginPassword
|
| KcContextBase.LoginPassword
|
||||||
| KcContextBase.LoginUpdatePassword
|
| KcContextBase.LoginUpdatePassword
|
||||||
| KcContextBase.LoginUpdateProfile
|
| KcContextBase.LoginUpdateProfile
|
||||||
@ -31,6 +33,16 @@ export type KcContextBase =
|
|||||||
| KcContextBase.UpdateUserProfile
|
| KcContextBase.UpdateUserProfile
|
||||||
| KcContextBase.IdpReviewUserProfile;
|
| KcContextBase.IdpReviewUserProfile;
|
||||||
|
|
||||||
|
export type WebauthnAuthenticator = {
|
||||||
|
credentialId: string;
|
||||||
|
transports: {
|
||||||
|
iconClass: KcTemplateClassKey;
|
||||||
|
displayNameProperties: MessageKeyBase[];
|
||||||
|
};
|
||||||
|
label: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
export declare namespace KcContextBase {
|
export declare namespace KcContextBase {
|
||||||
export type Common = {
|
export type Common = {
|
||||||
url: {
|
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 & {
|
export type LoginUpdatePassword = Common & {
|
||||||
pageId: "login-update-password.ftl";
|
pageId: "login-update-password.ftl";
|
||||||
username: string;
|
username: string;
|
||||||
|
@ -393,6 +393,27 @@ export const kcContextMocks: KcContextBase[] = [
|
|||||||
},
|
},
|
||||||
"login": {}
|
"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>({
|
id<KcContextBase.LoginUpdatePassword>({
|
||||||
...kcContextCommonMock,
|
...kcContextCommonMock,
|
||||||
"pageId": "login-update-password.ftl",
|
"pageId": "login-update-password.ftl",
|
||||||
|
@ -1765,6 +1765,11 @@ restore-cursor@^3.1.0:
|
|||||||
onetime "^5.1.0"
|
onetime "^5.1.0"
|
||||||
signal-exit "^3.0.2"
|
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:
|
rfdc@^1.3.0:
|
||||||
version "1.3.0"
|
version "1.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.0.tgz#d0b7c441ab2720d05dc4cf26e01c89631d9da08b"
|
resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.0.tgz#d0b7c441ab2720d05dc4cf26e01c89631d9da08b"
|
||||||
|
Reference in New Issue
Block a user