mirror of
https://github.com/inventree/InvenTree.git
synced 2025-10-29 20:30:39 +00:00
feat(frontend): add passkey/webauthn for secondary MFA (#9729)
* bump allauth * add trust * add device trust handling * fix style * [FR] Add passkey as a factor Fixes #4002 * add registration * allow better testing * add mfa context * fix login * add changelog entry * fix registration * remove multi device packages * move to helper * handle mfa trust * simplify page fnc
This commit is contained in:
@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Adds optional shipping address against individual sales order shipments in [#10650](https://github.com/inventree/InvenTree/pull/10650)
|
- Adds optional shipping address against individual sales order shipments in [#10650](https://github.com/inventree/InvenTree/pull/10650)
|
||||||
- Adds UI elements to "check" and "uncheck" sales order shipments in [#10654](https://github.com/inventree/InvenTree/pull/10654)
|
- Adds UI elements to "check" and "uncheck" sales order shipments in [#10654](https://github.com/inventree/InvenTree/pull/10654)
|
||||||
- Allow assigning project codes to order line items in [#10657](https://github.com/inventree/InvenTree/pull/10657)
|
- Allow assigning project codes to order line items in [#10657](https://github.com/inventree/InvenTree/pull/10657)
|
||||||
|
- Added support for webauthn login for the frontend in [#9729](https://github.com/inventree/InvenTree/pull/9729)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -1386,10 +1386,12 @@ MFA_ENABLED = get_boolean_setting(
|
|||||||
MFA_SUPPORTED_TYPES = get_setting(
|
MFA_SUPPORTED_TYPES = get_setting(
|
||||||
'INVENTREE_MFA_SUPPORTED_TYPES',
|
'INVENTREE_MFA_SUPPORTED_TYPES',
|
||||||
'mfa_supported_types',
|
'mfa_supported_types',
|
||||||
['totp', 'recovery_codes'],
|
['totp', 'recovery_codes', 'webauthn'],
|
||||||
typecast=list,
|
typecast=list,
|
||||||
)
|
)
|
||||||
MFA_TRUST_ENABLED = True
|
MFA_TRUST_ENABLED = True
|
||||||
|
MFA_PASSKEY_LOGIN_ENABLED = True
|
||||||
|
MFA_WEBAUTHN_ALLOW_INSECURE_ORIGIN = DEBUG
|
||||||
|
|
||||||
LOGOUT_REDIRECT_URL = get_setting(
|
LOGOUT_REDIRECT_URL = get_setting(
|
||||||
'INVENTREE_LOGOUT_REDIRECT_URL', 'logout_redirect_url', 'index'
|
'INVENTREE_LOGOUT_REDIRECT_URL', 'logout_redirect_url', 'index'
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export enum ApiEndpoints {
|
|||||||
auth_mfa_reauthenticate = 'auth/v1/auth/2fa/reauthenticate',
|
auth_mfa_reauthenticate = 'auth/v1/auth/2fa/reauthenticate',
|
||||||
auth_totp = 'auth/v1/account/authenticators/totp',
|
auth_totp = 'auth/v1/account/authenticators/totp',
|
||||||
auth_trust = 'auth/v1/auth/2fa/trust',
|
auth_trust = 'auth/v1/auth/2fa/trust',
|
||||||
|
auth_webauthn = 'auth/v1/account/authenticators/webauthn',
|
||||||
|
auth_webauthn_login = 'auth/v1/auth/webauthn/authenticate',
|
||||||
auth_reauthenticate = 'auth/v1/auth/reauthenticate',
|
auth_reauthenticate = 'auth/v1/auth/reauthenticate',
|
||||||
auth_email = 'auth/v1/account/email',
|
auth_email = 'auth/v1/account/email',
|
||||||
auth_email_verify = 'auth/v1/auth/email/verify',
|
auth_email_verify = 'auth/v1/auth/email/verify',
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
"@fullcalendar/daygrid": "^6.1.15",
|
"@fullcalendar/daygrid": "^6.1.15",
|
||||||
"@fullcalendar/interaction": "^6.1.15",
|
"@fullcalendar/interaction": "^6.1.15",
|
||||||
"@fullcalendar/react": "^6.1.15",
|
"@fullcalendar/react": "^6.1.15",
|
||||||
|
"@github/webauthn-json": "^2.1.1",
|
||||||
"@lingui/core": "^5.3.1",
|
"@lingui/core": "^5.3.1",
|
||||||
"@lingui/react": "^5.3.1",
|
"@lingui/react": "^5.3.1",
|
||||||
"@mantine/carousel": "^8.2.7",
|
"@mantine/carousel": "^8.2.7",
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import {
|
||||||
|
type CredentialRequestOptionsJSON,
|
||||||
|
get,
|
||||||
|
parseRequestOptionsFromJSON
|
||||||
|
} from '@github/webauthn-json/browser-ponyfill';
|
||||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||||
import { apiUrl } from '@lib/functions/Api';
|
import { apiUrl } from '@lib/functions/Api';
|
||||||
import { type AuthProvider, FlowEnum } from '@lib/types/Auth';
|
import { type AuthProvider, FlowEnum } from '@lib/types/Auth';
|
||||||
@@ -71,7 +76,7 @@ export async function doBasicLogin(
|
|||||||
const { getHost } = useLocalState.getState();
|
const { getHost } = useLocalState.getState();
|
||||||
const { clearUserState, setAuthenticated, fetchUserState } =
|
const { clearUserState, setAuthenticated, fetchUserState } =
|
||||||
useUserState.getState();
|
useUserState.getState();
|
||||||
const { setAuthContext } = useServerApiState.getState();
|
const { setAuthContext, setMfaContext } = useServerApiState.getState();
|
||||||
|
|
||||||
if (username.length == 0 || password.length == 0) {
|
if (username.length == 0 || password.length == 0) {
|
||||||
return;
|
return;
|
||||||
@@ -157,6 +162,7 @@ export async function doBasicLogin(
|
|||||||
(flow: any) => flow.id == FlowEnum.MfaAuthenticate
|
(flow: any) => flow.id == FlowEnum.MfaAuthenticate
|
||||||
);
|
);
|
||||||
if (mfa_flow?.is_pending) {
|
if (mfa_flow?.is_pending) {
|
||||||
|
setMfaContext(mfa_flow);
|
||||||
// MFA is required - we might already have a code
|
// MFA is required - we might already have a code
|
||||||
if (code && code.length > 0) {
|
if (code && code.length > 0) {
|
||||||
const rslt = await handleMfaLogin(
|
const rslt = await handleMfaLogin(
|
||||||
@@ -680,3 +686,57 @@ export function handleChangePassword(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function handleWebauthnLogin(
|
||||||
|
navigate?: NavigateFunction,
|
||||||
|
location?: Location<any>
|
||||||
|
) {
|
||||||
|
const { setAuthContext } = useServerApiState.getState();
|
||||||
|
|
||||||
|
const webauthn_challenge = api
|
||||||
|
.get(apiUrl(ApiEndpoints.auth_webauthn_login))
|
||||||
|
.catch(() => {})
|
||||||
|
.then((response) => {
|
||||||
|
if (response && response.status === 200) {
|
||||||
|
return response.data.data.request_options;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!webauthn_challenge) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const credential = await get(
|
||||||
|
parseRequestOptionsFromJSON(
|
||||||
|
webauthn_challenge as CredentialRequestOptionsJSON
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await api
|
||||||
|
.post(apiUrl(ApiEndpoints.auth_webauthn_login), {
|
||||||
|
credential: credential
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
if (response.status === 200) {
|
||||||
|
handleSuccessFullAuth(response, navigate, location, undefined);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (err?.response?.status == 401) {
|
||||||
|
const mfa_trust = err.response.data.data.flows.find(
|
||||||
|
(flow: any) => flow.id == FlowEnum.MfaTrust
|
||||||
|
);
|
||||||
|
if (mfa_trust?.is_pending) {
|
||||||
|
setAuthContext(err.response.data.data);
|
||||||
|
authApi(apiUrl(ApiEndpoints.auth_trust), undefined, 'post', {
|
||||||
|
trust: false
|
||||||
|
}).then((response) => {
|
||||||
|
handleSuccessFullAuth(response, navigate, location, undefined);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import { t } from '@lingui/core/macro';
|
|||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { Button, Checkbox, TextInput } from '@mantine/core';
|
import { Button, Checkbox, TextInput } from '@mantine/core';
|
||||||
import { useForm } from '@mantine/form';
|
import { useForm } from '@mantine/form';
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { handleMfaLogin } from '../../functions/auth';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
import { handleMfaLogin, handleWebauthnLogin } from '../../functions/auth';
|
||||||
|
import { useServerApiState } from '../../states/ServerApiState';
|
||||||
import { Wrapper } from './Layout';
|
import { Wrapper } from './Layout';
|
||||||
|
|
||||||
export default function Mfa() {
|
export default function Mfa() {
|
||||||
@@ -12,17 +14,30 @@ export default function Mfa() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [loginError, setLoginError] = useState<string | undefined>(undefined);
|
const [loginError, setLoginError] = useState<string | undefined>(undefined);
|
||||||
|
const [mfa_context] = useServerApiState(
|
||||||
|
useShallow((state) => [state.mfa_context])
|
||||||
|
);
|
||||||
|
const mfa_types = mfa_context?.types || [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mfa_types.includes('webauthn') || mfa_types.includes('webauthn_2fa')) {
|
||||||
|
handleWebauthnLogin(navigate, location);
|
||||||
|
}
|
||||||
|
}, [mfa_types]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Wrapper titleText={t`Multi-Factor Authentication`} logOff>
|
<Wrapper titleText={t`Multi-Factor Authentication`} logOff>
|
||||||
<TextInput
|
{(mfa_types.includes('recovery_codes') || mfa_types.includes('totp')) && (
|
||||||
required
|
<TextInput
|
||||||
label={t`TOTP Code`}
|
required
|
||||||
name='TOTP'
|
label={t`TOTP Code`}
|
||||||
description={t`Enter your TOTP or recovery code`}
|
name='TOTP'
|
||||||
{...simpleForm.getInputProps('code')}
|
description={t`Enter one of your codes: ${mfa_types}`}
|
||||||
error={loginError}
|
{...simpleForm.getInputProps('code')}
|
||||||
/>
|
error={loginError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label={t`Remember this device`}
|
label={t`Remember this device`}
|
||||||
name='remember'
|
name='remember'
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { create } from '@github/webauthn-json/browser-ponyfill';
|
||||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||||
import { apiUrl } from '@lib/functions/Api';
|
import { apiUrl } from '@lib/functions/Api';
|
||||||
import { type AuthConfig, type AuthProvider, FlowEnum } from '@lib/types/Auth';
|
import { type AuthConfig, type AuthProvider, FlowEnum } from '@lib/types/Auth';
|
||||||
@@ -449,6 +450,18 @@ function MfaSection() {
|
|||||||
getReauthText
|
getReauthText
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
const removeWebauthn = (code: string) => {
|
||||||
|
runActionWithFallback(
|
||||||
|
() =>
|
||||||
|
authApi(apiUrl(ApiEndpoints.auth_webauthn), undefined, 'delete', {
|
||||||
|
authenticators: [code]
|
||||||
|
}).then(() => {
|
||||||
|
refetch();
|
||||||
|
return ResultType.success;
|
||||||
|
}),
|
||||||
|
getReauthText
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
if (isLoading || !data) return null;
|
if (isLoading || !data) return null;
|
||||||
@@ -468,6 +481,16 @@ function MfaSection() {
|
|||||||
<Trans>View</Trans>
|
<Trans>View</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{token.type == 'webauthn' && (
|
||||||
|
<Button
|
||||||
|
color='red'
|
||||||
|
onClick={() => {
|
||||||
|
removeWebauthn(token.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trans>Remove</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
));
|
));
|
||||||
@@ -619,6 +642,62 @@ function MfaAddSection({
|
|||||||
getReauthText
|
getReauthText
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
const registerWebauthn = async () => {
|
||||||
|
let data: any = {};
|
||||||
|
await runActionWithFallback(
|
||||||
|
() =>
|
||||||
|
authApi(apiUrl(ApiEndpoints.auth_webauthn), undefined, 'get').then(
|
||||||
|
(res) => {
|
||||||
|
data = res.data?.data;
|
||||||
|
if (data?.creation_options) {
|
||||||
|
return ResultType.success;
|
||||||
|
} else {
|
||||||
|
return ResultType.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
getReauthText
|
||||||
|
);
|
||||||
|
if (data?.creation_options == undefined) {
|
||||||
|
showNotification({
|
||||||
|
title: t`Error while registering WebAuthn authenticator`,
|
||||||
|
message: t`Please reload page and try again.`,
|
||||||
|
color: 'red',
|
||||||
|
icon: <IconX />
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// register the webauthn authenticator with the browser
|
||||||
|
const resp = await create({
|
||||||
|
publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(
|
||||||
|
data.creation_options.publicKey
|
||||||
|
)
|
||||||
|
});
|
||||||
|
authApi(apiUrl(ApiEndpoints.auth_webauthn), undefined, 'post', {
|
||||||
|
name: 'Master Key',
|
||||||
|
credential: JSON.stringify(resp)
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
showNotification({
|
||||||
|
title: t`WebAuthn authenticator registered successfully`,
|
||||||
|
message: t`You can now use this authenticator for multi-factor authentication.`,
|
||||||
|
color: 'green'
|
||||||
|
});
|
||||||
|
refetch();
|
||||||
|
return ResultType.success;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
showNotification({
|
||||||
|
title: t`Error while registering WebAuthn authenticator`,
|
||||||
|
message: err.response.data.errors
|
||||||
|
.map((error: any) => error.message)
|
||||||
|
.join('\n'),
|
||||||
|
color: 'red',
|
||||||
|
icon: <IconX />
|
||||||
|
});
|
||||||
|
return ResultType.error;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const possibleFactors = useMemo(() => {
|
const possibleFactors = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
@@ -635,6 +714,13 @@ function MfaAddSection({
|
|||||||
description: t`One-Time pre-generated recovery codes`,
|
description: t`One-Time pre-generated recovery codes`,
|
||||||
function: registerRecoveryCodes,
|
function: registerRecoveryCodes,
|
||||||
used: usedFactors?.includes('recovery_codes')
|
used: usedFactors?.includes('recovery_codes')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'webauthn',
|
||||||
|
name: t`WebAuthn`,
|
||||||
|
description: t`Web Authentication (WebAuthn) is a web standard for secure authentication`,
|
||||||
|
function: registerWebauthn,
|
||||||
|
used: usedFactors?.includes('webauthn')
|
||||||
}
|
}
|
||||||
].filter((factor) => {
|
].filter((factor) => {
|
||||||
return auth_config?.mfa?.supported_types.includes(factor.type);
|
return auth_config?.mfa?.supported_types.includes(factor.type);
|
||||||
@@ -718,16 +804,18 @@ async function runActionWithFallback(
|
|||||||
action: () => Promise<ResultType>,
|
action: () => Promise<ResultType>,
|
||||||
getReauthText: (props: any) => any
|
getReauthText: (props: any) => any
|
||||||
) {
|
) {
|
||||||
const { setAuthContext } = useServerApiState.getState();
|
const { setAuthContext, setMfaContext, mfa_context } =
|
||||||
|
useServerApiState.getState();
|
||||||
|
|
||||||
const result = await action().catch((err) => {
|
const result = await action().catch((err) => {
|
||||||
setAuthContext(err.response.data?.data);
|
setAuthContext(err.response.data?.data);
|
||||||
// check if we need to re-authenticate
|
// check if we need to re-authenticate
|
||||||
if (err.status == 401) {
|
if (err.status == 401) {
|
||||||
if (
|
const mfaFlow = err.response.data.data.flows.find(
|
||||||
err.response.data.data.flows.find(
|
(flow: any) => flow.id == FlowEnum.MfaReauthenticate
|
||||||
(flow: any) => flow.id == FlowEnum.MfaReauthenticate
|
);
|
||||||
)
|
if (mfaFlow) {
|
||||||
) {
|
setMfaContext(mfaFlow);
|
||||||
return ResultType.mfareauth;
|
return ResultType.mfareauth;
|
||||||
} else if (
|
} else if (
|
||||||
err.response.data.data.flows.find(
|
err.response.data.data.flows.find(
|
||||||
@@ -742,13 +830,18 @@ async function runActionWithFallback(
|
|||||||
return ResultType.error;
|
return ResultType.error;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// run the re-authentication flows as needed
|
||||||
if (result == ResultType.mfareauth) {
|
if (result == ResultType.mfareauth) {
|
||||||
|
const mfa_types = mfa_context?.types || [];
|
||||||
|
const mfaCode = await getReauthText({
|
||||||
|
label: t`TOTP Code`,
|
||||||
|
name: 'TOTP',
|
||||||
|
description: t`Enter one of your codes: ${mfa_types}`
|
||||||
|
});
|
||||||
|
|
||||||
authApi(apiUrl(ApiEndpoints.auth_mfa_reauthenticate), undefined, 'post', {
|
authApi(apiUrl(ApiEndpoints.auth_mfa_reauthenticate), undefined, 'post', {
|
||||||
code: await getReauthText({
|
code: mfaCode
|
||||||
label: t`TOTP Code`,
|
|
||||||
name: 'TOTP',
|
|
||||||
description: t`Enter your TOTP or recovery code`
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
setAuthContext(response.data?.data);
|
setAuthContext(response.data?.data);
|
||||||
@@ -758,12 +851,14 @@ async function runActionWithFallback(
|
|||||||
setAuthContext(err.response.data?.data);
|
setAuthContext(err.response.data?.data);
|
||||||
});
|
});
|
||||||
} else if (result == ResultType.reauth) {
|
} else if (result == ResultType.reauth) {
|
||||||
|
const passwordInput = await getReauthText({
|
||||||
|
label: t`Password`,
|
||||||
|
name: 'password',
|
||||||
|
description: t`Enter your password`
|
||||||
|
});
|
||||||
|
|
||||||
authApi(apiUrl(ApiEndpoints.auth_reauthenticate), undefined, 'post', {
|
authApi(apiUrl(ApiEndpoints.auth_reauthenticate), undefined, 'post', {
|
||||||
password: await getReauthText({
|
password: passwordInput
|
||||||
label: t`Password`,
|
|
||||||
name: 'password',
|
|
||||||
description: t`Enter your password`
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
setAuthContext(response.data?.data);
|
setAuthContext(response.data?.data);
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ interface ServerApiStateProps {
|
|||||||
auth_config?: AuthConfig;
|
auth_config?: AuthConfig;
|
||||||
auth_context?: AuthContext;
|
auth_context?: AuthContext;
|
||||||
setAuthContext: (auth_context: AuthContext | undefined) => void;
|
setAuthContext: (auth_context: AuthContext | undefined) => void;
|
||||||
|
mfa_context?: any;
|
||||||
|
setMfaContext: (mfa_context: any) => void;
|
||||||
|
// Helper functions
|
||||||
sso_enabled: () => boolean;
|
sso_enabled: () => boolean;
|
||||||
registration_enabled: () => boolean;
|
registration_enabled: () => boolean;
|
||||||
sso_registration_enabled: () => boolean;
|
sso_registration_enabled: () => boolean;
|
||||||
@@ -61,6 +64,10 @@ export const useServerApiState = create<ServerApiStateProps>()(
|
|||||||
setAuthContext(auth_context) {
|
setAuthContext(auth_context) {
|
||||||
set({ auth_context });
|
set({ auth_context });
|
||||||
},
|
},
|
||||||
|
mfa_context: undefined,
|
||||||
|
setMfaContext(mfa_context) {
|
||||||
|
set({ mfa_context });
|
||||||
|
},
|
||||||
sso_enabled: () => {
|
sso_enabled: () => {
|
||||||
const data = get().auth_config?.socialaccount.providers;
|
const data = get().auth_config?.socialaccount.providers;
|
||||||
return !(data === undefined || data.length == 0);
|
return !(data === undefined || data.length == 0);
|
||||||
|
|||||||
@@ -888,6 +888,11 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@fullcalendar/react/-/react-6.1.19.tgz#921ecc92972af4c9654e4742dd4a73fd4092c2ae"
|
resolved "https://registry.yarnpkg.com/@fullcalendar/react/-/react-6.1.19.tgz#921ecc92972af4c9654e4742dd4a73fd4092c2ae"
|
||||||
integrity sha512-FP78vnyylaL/btZeHig8LQgfHgfwxLaIG6sKbNkzkPkKEACv11UyyBoTSkaavPsHtXvAkcTED1l7TOunAyPEnA==
|
integrity sha512-FP78vnyylaL/btZeHig8LQgfHgfwxLaIG6sKbNkzkPkKEACv11UyyBoTSkaavPsHtXvAkcTED1l7TOunAyPEnA==
|
||||||
|
|
||||||
|
"@github/webauthn-json@^2.1.1":
|
||||||
|
version "2.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@github/webauthn-json/-/webauthn-json-2.1.1.tgz#648e63fc28050917d2882cc2b27817a88cb420fc"
|
||||||
|
integrity sha512-XrftRn4z75SnaJOmZQbt7Mk+IIjqVHw+glDGOxuHwXkZBZh/MBoRS7MHjSZMDaLhT4RjN2VqiEU7EOYleuJWSQ==
|
||||||
|
|
||||||
"@isaacs/cliui@^8.0.2":
|
"@isaacs/cliui@^8.0.2":
|
||||||
version "8.0.2"
|
version "8.0.2"
|
||||||
resolved "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz"
|
resolved "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz"
|
||||||
|
|||||||
Reference in New Issue
Block a user