Skip to content

Commit

Permalink
Refactor ChangePasswordWizard to use useReauthenticate.
Browse files Browse the repository at this point in the history
  • Loading branch information
Joerger committed Dec 5, 2024
1 parent 04a0874 commit b9ce5f1
Show file tree
Hide file tree
Showing 12 changed files with 191 additions and 289 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,18 @@ import Dialog from 'design/Dialog';
import { createTeleportContext } from 'teleport/mocks/contexts';
import { ContextProvider } from 'teleport';

import { MfaDevice, WebauthnAssertionResponse } from 'teleport/services/mfa';
import {
MFA_OPTION_SSO_DEFAULT,
MFA_OPTION_TOTP,
WebauthnAssertionResponse,
} from 'teleport/services/mfa';

import {
ChangePasswordStep,
ChangePasswordWizardStepProps,
REAUTH_OPTION_PASSKEY,
REAUTH_OPTION_WEBAUTHN,
ReauthenticateStep,
createReauthOptions,
} from './ChangePasswordWizard';

export default {
Expand Down Expand Up @@ -60,44 +65,16 @@ export function ChangePasswordWithPasswordlessVerification() {
}

export function ChangePasswordWithMfaDeviceVerification() {
return <ChangePasswordStep {...stepProps} reauthMethod="mfaDevice" />;
return <ChangePasswordStep {...stepProps} reauthMethod="webauthn" />;
}

export function ChangePasswordWithOtpVerification() {
return <ChangePasswordStep {...stepProps} reauthMethod="otp" />;
return <ChangePasswordStep {...stepProps} reauthMethod="totp" />;
}

const devices: MfaDevice[] = [
{
id: '1',
description: 'Hardware Key',
name: 'touch_id',
registeredDate: new Date(1628799417000),
lastUsedDate: new Date(1628799417000),
type: 'webauthn',
usage: 'passwordless',
},
{
id: '2',
description: 'Hardware Key',
name: 'solokey',
registeredDate: new Date(1623722252000),
lastUsedDate: new Date(1623981452000),
type: 'webauthn',
usage: 'mfa',
},
{
id: '3',
description: 'Authenticator App',
name: 'iPhone',
registeredDate: new Date(1618711052000),
lastUsedDate: new Date(1626472652000),
type: 'totp',
usage: 'mfa',
},
];

const defaultReauthOptions = createReauthOptions('optional', true, devices);
export function ChangePasswordWithSsoVerification() {
return <ChangePasswordStep {...stepProps} reauthMethod="sso" />;
}

const stepProps = {
// StepComponentProps
Expand All @@ -109,14 +86,20 @@ const stepProps = {
refCallback: () => {},

// Shared props
reauthMethod: 'mfaDevice',
reauthMethod: 'passwordless',
onClose() {},
onSuccess() {},

// ReauthenticateStepProps
reauthOptions: defaultReauthOptions,
onReauthMethodChange() {},
onWebauthnResponse() {},
reauthOptions: [
REAUTH_OPTION_PASSKEY,
REAUTH_OPTION_WEBAUTHN,
MFA_OPTION_TOTP,
MFA_OPTION_SSO_DEFAULT,
],
onReauthMethodChange: () => {},
submitWithPasswordless: async () => {},
submitWithMfa: async () => {},

// ChangePasswordStepProps
webauthnResponse: {} as WebauthnAssertionResponse,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,19 @@ import { userEvent, UserEvent } from '@testing-library/user-event';

import auth, { MfaChallengeScope } from 'teleport/services/auth/auth';

import { MfaChallengeResponse } from 'teleport/services/mfa';
import {
MFA_OPTION_SSO_DEFAULT,
MFA_OPTION_TOTP,
MFA_OPTION_WEBAUTHN,
MfaChallengeResponse,
SSOChallenge,
} from 'teleport/services/mfa';

import {
ChangePasswordWizardProps,
createReauthOptions,
getReauthOptions,
REAUTH_OPTION_PASSKEY,
REAUTH_OPTION_WEBAUTHN,
} from './ChangePasswordWizard';

import { ChangePasswordWizard } from '.';
Expand Down Expand Up @@ -56,25 +64,10 @@ function twice(arr) {
return [...arr, ...arr];
}

// Repeat devices twice to make sure we support multiple devices of the same
// type and purpose.
const deviceCases = {
all: twice([
{ type: 'totp', usage: 'mfa' },
{ type: 'webauthn', usage: 'mfa' },
{ type: 'webauthn', usage: 'passwordless' },
]),
authApps: twice([{ type: 'totp', usage: 'mfa' }]),
mfaDevices: twice([{ type: 'webauthn', usage: 'mfa' }]),
passkeys: twice([{ type: 'webauthn', usage: 'passwordless' }]),
};

function TestWizard(props: Partial<ChangePasswordWizardProps> = {}) {
return (
<ChangePasswordWizard
auth2faType={'optional'}
passwordlessEnabled={true}
devices={deviceCases.all}
hasPasswordless={true}
onClose={() => {}}
onSuccess={onSuccess}
{...props}
Expand All @@ -86,7 +79,11 @@ beforeEach(() => {
user = userEvent.setup();
onSuccess = jest.fn();

jest.spyOn(auth, 'getMfaChallenge').mockResolvedValueOnce(undefined);
jest.spyOn(auth, 'getMfaChallenge').mockResolvedValueOnce({
totpChallenge: true,
webauthnPublicKey: {} as PublicKeyCredentialRequestOptions,
ssoChallenge: {} as SSOChallenge,
});
jest
.spyOn(auth, 'getMfaChallengeResponse')
.mockResolvedValueOnce(dummyChallengeResponse);
Expand Down Expand Up @@ -402,121 +399,20 @@ describe('with OTP MFA reauthentication', () => {
});
});

describe('without reauthentication', () => {
it('changes password', async () => {
render(<TestWizard auth2faType="off" passwordlessEnabled={false} />);

const changePasswordStep = within(
screen.getByTestId('change-password-step')
);
await user.type(
changePasswordStep.getByLabelText('Current Password'),
'current-pass'
);
await user.type(
changePasswordStep.getByLabelText('New Password'),
'new-pass1234'
);
await user.type(
changePasswordStep.getByLabelText('Confirm Password'),
'new-pass1234'
);
await user.click(changePasswordStep.getByText('Save Changes'));
expect(auth.getMfaChallenge).not.toHaveBeenCalled();
expect(auth.changePassword).toHaveBeenCalledWith({
oldPassword: 'current-pass',
newPassword: 'new-pass1234',
webauthnResponse: undefined,
secondFactorToken: '',
});
expect(onSuccess).toHaveBeenCalled();
});

it('cancels changing password', async () => {
render(<TestWizard auth2faType="off" passwordlessEnabled={false} />);

const changePasswordStep = within(
screen.getByTestId('change-password-step')
);
await user.type(
changePasswordStep.getByLabelText('New Password'),
'new-pass1234'
);
await user.type(
changePasswordStep.getByLabelText('Confirm Password'),
'new-pass1234'
);
await user.click(changePasswordStep.getByText('Cancel'));
expect(auth.changePassword).not.toHaveBeenCalled();
expect(onSuccess).not.toHaveBeenCalled();
});

it('validates the password form', async () => {
render(<TestWizard auth2faType="off" passwordlessEnabled={false} />);

const changePasswordStep = within(
screen.getByTestId('change-password-step')
);
await user.type(
changePasswordStep.getByLabelText('New Password'),
'new-pass123'
);
await user.type(
changePasswordStep.getByLabelText('Confirm Password'),
'new-pass123'
);
await user.click(changePasswordStep.getByText('Save Changes'));
expect(auth.changePassword).not.toHaveBeenCalled();
expect(onSuccess).not.toHaveBeenCalled();
expect(changePasswordStep.getByLabelText('New Password')).toBeInvalid();
expect(
changePasswordStep.getByLabelText('New Password')
).toHaveAccessibleDescription('Enter at least 12 characters');
expect(changePasswordStep.getByLabelText('Current Password')).toBeInvalid();
expect(
changePasswordStep.getByLabelText('Current Password')
).toHaveAccessibleDescription('Current Password is required');

await user.type(
changePasswordStep.getByLabelText('Current Password'),
'current-pass'
);
await user.type(
changePasswordStep.getByLabelText('New Password'),
'new-pass1234'
);
await user.click(changePasswordStep.getByText('Save Changes'));
expect(auth.changePassword).not.toHaveBeenCalled();
expect(onSuccess).not.toHaveBeenCalled();
expect(changePasswordStep.getByLabelText('Confirm Password')).toBeInvalid();
expect(
changePasswordStep.getByLabelText('Confirm Password')
).toHaveAccessibleDescription('Password does not match');
});
});

test.each`
auth2faType | passwordless | deviceCase | methods
${'otp'} | ${false} | ${'all'} | ${['otp']}
${'off'} | ${false} | ${'all'} | ${[]}
${'optional'} | ${false} | ${'all'} | ${['mfaDevice', 'otp']}
${'on'} | ${false} | ${'all'} | ${['mfaDevice', 'otp']}
${'webauthn'} | ${false} | ${'all'} | ${['mfaDevice']}
${'optional'} | ${true} | ${'all'} | ${['passwordless', 'mfaDevice', 'otp']}
${'on'} | ${true} | ${'all'} | ${['passwordless', 'mfaDevice', 'otp']}
${'webauthn'} | ${true} | ${'all'} | ${['passwordless', 'mfaDevice']}
${'optional'} | ${true} | ${'authApps'} | ${['otp']}
${'optional'} | ${true} | ${'mfaDevices'} | ${['mfaDevice']}
${'optional'} | ${true} | ${'passkeys'} | ${['passwordless']}
mfaOptions | hasPasswordless | reauthOptions
${[MFA_OPTION_TOTP]} | ${false} | ${[MFA_OPTION_TOTP]}
${[MFA_OPTION_WEBAUTHN]} | ${false} | ${[REAUTH_OPTION_WEBAUTHN]}
${[MFA_OPTION_SSO_DEFAULT]} | ${false} | ${[MFA_OPTION_SSO_DEFAULT]}
${[MFA_OPTION_TOTP, MFA_OPTION_WEBAUTHN, MFA_OPTION_SSO_DEFAULT]} | ${false} | ${[REAUTH_OPTION_PASSKEY, MFA_OPTION_TOTP, REAUTH_OPTION_WEBAUTHN, MFA_OPTION_SSO_DEFAULT]}
${[MFA_OPTION_WEBAUTHN]} | ${true} | ${[REAUTH_OPTION_PASSKEY, REAUTH_OPTION_WEBAUTHN]}
${[MFA_OPTION_TOTP, MFA_OPTION_WEBAUTHN, MFA_OPTION_SSO_DEFAULT]} | ${true} | ${[REAUTH_OPTION_PASSKEY, MFA_OPTION_TOTP, REAUTH_OPTION_WEBAUTHN, MFA_OPTION_SSO_DEFAULT]}
`(
'createReauthOptions: auth2faType=$auth2faType, passwordless=$passwordless, devices=$deviceCase',
({ auth2faType, passwordless, methods, deviceCase }) => {
const devices = deviceCases[deviceCase];
const reauthMethods = createReauthOptions(
auth2faType,
passwordless,
devices
).map(o => o.value);
expect(reauthMethods).toEqual(methods);
'getReauthOptions: mfaOptions=$mfaOptions, passwordless=$passwordless, devices=$deviceCase',
({ mfaOptions, hasPasswordless, reauthOptions }) => {
const gotReauthOptions = getReauthOptions(mfaOptions, hasPasswordless).map(
o => o.value
);
expect(gotReauthOptions).toEqual(reauthOptions);
}
);
Loading

0 comments on commit b9ce5f1

Please sign in to comment.