) => {
setOtpCode(e.target.value);
@@ -65,11 +57,11 @@ export function ReauthenticateStep({
) => {
e.preventDefault();
if (!validator.validate()) return;
- submitWithMfa(mfaOption, otpCode).then(next);
+ submitWithMfa(mfaOption, 'mfa', otpCode).then(([, err]) => {
+ if (!err) next();
+ });
};
- const errorMessage = getReauthenticationErrorMessage(reauthAttempt);
-
return (
@@ -79,14 +71,16 @@ export function ReauthenticateStep({
title="Verify Identity"
/>
- {errorMessage && {errorMessage}}
+ {submitAttempt.status === 'error' && (
+ {submitAttempt.statusText}
+ )}
{mfaOption && Multi-factor type}
{({ validator }) => (
);
}
-
-function getReauthenticationErrorMessage(attempt: Attempt): string {
- if (attempt.status === 'failed') {
- // This message relies on the status message produced by the auth server in
- // lib/auth/Server.checkOTP function. Please keep these in sync.
- if (attempt.statusText === 'invalid totp token') {
- return 'Invalid authenticator code';
- } else {
- return attempt.statusText;
- }
- }
-}
diff --git a/web/packages/teleport/src/Discover/ConnectMyComputer/TestConnection/TestConnection.tsx b/web/packages/teleport/src/Discover/ConnectMyComputer/TestConnection/TestConnection.tsx
index 1b5ae9e6a780a..eddce1b82a260 100644
--- a/web/packages/teleport/src/Discover/ConnectMyComputer/TestConnection/TestConnection.tsx
+++ b/web/packages/teleport/src/Discover/ConnectMyComputer/TestConnection/TestConnection.tsx
@@ -170,13 +170,13 @@ export function TestConnection(props: AgentStepProps) {
{showMfaDialog && (
- testConnection({
+ onMfaResponse={async res => {
+ await testConnection({
login: selectedLoginOpt.value,
sshPrincipalSelectionMode,
mfaResponse: res,
- })
- }
+ });
+ }}
onClose={cancelMfaDialog}
challengeScope={MfaChallengeScope.USER_SESSION}
/>
diff --git a/web/packages/teleport/src/Discover/Kubernetes/TestConnection/TestConnection.tsx b/web/packages/teleport/src/Discover/Kubernetes/TestConnection/TestConnection.tsx
index 6933383168cd6..d2c3a0812f48e 100644
--- a/web/packages/teleport/src/Discover/Kubernetes/TestConnection/TestConnection.tsx
+++ b/web/packages/teleport/src/Discover/Kubernetes/TestConnection/TestConnection.tsx
@@ -101,7 +101,9 @@ export function TestConnection({
{showMfaDialog && (
testConnection(makeTestConnRequest(), res)}
+ onMfaResponse={async res =>
+ testConnection(makeTestConnRequest(), res)
+ }
onClose={cancelMfaDialog}
challengeScope={MfaChallengeScope.USER_SESSION}
/>
diff --git a/web/packages/teleport/src/Discover/Server/TestConnection/TestConnection.tsx b/web/packages/teleport/src/Discover/Server/TestConnection/TestConnection.tsx
index 54195cb3e6cc1..403b38feab3af 100644
--- a/web/packages/teleport/src/Discover/Server/TestConnection/TestConnection.tsx
+++ b/web/packages/teleport/src/Discover/Server/TestConnection/TestConnection.tsx
@@ -87,7 +87,7 @@ export function TestConnection(props: AgentStepProps) {
{showMfaDialog && (
testConnection(selectedOpt.value, res)}
+ onMfaResponse={async res => testConnection(selectedOpt.value, res)}
onClose={cancelMfaDialog}
challengeScope={MfaChallengeScope.USER_SESSION}
/>
diff --git a/web/packages/teleport/src/components/ReAuthenticate/ReAuthenticate.tsx b/web/packages/teleport/src/components/ReAuthenticate/ReAuthenticate.tsx
index ac293d4c7da7d..7add4a659fb55 100644
--- a/web/packages/teleport/src/components/ReAuthenticate/ReAuthenticate.tsx
+++ b/web/packages/teleport/src/components/ReAuthenticate/ReAuthenticate.tsx
@@ -16,34 +16,32 @@
* along with this program. If not, see .
*/
-import React, { useEffect, useState } from 'react';
import {
- Flex,
Box,
- Text,
ButtonPrimary,
ButtonSecondary,
+ Flex,
Indicator,
+ Text,
} from 'design';
+import { Alert, Danger } from 'design/Alert';
import Dialog, {
- DialogHeader,
- DialogTitle,
DialogContent,
DialogFooter,
+ DialogHeader,
+ DialogTitle,
} from 'design/Dialog';
-import { Alert, Danger } from 'design/Alert';
-import Validation from 'shared/components/Validation';
-import { requiredToken } from 'shared/components/Validation/rules';
+import React, { useEffect, useState } from 'react';
import FieldInput from 'shared/components/FieldInput';
import FieldSelect from 'shared/components/FieldSelect';
-
-import { useAsync } from 'shared/hooks/useAsync';
+import Validation, { Validator } from 'shared/components/Validation';
+import { requiredToken } from 'shared/components/Validation/rules';
import { MfaOption } from 'teleport/services/mfa';
import useReAuthenticate, {
- ReauthState,
ReauthProps,
+ ReauthState,
} from './useReAuthenticate';
export type Props = ReauthProps & {
@@ -61,26 +59,21 @@ export type State = ReauthState & {
export function ReAuthenticate({
onClose,
- attempt,
- clearAttempt,
- getMfaChallengeOptions,
+ initAttempt,
+ mfaOptions,
submitWithMfa,
+ submitAttempt,
+ clearSubmitAttempt,
}: State) {
const [otpCode, setOtpToken] = useState('');
const [mfaOption, setMfaOption] = useState();
- const [challengeOptions, getChallengeOptions] = useAsync(async () => {
- const mfaOptions = await getMfaChallengeOptions();
- setMfaOption(mfaOptions[0]);
- return mfaOptions;
- });
-
useEffect(() => {
- getChallengeOptions();
- }, []);
+ if (mfaOptions?.length) setMfaOption(mfaOptions[0]);
+ }, [mfaOptions]);
// Handle potential error states first.
- switch (challengeOptions.status) {
+ switch (initAttempt.status) {
case 'processing':
return (
@@ -88,16 +81,20 @@ export function ReAuthenticate({
);
case 'error':
- return ;
+ return ;
case 'success':
break;
default:
return null;
}
- function onSubmit(e: React.MouseEvent) {
+ function onReauthenticate(
+ e: React.MouseEvent,
+ validator: Validator
+ ) {
e.preventDefault();
- submitWithMfa(mfaOption.value, otpCode);
+ if (!validator.validate()) return;
+ submitWithMfa(mfaOption.value, 'mfa', otpCode);
}
return (
@@ -119,9 +116,9 @@ export function ReAuthenticate({
two-factor devices before performing this action.
- {attempt.status === 'failed' && (
+ {submitAttempt.status === 'error' && (
- {attempt.statusText}
+ {submitAttempt.statusText}
)}
@@ -130,15 +127,15 @@ export function ReAuthenticate({
width="60%"
label="Two-factor Type"
value={mfaOption}
- options={challengeOptions.data}
+ options={mfaOptions}
onChange={(o: MfaOption) => {
setMfaOption(o);
- clearAttempt();
+ clearSubmitAttempt();
}}
data-testid="mfa-select"
mr={3}
mb={0}
- isDisabled={attempt.status === 'processing'}
+ isDisabled={submitAttempt.status === 'processing'}
elevated={true}
/>
@@ -151,7 +148,7 @@ export function ReAuthenticate({
value={otpCode}
onChange={e => setOtpToken(e.target.value)}
placeholder="123 456"
- readonly={attempt.status === 'processing'}
+ readonly={submitAttempt.status === 'processing'}
mb={0}
/>
)}
@@ -160,8 +157,8 @@ export function ReAuthenticate({
validator.validate() && onSubmit(e)}
- disabled={attempt.status === 'processing'}
+ onClick={e => onReauthenticate(e, validator)}
+ disabled={submitAttempt.status === 'processing'}
mr={3}
mt={3}
type="submit"
diff --git a/web/packages/teleport/src/components/ReAuthenticate/useReAuthenticate.ts b/web/packages/teleport/src/components/ReAuthenticate/useReAuthenticate.ts
index 7c4b11d132164..ed8c73f3fe6da 100644
--- a/web/packages/teleport/src/components/ReAuthenticate/useReAuthenticate.ts
+++ b/web/packages/teleport/src/components/ReAuthenticate/useReAuthenticate.ts
@@ -16,112 +16,141 @@
* along with this program. If not, see .
*/
-import { useState } from 'react';
-import useAttempt, { Attempt } from 'shared/hooks/useAttemptNext';
+import { useCallback, useEffect, useState } from 'react';
+import { Attempt, makeEmptyAttempt, useAsync } from 'shared/hooks/useAsync';
import auth from 'teleport/services/auth';
import { MfaChallengeScope } from 'teleport/services/auth/auth';
import {
- getMfaChallengeOptions as getChallengeOptions,
DeviceType,
+ DeviceUsage,
+ getMfaChallengeOptions,
MfaAuthenticateChallenge,
MfaChallengeResponse,
MfaOption,
} from 'teleport/services/mfa';
-export default function useReAuthenticate(props: ReauthProps): ReauthState {
- // Note that attempt state "success" is not used or required.
- // After the user submits, the control is passed back
- // to the caller who is responsible for rendering the `ReAuthenticate`
- // component.
- const { attempt, setAttempt } = useAttempt('');
-
- const [challenge, setMfaChallenge] = useState(null);
-
- // Provide a custom error handler to catch a webauthn frontend error that occurs
- // on Firefox and replace it with a more helpful error message.
- const handleError = (err: Error) => {
- if (err.message.includes('attempt was made to use an object that is not')) {
- setAttempt({
- status: 'failed',
- statusText:
- 'The two-factor device you used is not registered on this account. You must verify using a device that has already been registered.',
- });
- return;
- } else {
- setAttempt({ status: 'failed', statusText: err.message });
- return;
- }
- };
-
- async function getMfaChallenge() {
- if (challenge) {
- return challenge;
- }
+export default function useReAuthenticate({
+ challengeScope,
+ onMfaResponse,
+}: ReauthProps): ReauthState {
+ const [mfaOptions, setMfaOptions] = useState();
+ const [challengeState, setChallengeState] = useState();
- return auth.getMfaChallenge({ scope: props.challengeScope }).then(chal => {
- setMfaChallenge(chal);
- return chal;
+ const [initAttempt, init] = useAsync(async () => {
+ const challenge = await auth.getMfaChallenge({
+ scope: challengeScope,
});
- }
-
- function clearMfaChallenge() {
- setMfaChallenge(null);
- }
-
- function getMfaChallengeOptions() {
- return getMfaChallenge().then(getChallengeOptions);
- }
-
- function submitWithMfa(mfaType?: DeviceType, totp_code?: string) {
- setAttempt({ status: 'processing' });
- return getMfaChallenge()
- .then(chal => auth.getMfaChallengeResponse(chal, mfaType, totp_code))
- .then(props.onMfaResponse)
- .finally(clearMfaChallenge)
- .catch(handleError);
- }
-
- function submitWithPasswordless() {
- setAttempt({ status: 'processing' });
- // Always get a new passwordless challenge, the challenge stored in state is for mfa
- // and will also be overwritten in the backend by the passwordless challenge.
- return auth
- .getMfaChallenge({
- scope: props.challengeScope,
- userVerificationRequirement: 'required',
- })
- .then(chal => auth.getMfaChallengeResponse(chal, 'webauthn'))
- .then(props.onMfaResponse)
- .finally(clearMfaChallenge)
- .catch(handleError);
- }
- function clearAttempt() {
- setAttempt({ status: '' });
+ setChallengeState({ challenge, deviceUsage: 'mfa' });
+ setMfaOptions(getMfaChallengeOptions(challenge));
+ });
+
+ useEffect(() => {
+ init();
+ }, []);
+
+ const getChallenge = useCallback(
+ async (deviceUsage: DeviceUsage = 'mfa') => {
+ if (challengeState?.deviceUsage === deviceUsage) {
+ return challengeState.challenge;
+ }
+
+ // If the challenge state is empty, used, or has different args,
+ // retrieve a new mfa challenge and set it in the state.
+ const challenge = await auth.getMfaChallenge({
+ scope: challengeScope,
+ userVerificationRequirement:
+ deviceUsage === 'passwordless' ? 'required' : 'discouraged',
+ });
+ setChallengeState({
+ challenge,
+ deviceUsage,
+ });
+ return challenge;
+ },
+ [challengeState, challengeScope]
+ );
+
+ const [submitAttempt, submitWithMfa, setSubmitAttempt] = useAsync(
+ useCallback(
+ async (
+ mfaType?: DeviceType,
+ deviceUsage?: DeviceUsage,
+ totpCode?: string
+ ) => {
+ const challenge = await getChallenge(deviceUsage);
+
+ let response: MfaChallengeResponse;
+ try {
+ response = await auth.getMfaChallengeResponse(
+ challenge,
+ mfaType,
+ totpCode
+ );
+ } catch (err) {
+ throw new Error(getReAuthenticationErrorMessage(err));
+ }
+
+ try {
+ await onMfaResponse(response);
+ } finally {
+ // once onMfaResponse is called, assume the challenge
+ // has been consumed and clear the state.
+ setChallengeState(null);
+ }
+ },
+ [getChallenge, onMfaResponse]
+ )
+ );
+
+ function clearSubmitAttempt() {
+ setSubmitAttempt(makeEmptyAttempt());
}
return {
- attempt,
- clearAttempt,
- getMfaChallenge,
- getMfaChallengeOptions,
+ initAttempt,
+ mfaOptions,
submitWithMfa,
- submitWithPasswordless,
+ submitAttempt,
+ clearSubmitAttempt,
};
}
export type ReauthProps = {
challengeScope: MfaChallengeScope;
- onMfaResponse(res: MfaChallengeResponse): void;
+ onMfaResponse(res: MfaChallengeResponse): Promise;
};
export type ReauthState = {
- attempt: Attempt;
- clearAttempt: () => void;
- getMfaChallenge: () => Promise;
- getMfaChallengeOptions: () => Promise;
- submitWithMfa: (mfaType?: DeviceType, totp_code?: string) => Promise;
- submitWithPasswordless: () => Promise;
+ initAttempt: Attempt;
+ mfaOptions: MfaOption[];
+ submitWithMfa: (
+ mfaType?: DeviceType,
+ deviceUsage?: DeviceUsage,
+ totpCode?: string
+ ) => Promise<[void, Error]>;
+ submitAttempt: Attempt;
+ clearSubmitAttempt: () => void;
+};
+
+type challengeState = {
+ challenge: MfaAuthenticateChallenge;
+ deviceUsage: DeviceUsage;
};
+
+function getReAuthenticationErrorMessage(err: Error): string {
+ if (err.message.includes('attempt was made to use an object that is not')) {
+ // Catch a webauthn frontend error that occurs on Firefox and replace it with a more helpful error message.
+ return 'The two-factor device you used is not registered on this account. You must verify using a device that has already been registered.';
+ }
+
+ if (err.message === 'invalid totp token') {
+ // This message relies on the status message produced by the auth server in
+ // lib/auth/Server.checkOTP function. Please keep these in sync.
+ return 'Invalid authenticator code';
+ }
+
+ return err.message;
+}
diff --git a/web/packages/teleport/src/services/auth/auth.ts b/web/packages/teleport/src/services/auth/auth.ts
index 45cadd4a8fdf0..3724f1dc8b056 100644
--- a/web/packages/teleport/src/services/auth/auth.ts
+++ b/web/packages/teleport/src/services/auth/auth.ts
@@ -328,8 +328,8 @@ const auth = {
existingMfaResponse,
// TODO(Joerger): DELETE IN v19.0.0
// Also provide totp/webauthn response in backwards compatible format.
- secondFactorToken: existingMfaResponse.totp_code,
- webauthnAssertionResponse: existingMfaResponse.webauthn_response,
+ secondFactorToken: existingMfaResponse?.totp_code,
+ webauthnAssertionResponse: existingMfaResponse?.webauthn_response,
});
},