diff --git a/src/backend/InvenTree/users/api.py b/src/backend/InvenTree/users/api.py index 53b2105dcf..a8cfc55e3c 100644 --- a/src/backend/InvenTree/users/api.py +++ b/src/backend/InvenTree/users/api.py @@ -379,6 +379,34 @@ class RuleSetDetail(RuleSetMixin, RetrieveUpdateDestroyAPI): """Detail endpoint for a particular RuleSet instance.""" +def check_token_reauthentication(request) -> Response | None: + """Check that a user is allowed to be issued a new API token. + + If the user has MFA enabled, they must have recently (re)authenticated + before a new token can be issued. Otherwise a stolen (or aged) session + could be converted into a long-lived API token without the second factor. + + This check must be applied to *every* API endpoint which issues new tokens. + + Returns: + None if the user may be issued a token, or a 401 response + describing the required reauthentication flows. + """ + user = request.user + + if is_mfa_enabled(user) and not did_recently_authenticate(request): + return Response( + { + 'id': 'reauthentication_required', + 'detail': _('Reauthentication is required to generate a token'), + 'data': {'flows': get_reauthentication_flows(user)}, + }, + status=status.HTTP_401_UNAUTHORIZED, + ) + + return None + + class GetAuthToken(GenericAPIView): """Return authentication token for an authenticated user.""" @@ -409,16 +437,8 @@ class GetAuthToken(GenericAPIView): if not user.is_authenticated: raise exceptions.NotAuthenticated() # pragma: no cover - # does this user have MFA set up? if so: was he recently verified? - if is_mfa_enabled(user) and not did_recently_authenticate(request): - return Response( - { - 'id': 'reauthentication_required', - 'detail': _('Reauthentication is required to generate a token'), - 'data': {'flows': get_reauthentication_flows(user)}, - }, - status=status.HTTP_401_UNAUTHORIZED, - ) + if reauth_response := check_token_reauthentication(request): + return reauth_response name = ApiToken.sanitize_name(name) @@ -533,6 +553,9 @@ class TokenListView(TokenMixin, ListCreateAPI): def create(self, request, *args, **kwargs): """Create token and show key to user.""" + if reauth_response := check_token_reauthentication(request): + return reauth_response + resp = super().create(request, *args, **kwargs) resp.data['token'] = self._created_token.token diff --git a/src/backend/InvenTree/users/test_api.py b/src/backend/InvenTree/users/test_api.py index 9a65617ca4..f551211345 100644 --- a/src/backend/InvenTree/users/test_api.py +++ b/src/backend/InvenTree/users/test_api.py @@ -1,12 +1,15 @@ """API tests for various user / auth API endpoints.""" import datetime +import time from unittest import mock from django.contrib.auth.models import Group, User from django.urls import reverse +from allauth.account.internal.flows.login import AUTHENTICATION_METHODS_SESSION_KEY from allauth.account.models import EmailAddress +from allauth.mfa.models import Authenticator import InvenTree.helpers from common.models import NotificationMessage @@ -765,6 +768,70 @@ class UserTokenTests(InvenTreeAPITestCase): self.assertEqual(token.token_version, 2) self.assertIsNone(token.last_seen) + def set_auth_time(self, age: float): + """Record an allauth authentication event in the test client session. + + Arguments: + age: How many seconds ago the authentication occurred + """ + session = self.client.session + session[AUTHENTICATION_METHODS_SESSION_KEY] = [ + {'method': 'mfa', 'type': 'totp', 'at': time.time() - age} + ] + session.save() + + def test_token_mfa_reauthentication(self): + """Test that MFA users must have recently authenticated to mint a token. + + Every endpoint which issues a new API token must enforce this check. + Otherwise an aged (or stolen) session could be converted into a + long-lived API token without the second factor. + """ + Authenticator.objects.create( + user=self.user, type=Authenticator.Type.TOTP, data={'secret': 'abc'} + ) + + n_tokens = ApiToken.objects.filter(user=self.user).count() + + endpoints = [ + lambda name: self.get( + reverse('api-token'), data={'name': name}, expected_code=None + ), + lambda name: self.get( + reverse('api-token_legacy'), data={'name': name}, expected_code=None + ), + lambda name: self.post( + reverse('api-token-list'), data={'name': name}, expected_code=None + ), + ] + + # No authentication record, or an aged one: reauthentication is required + for age in [None, 600]: + if age is not None: + self.set_auth_time(age) + + for idx, mint in enumerate(endpoints): + response = mint(f'mfa-{age}-{idx}') + self.assertEqual(response.status_code, 401, response.data) + self.assertEqual(response.data['id'], 'reauthentication_required') + + flows = [flow['id'] for flow in response.data['data']['flows']] + self.assertIn('mfa_reauthenticate', flows) + + # No tokens were issued + self.assertEqual(ApiToken.objects.filter(user=self.user).count(), n_tokens) + + # After recent (re)authentication, tokens can be issued + self.set_auth_time(10) + + self.get(reverse('api-token'), data={'name': 'mfa-ok-0'}, expected_code=200) + response = self.post( + reverse('api-token-list'), data={'name': 'mfa-ok-1'}, expected_code=201 + ) + self.assertIn('token', response.data) + + self.assertEqual(ApiToken.objects.filter(user=self.user).count(), n_tokens + 2) + def test_token_v1(self): """Test that v1 API tokens still work.""" # Create a v1 token via model - this is NOT recommended; use v2 tokens diff --git a/src/frontend/src/components/forms/ApiForm.tsx b/src/frontend/src/components/forms/ApiForm.tsx index a33710ee6c..1b07395d61 100644 --- a/src/frontend/src/components/forms/ApiForm.tsx +++ b/src/frontend/src/components/forms/ApiForm.tsx @@ -706,6 +706,24 @@ export function ApiForm({ setNonFieldErrors(_nonFieldErrors); props.onFormError?.(error, form); + break; + case 401: + // The server may require the user to re-authenticate (e.g. MFA) + // before the action can be performed + if ( + error.response.data?.id === 'reauthentication_required' && + !!props.onFormError + ) { + setNonFieldErrors([ + error.response.data?.detail ?? + t`Reauthentication is required to continue` + ]); + props.onFormError(error, form); + break; + } + + invalidResponse(error.response.status); + props.onFormError?.(error, form); break; default: // Unexpected state on form error diff --git a/src/frontend/src/components/modals/ReauthenticateModal.tsx b/src/frontend/src/components/modals/ReauthenticateModal.tsx new file mode 100644 index 0000000000..3a53a97633 --- /dev/null +++ b/src/frontend/src/components/modals/ReauthenticateModal.tsx @@ -0,0 +1,271 @@ +import { StylishText } from '@lib/components/StylishText'; +import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; +import { apiUrl } from '@lib/functions/Api'; +import { FlowEnum } from '@lib/types/Auth'; +import { t } from '@lingui/core/macro'; +import { Trans } from '@lingui/react/macro'; +import { + Alert, + Button, + Divider, + Group, + Modal, + PasswordInput, + Stack +} from '@mantine/core'; +import { showNotification } from '@mantine/notifications'; +import { IconCircleCheck, IconExclamationCircle } from '@tabler/icons-react'; +import { type ReactNode, useCallback, useState } from 'react'; +import { api } from '../../App'; +import { useServerApiState } from '../../states/ServerApiState'; + +/** + * Extract the required re-authentication flow from an error response + */ +export function getReauthFlow(err: any): FlowEnum | null { + const flows: any = err.response?.data?.data?.flows ?? []; + + if (flows.find((flow: any) => flow.id == FlowEnum.MfaReauthenticate)) { + return FlowEnum.MfaReauthenticate; + } else if (flows.find((flow: any) => flow.id == FlowEnum.Reauthenticate)) { + return FlowEnum.Reauthenticate; + } else { + // No match found + return null; + } +} + +/** + * Extract an error message from an allauth error response. + * + * The allauth flows have a particular response structure, + * which this function handles. + */ +export function extractErrorMessage(err: any, defaultMsg: string): string { + const backupMsg = `${defaultMsg}: ${err.status}`; + + if (err.response?.data?.errors && err.response?.data?.errors.length > 0) { + return err.response?.data?.errors[0]?.message ?? backupMsg; + } + + return backupMsg; +} + +type ReauthInputProps = { + label: string; + name: string; + description: string; + url: string; +}; + +/** + * Internal modal component for re-authentication + */ +function ReauthenticateModalComponent({ + inputProps, + setOpen +}: { + inputProps: ReauthInputProps; + setOpen: (open: boolean) => void; +}) { + const { setAuthContext } = useServerApiState.getState(); + + const [value, setValue] = useState(''); + const [error, setError] = useState(''); + + const onSubmit = useCallback( + (value: string) => { + api + .post(inputProps.url, { + [inputProps.name]: value + }) + .then((response) => { + setAuthContext(response.data?.data); + showNotification({ + title: t`Reauthentication Succeeded`, + message: t`You have been reauthenticated successfully.`, + color: 'green', + icon: + }); + setOpen(false); + }) + .catch((error) => { + setError( + extractErrorMessage(error, t`Error during reauthentication`) + ); + showNotification({ + title: t`Reauthentication Failed`, + message: `${t`Failed to reauthenticate`}: ${error.status}`, + color: 'red', + icon: + }); + }); + }, + [inputProps] + ); + + return ( + + + } + title={t`Reauthenticate`} + > + {t`Reauthentication is required to continue.`} + + setValue(event.target.value)} + /> + + + + + + ); +} + +function ReauthenticateModal({ + inputProps, + opened, + setOpen +}: { + inputProps: ReauthInputProps; + opened: boolean; + setOpen: (open: boolean) => void; +}) { + return ( + setOpen(false)} + title={{t`Reauthenticate`}} + > + + + ); +} + +/** + * Modal for re-authenticating with password + */ +export function ReauthenticatePasswordModal({ + opened, + setOpen +}: { + opened: boolean; + setOpen: (open: boolean) => void; +}) { + return ( + + ); +} + +/** + * Modal for re-authenticating with TOTP code + */ +export function ReauthenticateTOTPModal({ + opened, + setOpen +}: { + opened: boolean; + setOpen: (open: boolean) => void; +}) { + return ( + + ); +} + +/** + * Hook providing re-authentication modals and helpers. + * + * Usage: + * - Render the returned `modals` element somewhere in the component tree + * - Call `reauthenticate(flow)` to open the matching modal directly, or + * - Call `handleReauthError(error)` from an API error handler; it returns true + * (and opens the appropriate modal) if the error indicates that the server + * requires the user to re-authenticate before continuing. + */ +export function useReauthenticate(): { + reauthenticate: (flow: FlowEnum) => void; + handleReauthError: (error: any) => boolean; + modals: ReactNode; +} { + const [passwordOpen, setPasswordOpen] = useState(false); + const [totpOpen, setTotpOpen] = useState(false); + + const reauthenticate = useCallback((flow: FlowEnum) => { + switch (flow) { + case FlowEnum.Reauthenticate: + setPasswordOpen(true); + break; + case FlowEnum.MfaReauthenticate: + setTotpOpen(true); + break; + default: + // Un-handled reauthentication flow + break; + } + }, []); + + const handleReauthError = useCallback( + (error: any): boolean => { + const status = error?.status ?? error?.response?.status; + + if (status !== 401) { + return false; + } + + const flow = getReauthFlow(error); + + if (!flow) { + return false; + } + + reauthenticate(flow); + return true; + }, + [reauthenticate] + ); + + const modals = ( + <> + + + + ); + + return { reauthenticate, handleReauthError, modals }; +} diff --git a/src/frontend/src/pages/Index/Settings/AccountSettings/MFASettings.tsx b/src/frontend/src/pages/Index/Settings/AccountSettings/MFASettings.tsx index ad5fae8c72..1a15c90578 100644 --- a/src/frontend/src/pages/Index/Settings/AccountSettings/MFASettings.tsx +++ b/src/frontend/src/pages/Index/Settings/AccountSettings/MFASettings.tsx @@ -2,7 +2,7 @@ import { CopyButton } from '@lib/components/CopyButton'; import { StylishText } from '@lib/components/StylishText'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { apiUrl } from '@lib/functions/Api'; -import { FlowEnum } from '@lib/types/Auth'; +import type { FlowEnum } from '@lib/types/Auth'; import { t } from '@lingui/core/macro'; import { Trans } from '@lingui/react/macro'; import { @@ -13,7 +13,6 @@ import { Loader, Modal, Paper, - PasswordInput, SimpleGrid, Stack, Table, @@ -32,6 +31,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useShallow } from 'zustand/react/shallow'; import { api, queryClient } from '../../../../App'; +import { + extractErrorMessage, + getReauthFlow, + useReauthenticate +} from '../../../../components/modals/ReauthenticateModal'; import { authApi, doLogout } from '../../../../functions/auth'; import { showApiErrorMessage } from '../../../../functions/notifications'; import { useServerApiState } from '../../../../states/ServerApiState'; @@ -39,192 +43,6 @@ import { useGlobalSettingsState } from '../../../../states/SettingsStates'; import { QrRegistrationForm } from './QrRegistrationForm'; import { parseDate } from './SecurityContent'; -/** - * Extract the required re-authentication flow from an error response - */ -function getReauthFlow(err: any): FlowEnum | null { - const flows: any = err.response?.data?.data?.flows ?? []; - - if (flows.find((flow: any) => flow.id == FlowEnum.MfaReauthenticate)) { - return FlowEnum.MfaReauthenticate; - } else if (flows.find((flow: any) => flow.id == FlowEnum.Reauthenticate)) { - return FlowEnum.Reauthenticate; - } else { - // No match found - return null; - } -} - -/** - * Extract an error message from an allauth error response. - * - * The allauth flows have a particular response structure, - * which this function handles. - */ -function extractErrorMessage(err: any, defaultMsg: string): string { - const backupMsg = `${defaultMsg}: ${err.status}`; - - if (err.response?.data?.errors && err.response?.data?.errors.length > 0) { - return err.response?.data?.errors[0]?.message ?? backupMsg; - } - - return backupMsg; -} - -type ReauthInputProps = { - label: string; - name: string; - description: string; - url: string; -}; - -/** - * Internal modal component for re-authentication - */ -function ReauthenticateModalComponent({ - inputProps, - setOpen -}: { - inputProps: ReauthInputProps; - setOpen: (open: boolean) => void; -}) { - const { setAuthContext } = useServerApiState.getState(); - - const [value, setValue] = useState(''); - const [error, setError] = useState(''); - - const onSubmit = useCallback( - (value: string) => { - api - .post(inputProps.url, { - [inputProps.name]: value - }) - .then((response) => { - setAuthContext(response.data?.data); - showNotification({ - title: t`Reauthentication Succeeded`, - message: t`You have been reauthenticated successfully.`, - color: 'green', - icon: - }); - setOpen(false); - }) - .catch((error) => { - setError( - extractErrorMessage(error, t`Error during reauthentication`) - ); - showNotification({ - title: t`Reauthentication Failed`, - message: `${t`Failed to reauthenticate`}: ${error.status}`, - color: 'red', - icon: - }); - }); - }, - [inputProps] - ); - - return ( - - - } - title={t`Reauthenticate`} - > - {t`Reauthentiction is required to continue.`} - - setValue(event.target.value)} - /> - - - - - - ); -} - -function ReauthenticateModal({ - inputProps, - opened, - setOpen -}: { - inputProps: ReauthInputProps; - opened: boolean; - setOpen: (open: boolean) => void; -}) { - return ( - setOpen(false)} - title={{t`Reauthenticate`}} - > - - - ); -} - -/** - * Modal for re-authenticating with password - */ -function ReauthenticatePasswordModal({ - opened, - setOpen -}: { - opened: boolean; - setOpen: (open: boolean) => void; -}) { - return ( - - ); -} - -/** - * Modal for re-authenticating with TOTP code - */ -function ReauthenticateTOTPModal({ - opened, - setOpen -}: { - opened: boolean; - setOpen: (open: boolean) => void; -}) { - return ( - - ); -} - /** * Modal for removing a registered WebAuthn credential: * - Deletes the WebAuthn credential from the server @@ -730,8 +548,6 @@ export default function MFASettings() { const [webauthnToken, setWebauthnToken] = useState(null); const [recoveryCodesOpen, setRecoveryCodesOpen] = useState(false); - const [reauthPassOpen, setReauthPassModalOpen] = useState(false); - const [reauthTOTPOpen, setReauthTOTPModalOpen] = useState(false); const [registerTOTPModalOpen, setRegisterTOTPModalOpen] = useState(false); const [removeTOTPModalOpen, setRemoveTOTPModalOpen] = @@ -739,20 +555,8 @@ export default function MFASettings() { const [removeWebauthnModalOpen, setRemoveWebauthnModalOpen] = useState(false); - // Callback function used to re-authenticate the user - const reauthenticate = useCallback((flow: FlowEnum) => { - switch (flow) { - case FlowEnum.Reauthenticate: - setReauthPassModalOpen(true); - break; - case FlowEnum.MfaReauthenticate: - setReauthTOTPModalOpen(true); - break; - default: - // Un-handled reauthentication flow - break; - } - }, []); + // Re-authentication modals and callback + const { reauthenticate, modals: reauthModals } = useReauthenticate(); const registerRecoveryCodes = useCallback(() => { setRecoveryCodesOpen(true); @@ -951,14 +755,7 @@ export default function MFASettings() { onReauthFlow={reauthenticate} onSuccess={refetch} /> - - + {reauthModals} {mfaRows.length > 0 ? ( diff --git a/src/frontend/src/tables/settings/ApiTokenTable.tsx b/src/frontend/src/tables/settings/ApiTokenTable.tsx index 029026cfe2..5d795ee279 100644 --- a/src/frontend/src/tables/settings/ApiTokenTable.tsx +++ b/src/frontend/src/tables/settings/ApiTokenTable.tsx @@ -24,6 +24,7 @@ import { useDisclosure } from '@mantine/hooks'; import { IconAlertTriangle, IconCircleX } from '@tabler/icons-react'; import { useCallback, useMemo, useState } from 'react'; import { api } from '../../App'; +import { useReauthenticate } from '../../components/modals/ReauthenticateModal'; import { BooleanColumn, UserColumn @@ -42,12 +43,18 @@ export function ApiTokenTable({ const [revokeTokenId, setRevokeTokenId] = useState(null); const [revocationReason, setRevocationReason] = useState(''); + const { handleReauthError, modals: reauthModals } = useReauthenticate(); + const generateToken = useCreateApiFormModal({ url: ApiEndpoints.user_me_token, method: 'GET', title: t`Generate Token`, fields: { name: {} }, successMessage: t`Token generated`, + onFormError: (error: any) => { + // MFA users must have recently authenticated to generate a token + handleReauthError(error); + }, onFormSuccess: (data: any) => { setToken(data.token); setTokenWarning(data.warning ?? ''); @@ -224,6 +231,7 @@ export function ApiTokenTable({ {only_myself && ( <> {generateToken.modal} + {reauthModals} ({ !url.includes('/api/auth/v1/auth/login') && !url.includes('/api/auth/v1/auth/session') && !url.includes('/api/auth/v1/auth/2fa/authenticate') && + !url.includes('/api/auth/v1/auth/2fa/reauthenticate') && !url.includes('/api/auth/v1/auth/provider/signup') && !url.includes('/api/auth/v1/account/authenticators/totp') && !url.includes('/api/auth/v1/account/password/change') && diff --git a/src/frontend/tests/pui_login.spec.ts b/src/frontend/tests/pui_login.spec.ts index 6456464bac..62a16509fd 100644 --- a/src/frontend/tests/pui_login.spec.ts +++ b/src/frontend/tests/pui_login.spec.ts @@ -1,4 +1,4 @@ -import type { Page } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; import { TOTP } from 'otpauth'; import { expect, test } from './baseFixtures.js'; import { engineeruser, logoutUrl, mfauser, noaccessuser } from './defaults.js'; @@ -508,6 +508,76 @@ test('Login - MFA - TOTP', async ({ page }) => { .waitFor(); }); +/** + * Submit a TOTP code for the demo MFA user. + * + * Codes are single-use per period (server side), and the same user may be + * authenticating from another browser project concurrently. + * So, if a code is rejected, wait for the next period and try again. + * + * @param codeInput - The input field for the TOTP code + * @param submitButton - The button which submits the code + * @param successLocator - Visible once the code has been accepted + */ +async function submitTotpCode( + page: Page, + codeInput: Locator, + submitButton: Locator, + successLocator: Locator +) { + const totp = new TOTP({ + secret: mfauser.totpSecret, + digits: 6, + period: 30 + }); + + let lastToken = ''; + + for (let attempt = 0; attempt < 3; attempt++) { + while (totp.generate() == lastToken) { + await page.waitForTimeout(1000); + } + + lastToken = totp.generate(); + + await codeInput.fill(lastToken); + await submitButton.click(); + + const accepted = await successLocator + .waitFor({ timeout: 5000 }) + .then(() => true) + .catch(() => false); + + if (accepted) { + return; + } + } + + throw new Error('TOTP code was not accepted'); +} + +/** + * Log in as the demo MFA user (password + TOTP) + */ +async function doMfaLogin(page: Page) { + await page.context().clearCookies(); + await navigate(page, logoutUrl, { waitUntil: 'load' }); + await page.waitForURL('**/web/login'); + + await page.getByLabel('login-username').fill(mfauser.username); + await page.getByLabel('login-password').fill(mfauser.testcred); + await page.getByRole('button', { name: 'Log In' }).click(); + + await page.waitForURL('**/web/mfa'); + + await submitTotpCode( + page, + page.getByRole('textbox', { name: 'TOTP Code' }), + page.getByRole('button', { name: 'Log in' }), + page.getByRole('button', { name: 'navigation-menu' }) + ); +} + // Test login for a user with a pre-configured TOTP authenticator test('Login - MFA - TOTP Login', async ({ page }) => { await page.context().clearCookies(); @@ -533,39 +603,106 @@ test('Login - MFA - TOTP Login', async ({ page }) => { // We should still be on the MFA page await expect(page).toHaveURL(/\/web\/mfa/); - const totp = new TOTP({ - secret: mfauser.totpSecret, - digits: 6, - period: 30 - }); - - // Codes are single-use per period (server side), and the same user may be - // logging in from another browser project concurrently. - // So, if a code is rejected, wait for the next period and try again. - let lastToken = ''; - - for (let attempt = 0; attempt < 3; attempt++) { - while (totp.generate() == lastToken) { - await page.waitForTimeout(1000); - } - - lastToken = totp.generate(); - - await codeInput.fill(lastToken); - await page.getByRole('button', { name: 'Log in' }).click(); - - const loggedIn = await page - .getByRole('button', { name: 'navigation-menu' }) - .waitFor({ timeout: 5000 }) - .then(() => true) - .catch(() => false); - - if (loggedIn) { - break; - } - } + await submitTotpCode( + page, + codeInput, + page.getByRole('button', { name: 'Log in' }), + page.getByRole('button', { name: 'navigation-menu' }) + ); await page.getByRole('tab', { name: 'Dashboard' }).waitFor(); await page.getByRole('button', { name: 'navigation-menu' }).waitFor(); await page.waitForURL(/\/web(\/home)?/); }); + +/* + * Regression test: MFA users must re-authenticate to generate an API token, + * once their session is older than the reauthentication timeout. + * + * The server-side timeout (5 minutes) is too long to wait for here, + * so the first token request is answered with the "reauthentication_required" + * response which the server returns for an aged session. + * The reauthentication itself, and the final token request, hit the real server. + * The server-side check is covered by the backend unit tests. + */ +test('Login - MFA - Token Reauthentication', async ({ page }) => { + await doMfaLogin(page); + + // Intercept the first token request only + let intercepted = 0; + + await page.route('**/api/user/me/token/**', async (route) => { + const request = route.request(); + + if (request.method() !== 'GET' || intercepted > 0) { + return route.fallback(); + } + + intercepted += 1; + + await route.fulfill({ + status: 401, + contentType: 'application/json', + headers: { + 'access-control-allow-origin': request.headers()['origin'] ?? '*', + 'access-control-allow-credentials': 'true' + }, + body: JSON.stringify({ + id: 'reauthentication_required', + detail: 'Reauthentication is required to generate a token', + data: { + flows: [ + { id: 'reauthenticate' }, + { id: 'mfa_reauthenticate', types: ['totp', 'recovery_codes'] } + ] + } + }) + }); + }); + + await navigate(page, 'settings/user/security', { waitUntil: 'networkidle' }); + await page.getByRole('button', { name: 'Access Tokens' }).click(); + await page + .getByRole('button', { name: 'action-button-generate-token' }) + .click(); + + const tokenForm = page.getByRole('dialog', { name: 'Generate Token' }); + const tokenName = `mfa-reauth-${Date.now()}`; + + await tokenForm + .getByRole('textbox', { name: 'text-field-name' }) + .fill(tokenName); + await tokenForm.getByRole('button', { name: 'Submit', exact: true }).click(); + + // The form reports the error, and the TOTP reauthentication modal opens + await tokenForm + .getByText('Reauthentication is required to generate a token') + .waitFor(); + + const reauthModal = page.getByRole('dialog', { name: 'Reauthenticate' }); + await reauthModal + .getByText('Reauthentication is required to continue') + .waitFor(); + + await submitTotpCode( + page, + reauthModal.getByRole('textbox', { name: 'TOTP Code' }), + reauthModal.getByRole('button', { name: 'Submit', exact: true }), + page.getByText('You have been reauthenticated successfully') + ); + + await reauthModal.waitFor({ state: 'hidden' }); + + // The token form is still open - submit it again + await tokenForm.getByRole('button', { name: 'Submit', exact: true }).click(); + + await page.getByText('Tokens are only shown once').waitFor(); + expect(intercepted).toBe(1); + + // Close the token display, and check the new token is listed + await page + .getByTestId('generated-api-token') + .locator('.mantine-CloseButton-root') + .click(); + await page.getByRole('cell', { name: tokenName }).waitFor(); +});