2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-11-13 19:36:46 +00:00

[UI] MFA Refactor (#10775)

* Install otpauth package

* Add separate MFASettings components

* Refresh methods after registering token

* Simplify layout

* Add modal for deleting TOTP code

* Display recovery codes

* Adjust text

* Register webauthn

* Add longer timeouts

* Add workflow for removing webauthn

* Cleanup SecurityContext.tsx

* Add playwright testing for TOTP registration

* Spelling fixes

* Delete unused file

* Better clipboard copy
This commit is contained in:
Oliver
2025-11-05 22:54:47 +11:00
committed by GitHub
parent d12102ba96
commit 2dfe6b5f41
8 changed files with 1115 additions and 604 deletions

View File

@@ -125,6 +125,7 @@
"@vitejs/plugin-react": "^5.0.2", "@vitejs/plugin-react": "^5.0.2",
"babel-plugin-macros": "^3.1.0", "babel-plugin-macros": "^3.1.0",
"nyc": "^17.1.0", "nyc": "^17.1.0",
"otpauth": "^9.4.1",
"path": "^0.12.7", "path": "^0.12.7",
"rollup": "^4.0.0", "rollup": "^4.0.0",
"rollup-plugin-license": "^3.5.3", "rollup-plugin-license": "^3.5.3",

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { Divider, Text, TextInput } from '@mantine/core'; import { Divider, Group, Paper, Stack, Text, TextInput } from '@mantine/core';
import { QRCode } from '../../../../components/barcodes/QRCode'; import { QRCode } from '../../../../components/barcodes/QRCode';
import { CopyButton } from '../../../../components/buttons/CopyButton';
export function QrRegistrationForm({ export function QrRegistrationForm({
url, url,
@@ -17,22 +18,32 @@ export function QrRegistrationForm({
setValue: (value: string) => void; setValue: (value: string) => void;
}>) { }>) {
return ( return (
<> <Stack gap='xs'>
<Divider /> <Divider />
<QRCode data={url} /> <QRCode data={url} />
<Text> <Paper withBorder p='sm' aria-label='otp-secret-container'>
<Trans>Secret</Trans> <Stack gap='xs'>
<br /> <Text>
{secret} <Trans>Secret</Trans>
</Text> </Text>
<Group justify='space-between'>
<Text size='sm' aria-label='otp-secret'>
{secret}
</Text>
<CopyButton value={secret} />
</Group>
</Stack>
</Paper>
<TextInput <TextInput
required required
aria-label={'text-input-otp-code'}
label={t`One-Time Password`} label={t`One-Time Password`}
description={t`Enter the TOTP code to ensure it registered correctly`} description={t`Enter the TOTP code to ensure it registered correctly`}
value={value} value={value}
onChange={(event) => setValue(event.currentTarget.value)} onChange={(event) => setValue(event.currentTarget.value)}
error={error} error={error}
/> />
</> <Divider />
</Stack>
); );
} }

View File

@@ -1,7 +1,6 @@
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, AuthProvider } from '@lib/types/Auth';
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { import {
@@ -10,39 +9,32 @@ import {
Alert, Alert,
Badge, Badge,
Button, Button,
Code,
Grid, Grid,
Group, Group,
Loader, Loader,
Modal,
Radio, Radio,
SimpleGrid, SimpleGrid,
Stack, Stack,
Table, Table,
Text, Text,
TextInput, TextInput
Tooltip
} from '@mantine/core'; } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { hideNotification, showNotification } from '@mantine/notifications'; import { hideNotification, showNotification } from '@mantine/notifications';
import { import {
IconAlertCircle, IconAlertCircle,
IconAt, IconAt,
IconExclamationCircle,
IconRefresh, IconRefresh,
IconX IconX
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { api } from '../../../../App';
import { StylishText } from '../../../../components/items/StylishText'; import { StylishText } from '../../../../components/items/StylishText';
import { ProviderLogin, authApi } from '../../../../functions/auth'; import { ProviderLogin, authApi } from '../../../../functions/auth';
import { useServerApiState } from '../../../../states/ServerApiState'; import { useServerApiState } from '../../../../states/ServerApiState';
import { useUserState } from '../../../../states/UserState'; import { useUserState } from '../../../../states/UserState';
import { ApiTokenTable } from '../../../../tables/settings/ApiTokenTable'; import { ApiTokenTable } from '../../../../tables/settings/ApiTokenTable';
import { QrRegistrationForm } from './QrRegistrationForm'; import MFASettings from './MFASettings';
import { useReauth } from './useConfirm';
export function SecurityContent() { export function SecurityContent() {
const [auth_config, sso_enabled] = useServerApiState( const [auth_config, sso_enabled] = useServerApiState(
@@ -85,7 +77,7 @@ export function SecurityContent() {
<StylishText size='lg'>{t`Multi-Factor Authentication`}</StylishText> <StylishText size='lg'>{t`Multi-Factor Authentication`}</StylishText>
</Accordion.Control> </Accordion.Control>
<Accordion.Panel> <Accordion.Panel>
<MfaSection /> <MFASettings />
</Accordion.Panel> </Accordion.Panel>
</Accordion.Item> </Accordion.Item>
<Accordion.Item value='token'> <Accordion.Item value='token'>
@@ -403,472 +395,5 @@ function ProviderSection({
); );
} }
function MfaSection() {
const [getReauthText, ReauthModal] = useReauth();
const [recoveryCodes, setRecoveryCodes] = useState<
Recoverycodes | undefined
>();
const [
recoveryCodesOpen,
{ open: openRecoveryCodes, close: closeRecoveryCodes }
] = useDisclosure(false);
const { isLoading, data, refetch } = useQuery({
queryKey: ['mfa-list'],
queryFn: () =>
api
.get(apiUrl(ApiEndpoints.auth_authenticators))
.then((res) => res?.data?.data ?? [])
.catch(() => [])
});
function showRecoveryCodes(codes: Recoverycodes) {
setRecoveryCodes(codes);
openRecoveryCodes();
}
const removeTotp = () => {
runActionWithFallback(
() =>
authApi(apiUrl(ApiEndpoints.auth_totp), undefined, 'delete').then(
() => {
refetch();
return ResultType.success;
}
),
getReauthText
);
};
const viewRecoveryCodes = () => {
runActionWithFallback(
() =>
authApi(apiUrl(ApiEndpoints.auth_recovery), undefined, 'get').then(
(res) => {
showRecoveryCodes(res.data.data);
return ResultType.success;
}
),
getReauthText
);
};
const removeWebauthn = (code: string) => {
runActionWithFallback(
() =>
authApi(apiUrl(ApiEndpoints.auth_webauthn), undefined, 'delete', {
authenticators: [code]
}).then(() => {
refetch();
return ResultType.success;
}),
getReauthText
);
};
const rows = useMemo(() => {
if (isLoading || !data) return null;
return data.map((token: any) => (
<Table.Tr key={`${token.created_at}-${token.type}`}>
<Table.Td>{token.type}</Table.Td>
<Table.Td>{parseDate(token.last_used_at)}</Table.Td>
<Table.Td>{parseDate(token.created_at)}</Table.Td>
<Table.Td>
{token.type == 'totp' && (
<Button color='red' onClick={removeTotp}>
<Trans>Remove</Trans>
</Button>
)}
{token.type == 'recovery_codes' && (
<Button onClick={viewRecoveryCodes}>
<Trans>View</Trans>
</Button>
)}
{token.type == 'webauthn' && (
<Button
color='red'
onClick={() => {
removeWebauthn(token.id);
}}
>
<Trans>Remove</Trans>
</Button>
)}
</Table.Td>
</Table.Tr>
));
}, [data, isLoading]);
const usedFactors: string[] = useMemo(() => {
if (isLoading || !data) return [];
return data.map((token: any) => token.type);
}, [data]);
if (isLoading) return <Loader />;
return (
<>
<ReauthModal />
<SimpleGrid cols={{ xs: 1, md: 2 }} spacing='sm'>
{data.length == 0 ? (
<Stack gap='xs'>
<Alert
title={t`Not Configured`}
icon={<IconAlertCircle size='1rem' />}
color='yellow'
>
<Trans>No multi-factor tokens configured for this account</Trans>
</Alert>
</Stack>
) : (
<Table stickyHeader striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>
<Trans>Type</Trans>
</Table.Th>
<Table.Th>
<Trans>Last used at</Trans>
</Table.Th>
<Table.Th>
<Trans>Created at</Trans>
</Table.Th>
<Table.Th>
<Trans>Actions</Trans>
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
)}
<MfaAddSection
usedFactors={usedFactors}
refetch={refetch}
showRecoveryCodes={showRecoveryCodes}
/>
<Modal
opened={recoveryCodesOpen}
onClose={() => {
refetch();
closeRecoveryCodes();
}}
title={t`Recovery Codes`}
centered
>
<StylishText size='lg'>
<Trans>Unused Codes</Trans>
</StylishText>
<Code>{recoveryCodes?.unused_codes?.join('\n')}</Code>
<StylishText size='lg'>
<Trans>Used Codes</Trans>
</StylishText>
<Code>{recoveryCodes?.used_codes?.join('\n')}</Code>
</Modal>
</SimpleGrid>
</>
);
}
enum ResultType {
success = 0,
reauth = 1,
mfareauth = 2,
error = 3
}
export interface Recoverycodes {
type: string;
created_at: number;
last_used_at: null;
total_code_count: number;
unused_code_count: number;
unused_codes: string[];
used_code_count: number;
used_codes: string[];
}
function MfaAddSection({
usedFactors,
refetch,
showRecoveryCodes
}: Readonly<{
usedFactors: string[];
refetch: () => void;
showRecoveryCodes: (codes: Recoverycodes) => void;
}>) {
const [auth_config] = useServerApiState(
useShallow((state) => [state.auth_config])
);
const [totpQrOpen, { open: openTotpQr, close: closeTotpQr }] =
useDisclosure(false);
const [totpQr, setTotpQr] = useState<{ totp_url: string; secret: string }>();
const [value, setValue] = useState('');
const [getReauthText, ReauthModal] = useReauth();
const registerRecoveryCodes = async () => {
await runActionWithFallback(
() =>
authApi(apiUrl(ApiEndpoints.auth_recovery), undefined, 'post')
.then((res) => {
showRecoveryCodes(res.data.data);
return ResultType.success;
})
.catch((err) => {
showNotification({
title: t`Error while registering recovery codes`,
message: err.response.data.errors
.map((error: any) => error.message)
.join('\n'),
color: 'red',
icon: <IconX />
});
return ResultType.error;
}),
getReauthText
);
};
const registerTotp = async () => {
await runActionWithFallback(
() =>
authApi(apiUrl(ApiEndpoints.auth_totp), undefined, 'get')
.then(() => ResultType.error)
.catch((err) => {
if (err.status == 404 && err.response.data.meta.secret) {
setTotpQr(err.response.data.meta);
openTotpQr();
return ResultType.success;
}
return ResultType.error;
}),
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(() => {
return [
{
type: 'totp',
name: t`TOTP`,
description: t`Time-based One-Time Password`,
function: registerTotp,
used: usedFactors?.includes('totp')
},
{
type: 'recovery_codes',
name: t`Recovery Codes`,
description: t`One-Time pre-generated recovery codes`,
function: registerRecoveryCodes,
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) => {
return auth_config?.mfa?.supported_types.includes(factor.type);
});
}, [usedFactors, auth_config]);
const [totpError, setTotpError] = useState<string>('');
return (
<Stack>
<ReauthModal />
<StylishText size='md'>{t`Add Token`}</StylishText>
{possibleFactors.map((factor) => (
<Tooltip label={factor.description} key={factor.type}>
<Button
onClick={factor.function}
disabled={factor.used}
variant='outline'
>
{factor.name}
</Button>
</Tooltip>
))}
<Modal
opened={totpQrOpen}
onClose={closeTotpQr}
title={<StylishText size='lg'>{t`Register TOTP Token`}</StylishText>}
>
<Stack>
<QrRegistrationForm
url={totpQr?.totp_url ?? ''}
secret={totpQr?.secret ?? ''}
value={value}
error={totpError}
setValue={setValue}
/>
<Button
fullWidth
onClick={() =>
runActionWithFallback(
() =>
authApi(apiUrl(ApiEndpoints.auth_totp), undefined, 'post', {
code: value
})
.then(() => {
setTotpError('');
closeTotpQr();
refetch();
return ResultType.success;
})
.catch((error) => {
const errorMsg = t`Error registering TOTP token`;
setTotpError(
error.response?.data?.errors[0]?.message ?? errorMsg
);
hideNotification('totp-error');
showNotification({
id: 'totp-error',
title: t`Error`,
message: errorMsg,
color: 'red',
icon: <IconExclamationCircle />
});
return ResultType.error;
}),
getReauthText
)
}
>
<Trans>Submit</Trans>
</Button>
</Stack>
</Modal>
</Stack>
);
}
async function runActionWithFallback(
action: () => Promise<ResultType>,
getReauthText: (props: any) => any
) {
const { setAuthContext, setMfaContext, mfa_context } =
useServerApiState.getState();
const result = await action().catch((err) => {
setAuthContext(err.response.data?.data);
// check if we need to re-authenticate
if (err.status == 401) {
const mfaFlow = err.response.data.data.flows.find(
(flow: any) => flow.id == FlowEnum.MfaReauthenticate
);
if (mfaFlow) {
setMfaContext(mfaFlow);
return ResultType.mfareauth;
} else if (
err.response.data.data.flows.find(
(flow: any) => flow.id == FlowEnum.Reauthenticate
)
) {
return ResultType.reauth;
} else {
return ResultType.error;
}
} else {
return ResultType.error;
}
});
// run the re-authentication flows as needed
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', {
code: mfaCode
})
.then((response) => {
setAuthContext(response.data?.data);
action();
})
.catch((err) => {
setAuthContext(err.response.data?.data);
});
} 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', {
password: passwordInput
})
.then((response) => {
setAuthContext(response.data?.data);
action();
})
.catch((err) => {
setAuthContext(err.response.data?.data);
});
}
}
export const parseDate = (date: number) => export const parseDate = (date: number) =>
date == null ? 'Never' : new Date(date * 1000).toLocaleString(); date == null ? 'Never' : new Date(date * 1000).toLocaleString();

View File

@@ -1,117 +0,0 @@
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { Button, Group, Modal, Stack, TextInput } from '@mantine/core';
import { type JSX, useState } from 'react';
/* Adapted from https://daveteu.medium.com/react-custom-confirmation-box-458cceba3f7b */
const createPromise = () => {
let resolver: any;
return [
new Promise((resolve) => {
resolver = resolve;
}),
resolver
];
};
/* Adapted from https://daveteu.medium.com/react-custom-confirmation-box-458cceba3f7b */
export const useConfirm = () => {
const [open, setOpen] = useState(false);
const [resolver, setResolver] = useState<((status: boolean) => void) | null>(
null
);
const [label, setLabel] = useState('');
const getConfirmation = async (text: string) => {
setLabel(text);
setOpen(true);
const [promise, resolve] = await createPromise();
setResolver(resolve);
return promise;
};
const onClick = async (status: boolean) => {
setOpen(false);
if (resolver) {
resolver(status);
}
};
const Confirmation = () => (
<Modal opened={open} onClose={() => setOpen(false)}>
{label}
<Button onClick={() => onClick(false)}> Cancel </Button>
<Button onClick={() => onClick(true)}> OK </Button>
</Modal>
);
return [getConfirmation, Confirmation];
};
type InputProps = {
label: string;
name: string;
description: string;
};
export const useReauth = (): [
(props: InputProps) => Promise<[string, boolean]>,
() => JSX.Element
] => {
const [inputProps, setInputProps] = useState<InputProps>({
label: '',
name: '',
description: ''
});
const [value, setValue] = useState('');
const [open, setOpen] = useState(false);
const [resolver, setResolver] = useState<{
resolve: (result: string, positive: boolean) => void;
} | null>(null);
const getReauthText = async (props: InputProps) => {
setInputProps(props);
setOpen(true);
const [promise, resolve] = await createPromise();
setResolver({ resolve });
return promise;
};
const onClick = async (result: string, positive: boolean) => {
setOpen(false);
if (resolver) {
resolver.resolve(result, positive);
}
};
const ReauthModal = () => (
<Modal
opened={open}
onClose={() => setOpen(false)}
title={t`Reauthentication`}
>
<Stack>
<TextInput
required
label={inputProps.label}
name={inputProps.name}
description={inputProps.description}
value={value}
onChange={(event) => setValue(event.currentTarget.value)}
/>
<Group justify='space-between'>
<Button onClick={() => onClick('', false)} color='red'>
<Trans>Cancel</Trans>
</Button>
<Button onClick={() => onClick(value, true)}>
<Trans>OK</Trans>
</Button>
</Group>
</Stack>
</Modal>
);
return [getReauthText, ReauthModal];
};

View File

@@ -73,6 +73,7 @@ export const test = baseTest.extend({
!url.includes('/api/user/token/') && !url.includes('/api/user/token/') &&
!url.includes('/api/auth/v1/auth/login') && !url.includes('/api/auth/v1/auth/login') &&
!url.includes('/api/auth/v1/auth/session') && !url.includes('/api/auth/v1/auth/session') &&
!url.includes('/api/auth/v1/account/authenticators/totp') &&
!url.includes('/api/auth/v1/account/password/change') && !url.includes('/api/auth/v1/account/password/change') &&
!url.includes('/api/barcode/') && !url.includes('/api/barcode/') &&
!url.includes('/favicon.ico') && !url.includes('/favicon.ico') &&

View File

@@ -3,6 +3,8 @@ import { logoutUrl } from './defaults.js';
import { navigate } from './helpers.js'; import { navigate } from './helpers.js';
import { doLogin } from './login.js'; import { doLogin } from './login.js';
import { TOTP } from 'otpauth';
/** /**
* Test various types of login failure * Test various types of login failure
*/ */
@@ -84,3 +86,74 @@ test('Login - Change Password', async ({ page }) => {
await page.getByText('Password Changed').waitFor(); await page.getByText('Password Changed').waitFor();
await page.getByText('The password was set successfully').waitFor(); await page.getByText('The password was set successfully').waitFor();
}); });
// Tests for assigning MFA tokens to users
test('Login - MFA - TOTP', async ({ page }) => {
await doLogin(page, {
username: 'noaccess',
password: 'youshallnotpass'
});
await navigate(page, 'settings/user/security', { waitUntil: 'networkidle' });
// Expand the MFA section
await page
.getByRole('button', { name: 'Multi-Factor Authentication' })
.click();
await page.getByRole('button', { name: 'add-factor-totp' }).click();
// Ensure the user starts without any codes
await page
.getByText('No multi-factor tokens configured for this account')
.waitFor();
// Try to submit with an empty code
await page.getByRole('textbox', { name: 'text-input-otp-code' }).fill('');
await page.getByRole('button', { name: 'Submit', exact: true }).click();
await page.getByText('This field is required.').waitFor();
// Try to submit with an invalid secret
await page
.getByRole('textbox', { name: 'text-input-otp-code' })
.fill('ABCDEF');
await page.getByRole('button', { name: 'Submit', exact: true }).click();
await page.getByText('Incorrect code.').waitFor();
// Submit a valid code
const secret = await page
.getByLabel('otp-secret', { exact: true })
.innerText();
// Construct a TOTP code based on the secret
const totp = new TOTP({
secret: secret,
digits: 6,
period: 30
});
const token = totp.generate();
await page.getByRole('textbox', { name: 'text-input-otp-code' }).fill(token);
await page.getByRole('button', { name: 'Submit', exact: true }).click();
await page.getByText('TOTP token registered successfully').waitFor();
// View recovery codes
await page.getByRole('button', { name: 'view-recovery-codes' }).click();
await page
.getByText('The following one time recovery codes are available')
.waitFor();
await page.getByRole('button', { name: 'Close' }).click();
// Remove TOTP token
await page.getByRole('button', { name: 'remove-totp' }).click();
await page.getByRole('button', { name: 'Remove', exact: true }).click();
await page.getByText('TOTP token removed successfully').waitFor();
// And, once again there should be no configured tokens
await page
.getByText('No multi-factor tokens configured for this account')
.waitFor();
});

View File

@@ -1260,6 +1260,11 @@
resolved "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz" resolved "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz"
integrity sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw== integrity sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==
"@noble/hashes@1.8.0":
version "1.8.0"
resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.8.0.tgz#cee43d801fcef9644b11b8194857695acd5f815a"
integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==
"@octokit/auth-token@^4.0.0": "@octokit/auth-token@^4.0.0":
version "4.0.0" version "4.0.0"
resolved "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz" resolved "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz"
@@ -3850,6 +3855,13 @@ ora@^5.1.0:
strip-ansi "^6.0.0" strip-ansi "^6.0.0"
wcwidth "^1.0.1" wcwidth "^1.0.1"
otpauth@^9.4.1:
version "9.4.1"
resolved "https://registry.yarnpkg.com/otpauth/-/otpauth-9.4.1.tgz#6c5d8cf82b441b2f9f2a2a64ba5e00b992f5f31f"
integrity sha512-+iVvys36CFsyXEqfNftQm1II7SW23W1wx9RwNk0Cd97lbvorqAhBDksb/0bYry087QMxjiuBS0wokdoZ0iUeAw==
dependencies:
"@noble/hashes" "1.8.0"
p-limit@^2.2.0: p-limit@^2.2.0:
version "2.3.0" version "2.3.0"
resolved "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz" resolved "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz"