Skip to content

Commit fc3ffd8

Browse files
authoredMar 29, 2024··
feat(clerk-js,localizations,shared,types): Prompt user to reset pwned password at sign-in (#3034)
1 parent 9879949 commit fc3ffd8

38 files changed

+1124
-63
lines changed
 

‎.changeset/flat-geese-applaud.md

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@clerk/localizations': minor
3+
'@clerk/clerk-js': minor
4+
'@clerk/shared': minor
5+
'@clerk/types': minor
6+
---
7+
8+
Support for prompting a user to reset their password if it is found to be compromised during sign-in.

‎packages/clerk-js/src/ui/components/SignIn/AlternativeMethods.tsx

+53-15
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@ import { SignInSocialButtons } from './SignInSocialButtons';
1212
import { useResetPasswordFactor } from './useResetPasswordFactor';
1313
import { withHavingTrouble } from './withHavingTrouble';
1414

15+
type AlternativeMethodsMode = 'forgot' | 'pwned' | 'default';
16+
1517
export type AlternativeMethodsProps = {
1618
onBackLinkClick: React.MouseEventHandler | undefined;
1719
onFactorSelected: (factor: SignInFactor) => void;
1820
currentFactor: SignInFactor | undefined | null;
19-
asForgotPassword?: boolean;
21+
mode?: AlternativeMethodsMode;
2022
};
2123

2224
export type AlternativeMethodListProps = AlternativeMethodsProps & { onHavingTroubleClick: React.MouseEventHandler };
@@ -28,26 +30,24 @@ export const AlternativeMethods = (props: AlternativeMethodsProps) => {
2830
};
2931

3032
const AlternativeMethodsList = (props: AlternativeMethodListProps) => {
31-
const { onBackLinkClick, onHavingTroubleClick, onFactorSelected, asForgotPassword = false } = props;
33+
const { onBackLinkClick, onHavingTroubleClick, onFactorSelected, mode = 'default' } = props;
3234
const card = useCardState();
3335
const resetPasswordFactor = useResetPasswordFactor();
3436
const { firstPartyFactors, hasAnyStrategy } = useAlternativeStrategies({
3537
filterOutFactor: props?.currentFactor,
3638
});
3739

40+
const flowPart = determineFlowPart(mode);
41+
const cardTitleKey = determineTitle(mode);
42+
const isReset = determineIsReset(mode);
43+
3844
return (
39-
<Flow.Part part={asForgotPassword ? 'forgotPasswordMethods' : 'alternativeMethods'}>
45+
<Flow.Part part={flowPart}>
4046
<Card.Root>
4147
<Card.Content>
4248
<Header.Root showLogo>
43-
<Header.Title
44-
localizationKey={localizationKeys(
45-
asForgotPassword ? 'signIn.forgotPasswordAlternativeMethods.title' : 'signIn.alternativeMethods.title',
46-
)}
47-
/>
48-
{!asForgotPassword && (
49-
<Header.Subtitle localizationKey={localizationKeys('signIn.alternativeMethods.subtitle')} />
50-
)}
49+
<Header.Title localizationKey={cardTitleKey} />
50+
{!isReset && <Header.Subtitle localizationKey={localizationKeys('signIn.alternativeMethods.subtitle')} />}
5151
</Header.Root>
5252
<Card.Alert>{card.error}</Card.Alert>
5353
{/*TODO: extract main in its own component */}
@@ -56,15 +56,18 @@ const AlternativeMethodsList = (props: AlternativeMethodListProps) => {
5656
elementDescriptor={descriptors.main}
5757
gap={6}
5858
>
59-
{asForgotPassword && resetPasswordFactor && (
59+
{isReset && resetPasswordFactor && (
6060
<Button
6161
localizationKey={getButtonLabel(resetPasswordFactor)}
6262
elementDescriptor={descriptors.alternativeMethodsBlockButton}
6363
isDisabled={card.isLoading}
64-
onClick={() => onFactorSelected(resetPasswordFactor)}
64+
onClick={() => {
65+
card.setError(undefined);
66+
onFactorSelected(resetPasswordFactor);
67+
}}
6568
/>
6669
)}
67-
{asForgotPassword && hasAnyStrategy && (
70+
{isReset && hasAnyStrategy && (
6871
<Divider
6972
dividerText={localizationKeys('signIn.forgotPasswordAlternativeMethods.label__alternativeMethods')}
7073
/>
@@ -90,7 +93,10 @@ const AlternativeMethodsList = (props: AlternativeMethodListProps) => {
9093
key={i}
9194
textVariant='buttonLarge'
9295
isDisabled={card.isLoading}
93-
onClick={() => onFactorSelected(factor)}
96+
onClick={() => {
97+
card.setError(undefined);
98+
onFactorSelected(factor);
99+
}}
94100
/>
95101
))}
96102
</Flex>
@@ -161,3 +167,35 @@ export function getButtonIcon(factor: SignInFactor) {
161167

162168
return icons[factor.strategy as keyof typeof icons];
163169
}
170+
171+
function determineFlowPart(mode: AlternativeMethodsMode) {
172+
switch (mode) {
173+
case 'forgot':
174+
return 'forgotPasswordMethods';
175+
case 'pwned':
176+
return 'passwordPwnedMethods';
177+
default:
178+
return 'alternativeMethods';
179+
}
180+
}
181+
182+
function determineTitle(mode: AlternativeMethodsMode): LocalizationKey {
183+
switch (mode) {
184+
case 'forgot':
185+
return localizationKeys('signIn.forgotPasswordAlternativeMethods.title');
186+
case 'pwned':
187+
return localizationKeys('signIn.passwordPwned.title');
188+
default:
189+
return localizationKeys('signIn.alternativeMethods.title');
190+
}
191+
}
192+
193+
function determineIsReset(mode: AlternativeMethodsMode): boolean {
194+
switch (mode) {
195+
case 'forgot':
196+
case 'pwned':
197+
return true;
198+
default:
199+
return false;
200+
}
201+
}

‎packages/clerk-js/src/ui/components/SignIn/SignInFactorOne.tsx

+17-3
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import React from 'react';
33

44
import { withRedirectToAfterSignIn } from '../../common';
55
import { useCoreSignIn, useEnvironment } from '../../contexts';
6-
import { ErrorCard, LoadingCard, withCardStateProvider } from '../../elements';
6+
import { ErrorCard, LoadingCard, useCardState, withCardStateProvider } from '../../elements';
77
import { useAlternativeStrategies } from '../../hooks/useAlternativeStrategies';
88
import { localizationKeys } from '../../localization';
99
import { useRouter } from '../../router';
@@ -36,6 +36,7 @@ export function _SignInFactorOne(): JSX.Element {
3636
const { preferredSignInStrategy } = useEnvironment().displayConfig;
3737
const availableFactors = signIn.supportedFirstFactors;
3838
const router = useRouter();
39+
const card = useCardState();
3940

4041
const lastPreparedFactorKeyRef = React.useRef('');
4142
const [{ currentFactor }, setFactor] = React.useState<{
@@ -58,6 +59,8 @@ export function _SignInFactorOne(): JSX.Element {
5859

5960
const [showForgotPasswordStrategies, setShowForgotPasswordStrategies] = React.useState(false);
6061

62+
const [isPasswordPwned, setIsPasswordPwned] = React.useState(false);
63+
6164
React.useEffect(() => {
6265
// Handle the case where a user lands on alternative methods screen,
6366
// clicks a social button but then navigates back to sign in.
@@ -94,11 +97,18 @@ export function _SignInFactorOne(): JSX.Element {
9497
const canGoBack = factorHasLocalStrategy(currentFactor);
9598

9699
const toggle = showAllStrategies ? toggleAllStrategies : toggleForgotPasswordStrategies;
100+
const backHandler = () => {
101+
card.setError(undefined);
102+
setIsPasswordPwned(false);
103+
toggle?.();
104+
};
105+
106+
const mode = showForgotPasswordStrategies ? (isPasswordPwned ? 'pwned' : 'forgot') : 'default';
97107

98108
return (
99109
<AlternativeMethods
100-
asForgotPassword={showForgotPasswordStrategies}
101-
onBackLinkClick={canGoBack ? toggle : undefined}
110+
mode={mode}
111+
onBackLinkClick={canGoBack ? backHandler : undefined}
102112
onFactorSelected={f => {
103113
selectFactor(f);
104114
toggle?.();
@@ -135,6 +145,10 @@ export function _SignInFactorOne(): JSX.Element {
135145
}}
136146
onForgotPasswordMethodClick={resetPasswordFactor ? toggleForgotPasswordStrategies : toggleAllStrategies}
137147
onShowAlternativeMethodsClick={toggleAllStrategies}
148+
onPasswordPwned={() => {
149+
setIsPasswordPwned(true);
150+
toggleForgotPasswordStrategies();
151+
}}
138152
/>
139153
);
140154
case 'email_code':

‎packages/clerk-js/src/ui/components/SignIn/SignInFactorOnePasswordCard.tsx

+9-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { isUserLockedError } from '@clerk/shared/error';
1+
import { isPasswordPwnedError, isUserLockedError } from '@clerk/shared/error';
22
import { useClerk } from '@clerk/shared/react';
33
import type { ResetPasswordCodeFactor } from '@clerk/types';
44
import React from 'react';
@@ -17,6 +17,7 @@ type SignInFactorOnePasswordProps = {
1717
onForgotPasswordMethodClick: React.MouseEventHandler | undefined;
1818
onShowAlternativeMethodsClick: React.MouseEventHandler | undefined;
1919
onFactorPrepare: (f: ResetPasswordCodeFactor) => void;
20+
onPasswordPwned?: () => void;
2021
};
2122

2223
const usePasswordControl = (props: SignInFactorOnePasswordProps) => {
@@ -45,7 +46,7 @@ const usePasswordControl = (props: SignInFactorOnePasswordProps) => {
4546
};
4647

4748
export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps) => {
48-
const { onShowAlternativeMethodsClick } = props;
49+
const { onShowAlternativeMethodsClick, onPasswordPwned } = props;
4950
const card = useCardState();
5051
const { setActive } = useClerk();
5152
const signIn = useCoreSignIn();
@@ -81,6 +82,12 @@ export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps)
8182
return clerk.__internal_navigateWithError('..', err.errors[0]);
8283
}
8384

85+
if (isPasswordPwnedError(err) && onPasswordPwned) {
86+
card.setError({ ...err.errors[0], code: 'form_password_pwned__sign_in' });
87+
onPasswordPwned();
88+
return;
89+
}
90+
8491
handleError(err, [passwordControl], card.setError);
8592
});
8693
};

‎packages/clerk-js/src/ui/components/SignIn/__tests__/SignInFactorOne.test.tsx

+147
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,153 @@ describe('SignInFactorOne', () => {
227227
});
228228
});
229229
});
230+
231+
it('Prompts the user to reset their password via email if it has been pwned', async () => {
232+
const { wrapper, fixtures } = await createFixtures(f => {
233+
f.withEmailAddress();
234+
f.withPassword();
235+
f.withPreferredSignInStrategy({ strategy: 'password' });
236+
f.startSignInWithEmailAddress({
237+
supportPassword: true,
238+
supportEmailCode: true,
239+
supportResetPassword: true,
240+
});
241+
});
242+
fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));
243+
244+
const errJSON = {
245+
code: 'form_password_pwned',
246+
long_message:
247+
'Password has been found in an online data breach. For account safety, please reset your password.',
248+
message: 'Password has been found in an online data breach. For account safety, please reset your password.',
249+
meta: { param_name: 'password' },
250+
};
251+
252+
fixtures.signIn.attemptFirstFactor.mockRejectedValueOnce(
253+
new ClerkAPIResponseError('Error', {
254+
data: [errJSON],
255+
status: 422,
256+
}),
257+
);
258+
259+
await runFakeTimers(async () => {
260+
const { userEvent } = render(<SignInFactorOne />, { wrapper });
261+
await userEvent.type(screen.getByLabelText('Password'), '123456');
262+
await userEvent.click(screen.getByText('Continue'));
263+
264+
await waitFor(() => {
265+
screen.getByText('Password compromised');
266+
screen.getByText(
267+
'This password has been found as part of a breach and can not be used, please reset your password.',
268+
);
269+
screen.getByText('Or, sign in with another method');
270+
});
271+
272+
await userEvent.click(screen.getByText('Reset your password'));
273+
screen.getByText('First, enter the code sent to your email ID');
274+
});
275+
});
276+
277+
it('Prompts the user to reset their password via phone if it has been pwned', async () => {
278+
const { wrapper, fixtures } = await createFixtures(f => {
279+
f.withEmailAddress();
280+
f.withPassword();
281+
f.withPreferredSignInStrategy({ strategy: 'password' });
282+
f.startSignInWithPhoneNumber({
283+
supportPassword: true,
284+
supportPhoneCode: true,
285+
supportResetPassword: true,
286+
});
287+
});
288+
fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));
289+
290+
const errJSON = {
291+
code: 'form_password_pwned',
292+
long_message:
293+
'Password has been found in an online data breach. For account safety, please reset your password.',
294+
message: 'Password has been found in an online data breach. For account safety, please reset your password.',
295+
meta: { param_name: 'password' },
296+
};
297+
298+
fixtures.signIn.attemptFirstFactor.mockRejectedValueOnce(
299+
new ClerkAPIResponseError('Error', {
300+
data: [errJSON],
301+
status: 422,
302+
}),
303+
);
304+
305+
await runFakeTimers(async () => {
306+
const { userEvent } = render(<SignInFactorOne />, { wrapper });
307+
await userEvent.type(screen.getByLabelText('Password'), '123456');
308+
await userEvent.click(screen.getByText('Continue'));
309+
310+
await waitFor(() => {
311+
screen.getByText('Password compromised');
312+
screen.getByText(
313+
'This password has been found as part of a breach and can not be used, please reset your password.',
314+
);
315+
screen.getByText('Or, sign in with another method');
316+
});
317+
318+
await userEvent.click(screen.getByText('Reset your password'));
319+
screen.getByText('First, enter the code sent to your phone');
320+
});
321+
});
322+
323+
it('entering a pwned password, then going back and clicking forgot password should result in the correct title', async () => {
324+
const { wrapper, fixtures } = await createFixtures(f => {
325+
f.withEmailAddress();
326+
f.withPassword();
327+
f.withPreferredSignInStrategy({ strategy: 'password' });
328+
f.startSignInWithEmailAddress({
329+
supportPassword: true,
330+
supportEmailCode: true,
331+
supportResetPassword: true,
332+
});
333+
});
334+
fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));
335+
336+
const errJSON = {
337+
code: 'form_password_pwned',
338+
long_message:
339+
'Password has been found in an online data breach. For account safety, please reset your password.',
340+
message: 'Password has been found in an online data breach. For account safety, please reset your password.',
341+
meta: { param_name: 'password' },
342+
};
343+
344+
fixtures.signIn.attemptFirstFactor.mockRejectedValueOnce(
345+
new ClerkAPIResponseError('Error', {
346+
data: [errJSON],
347+
status: 422,
348+
}),
349+
);
350+
351+
await runFakeTimers(async () => {
352+
const { userEvent } = render(<SignInFactorOne />, { wrapper });
353+
await userEvent.type(screen.getByLabelText('Password'), '123456');
354+
await userEvent.click(screen.getByText('Continue'));
355+
356+
await waitFor(() => {
357+
screen.getByText('Password compromised');
358+
screen.getByText(
359+
'This password has been found as part of a breach and can not be used, please reset your password.',
360+
);
361+
screen.getByText('Or, sign in with another method');
362+
});
363+
364+
// Go back
365+
await userEvent.click(screen.getByText('Back'));
366+
367+
// Choose to reset password via "Forgot password" instead
368+
await userEvent.click(screen.getByText(/Forgot password/i));
369+
screen.getByText('Forgot Password?');
370+
expect(
371+
screen.queryByText(
372+
'This password has been found as part of a breach and can not be used, please reset your password.',
373+
),
374+
).not.toBeInTheDocument();
375+
});
376+
});
230377
});
231378

232379
describe('Forgot Password', () => {

‎packages/clerk-js/src/ui/elements/contexts/FlowMetadataContext.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ type FlowMetadata = {
2525
| 'emailLinkStatus'
2626
| 'alternativeMethods'
2727
| 'forgotPasswordMethods'
28+
| 'passwordPwnedMethods'
2829
| 'havingTrouble'
2930
| 'ssoCallback'
3031
| 'popover'

0 commit comments

Comments
 (0)
Please sign in to comment.