diff --git a/.changeset/reverification-reset-breached-password.md b/.changeset/reverification-reset-breached-password.md new file mode 100644 index 00000000000..4520ea80a29 --- /dev/null +++ b/.changeset/reverification-reset-breached-password.md @@ -0,0 +1,11 @@ +--- +'@clerk/shared': minor +'@clerk/clerk-js': minor +'@clerk/ui': minor +--- + +Let users whose password was found in a data breach pass reverification by resetting it. Until now the reverification modal only showed the breached-password error, so these users couldn't make any change that needs reverification, including changing that password. The modal now offers "Reset your password": the user sets a new password and enters a code sent to their email or phone, as in sign-in. Their other verification methods stay available. + +From UserProfile's change password form, the modal reuses the new password the user already entered, so they only enter the code. + +`session.prepareFirstFactorVerification()` and `session.attemptFirstFactorVerification()` accept the `reset_password_email_code` and `reset_password_phone_code` strategies, so custom flows can build the same reset. diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 1a2c4023e65..a59b2707d0b 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -269,6 +269,12 @@ export class Session extends BaseResource implements SessionResource { default: factor.default, } as PhoneCodeConfig; break; + case 'reset_password_email_code': + config = { emailAddressId: factor.emailAddressId }; + break; + case 'reset_password_phone_code': + config = { phoneNumberId: factor.phoneNumberId }; + break; case 'passkey': config = {}; break; diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index b776ddd1753..46bba6a304b 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -1171,6 +1171,82 @@ describe('Session', () => { }); }); + describe('reset password reverification', () => { + const sessionJSON = { + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON; + + const verificationResponse = { + object: 'session_verification', + id: 'sessver_1', + status: 'needs_first_factor', + level: 'first_factor', + session: sessionJSON, + first_factor_verification: null, + second_factor_verification: null, + supported_first_factors: null, + supported_second_factors: null, + }; + + let requestSpy: Mock; + + beforeEach(() => { + BaseResource.clerk = clerkMock(); + requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + requestSpy.mockResolvedValue({ payload: { response: verificationResponse }, status: 200 }); + }); + + afterEach(() => { + BaseResource.clerk = null as any; + }); + + it.each([ + [{ strategy: 'reset_password_email_code', emailAddressId: 'idn_1' }], + [{ strategy: 'reset_password_phone_code', phoneNumberId: 'idn_2' }], + ] as const)('prepares a first factor with %o', async factor => { + const session = new Session(sessionJSON); + + await session.prepareFirstFactorVerification(factor); + + expect(requestSpy).toHaveBeenCalledWith( + expect.objectContaining({ + body: factor, + method: 'POST', + path: '/client/sessions/session_1/verify/prepare_first_factor', + }), + expect.anything(), + ); + }); + + it('attempts a first factor with the code and the new password', async () => { + const session = new Session(sessionJSON); + const attempt = { + strategy: 'reset_password_email_code', + code: '123456', + password: 'a-much-better-password-123', + signOutOfOtherSessions: true, + } as const; + + await session.attemptFirstFactorVerification(attempt); + + expect(requestSpy).toHaveBeenCalledWith( + expect.objectContaining({ + body: attempt, + method: 'POST', + path: '/client/sessions/session_1/verify/attempt_first_factor', + }), + expect.anything(), + ); + }); + }); + describe('trusted device reverification', () => { const sessionJSON = { status: 'active', diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 069bc460a14..4a4f72d628a 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -1972,7 +1972,7 @@ export type __internal_UserVerificationProps = RoutingOptions & { /** * Non-awaitable callback for when verification is completed successfully */ - afterVerification?: () => void; + afterVerification?: (result?: __internal_UserVerificationResult) => void; /** * Non-awaitable callback for when verification is cancelled, (i.e modal is closed) @@ -1987,12 +1987,26 @@ export type __internal_UserVerificationProps = RoutingOptions & { */ level?: SessionVerificationLevel; + /** + * A new password the caller already collected. When the user resets their + * password to verify, it's used instead of asking for one. + */ + resetPassword?: { + password: string; + signOutOfOtherSessions: boolean; + }; + /** * Customization options to fully match the Clerk components to your own brand. These options serve as overrides and will be merged with the global `appearance` configuration (if one is provided). See the [`Appearance`](https://clerk.com/docs/guides/customizing-clerk/appearance-prop/overview) docs for more information. */ appearance?: ClerkAppearanceTheme; }; +export type __internal_UserVerificationResult = { + /** True when the user reset their password to verify. */ + passwordReset?: boolean; +}; + export type __internal_UserVerificationModalProps = WithoutRouting<__internal_UserVerificationProps>; export type ProtectCheckFlow = 'signIn' | 'signUp'; diff --git a/packages/shared/src/types/factors.ts b/packages/shared/src/types/factors.ts index 4531ebf7249..a155f449973 100644 --- a/packages/shared/src/types/factors.ts +++ b/packages/shared/src/types/factors.ts @@ -128,21 +128,25 @@ export type EnterpriseSSOFactor = { strategy: EnterpriseSSOStrategy; /** * The ID of the enterprise connection. + * * @experimental */ enterpriseConnectionId?: string; /** * The name of the enterprise connection. + * * @experimental */ enterpriseConnectionName?: string; /** * The public URL of the enterprise connection logo. + * * @experimental */ enterpriseConnectionLogoPublicUrl?: string | null; /** * The provider of the enterprise connection. + * * @experimental */ enterpriseConnectionProvider?: string; @@ -255,11 +259,13 @@ export type EnterpriseSSOConfig = EnterpriseSSOFactor & { oidcPrompt?: string; /** * The ID of the email address used for the enterprise SSO factor. + * * @experimental */ emailAddressId?: string; /** * The ID of the enterprise connection used for the enterprise SSO factor. + * * @experimental */ enterpriseConnectionId?: string; @@ -442,3 +448,24 @@ export type ResetPasswordEmailCodeAttempt = { */ password?: string; }; + +/** @generateWithEmptyComment */ +export type SessionVerifyResetPasswordAttempt = { + /** + * The strategy type. + */ + strategy: ResetPasswordEmailCodeStrategy | ResetPasswordPhoneCodeStrategy; + /** + * The one-time code sent to the user's email address or phone number. + */ + code: string; + /** + * The new password, which replaces the current one. Unlike a sign-in's reset + * attempt, it's required: the code only counts together with it. + */ + password: string; + /** + * Whether to sign the user out of their other sessions once the new password is set. + */ + signOutOfOtherSessions?: boolean; +}; diff --git a/packages/shared/src/types/session.ts b/packages/shared/src/types/session.ts index fdab3d21fa8..59f792a3ca9 100644 --- a/packages/shared/src/types/session.ts +++ b/packages/shared/src/types/session.ts @@ -10,6 +10,9 @@ import type { PhoneCodeAttempt, PhoneCodeConfig, PhoneCodeSecondFactorConfig, + ResetPasswordEmailCodeFactorConfig, + ResetPasswordPhoneCodeFactorConfig, + SessionVerifyResetPasswordAttempt, TOTPAttempt, TrustedDeviceAttempt, TrustedDeviceConfig, @@ -291,6 +294,7 @@ export interface SessionResource extends ClerkResource { getToken: GetToken; /** * Checks if the user is [authorized for the specified Role, Permission, Feature, or Plan](https://clerk.com/docs/guides/secure/authorization-checks) or requires the user to [reverify their credentials](https://clerk.com/docs/guides/secure/reverification) if their last verification is older than allowed. + * * @skipParametersSection */ checkAuthorization: CheckAuthorization; @@ -308,12 +312,15 @@ export interface SessionResource extends ClerkResource { updatedAt: Date; /** * Initiates the reverification flow. + * * @returns A [`SessionVerification`](https://clerk.com/docs/reference/types/session-verification) instance with its status and supported factors. */ startVerification: (params: SessionVerifyCreateParams) => Promise; /** * Initiates the [first factor verification](!first-factor-verification) process. This is a required step to complete a reverification flow when using a preparable factor. + * * @returns A [`SessionVerification`](https://clerk.com/docs/reference/types/session-verification) instance with its status and supported factors. + * * @skipParametersSection */ prepareFirstFactorVerification: ( @@ -321,7 +328,9 @@ export interface SessionResource extends ClerkResource { ) => Promise; /** * Attempts to complete the [first factor verification](!first-factor-verification) process. + * * @returns A [`SessionVerification`](https://clerk.com/docs/reference/types/session-verification) instance with its status and supported factors. + * * @skipParametersSection */ attemptFirstFactorVerification: ( @@ -329,7 +338,9 @@ export interface SessionResource extends ClerkResource { ) => Promise; /** * Initiates the [second factor verification](!second-factor-verification) process. This is a required step to complete a reverification flow when using a preparable factor. + * * @returns A [`SessionVerification`](https://clerk.com/docs/reference/types/session-verification) instance with its status and supported factors. + * * @skipParametersSection */ prepareSecondFactorVerification: ( @@ -337,7 +348,9 @@ export interface SessionResource extends ClerkResource { ) => Promise; /** * Attempts to complete the [second factor verification](!second-factor-verification) process. + * * @returns A [`SessionVerification`](https://clerk.com/docs/reference/types/session-verification) instance with its status and supported factors. + * * @skipParametersSection */ attemptSecondFactorVerification: ( @@ -351,6 +364,7 @@ export interface SessionResource extends ClerkResource { * in-progress multi-factor reverification instead. Any other `level` * value is rejected. Throws a `ClerkWebAuthnError` when WebAuthn is * unsupported or the passkey ceremony fails. + * * @returns A [`SessionVerification`](https://clerk.com/docs/reference/types/session-verification) instance with its status and supported factors. */ verifyWithPasskey: (params?: SessionVerifyWithPasskeyParams) => Promise; @@ -537,6 +551,8 @@ export type SessionVerifyPrepareFirstFactorParams = | EmailCodeConfig | PhoneCodeConfig | PassKeyConfig + | ResetPasswordEmailCodeFactorConfig + | ResetPasswordPhoneCodeFactorConfig /** * @experimental */ @@ -551,6 +567,7 @@ export type SessionVerifyAttemptFirstFactorParams = | PhoneCodeAttempt | PasswordAttempt | PasskeyAttempt + | SessionVerifyResetPasswordAttempt /** * @experimental */ diff --git a/packages/shared/src/types/sessionVerification.ts b/packages/shared/src/types/sessionVerification.ts index bcbd347d5fd..e2123bf2387 100644 --- a/packages/shared/src/types/sessionVerification.ts +++ b/packages/shared/src/types/sessionVerification.ts @@ -5,6 +5,8 @@ import type { PasskeyFactor, PasswordFactor, PhoneCodeFactor, + ResetPasswordEmailCodeFactor, + ResetPasswordPhoneCodeFactor, TOTPFactor, } from './factors'; import type { ClerkResource } from './resource'; @@ -55,6 +57,8 @@ export type SessionVerificationFirstFactor = | PhoneCodeFactor | PasswordFactor | PasskeyFactor + | ResetPasswordEmailCodeFactor + | ResetPasswordPhoneCodeFactor /** * @experimental */ diff --git a/packages/ui/bundlewatch.config.json b/packages/ui/bundlewatch.config.json index 3bfffc77315..5996d76567e 100644 --- a/packages/ui/bundlewatch.config.json +++ b/packages/ui/bundlewatch.config.json @@ -5,7 +5,7 @@ { "path": "./dist/ui.shared.browser.js", "maxSize": "44KB" }, { "path": "./dist/framework*.js", "maxSize": "44KB" }, { "path": "./dist/vendors*.js", "maxSize": "73KB" }, - { "path": "./dist/ui-common*.js", "maxSize": "139KB" }, + { "path": "./dist/ui-common*.js", "maxSize": "140KB" }, { "path": "./dist/signin*.js", "maxSize": "19KB" }, { "path": "./dist/signup*.js", "maxSize": "13KB" }, { "path": "./dist/userprofile*.js", "maxSize": "16KB" }, diff --git a/packages/ui/src/Components.tsx b/packages/ui/src/Components.tsx index 0fb3e678246..304d9683d2b 100644 --- a/packages/ui/src/Components.tsx +++ b/packages/ui/src/Components.tsx @@ -7,6 +7,7 @@ import type { __internal_ProtectCheckModalProps, __internal_SubscriptionDetailsProps, __internal_UserVerificationProps, + __internal_UserVerificationResult, Clerk, ClerkOptions, CreateOrganizationModalProps, @@ -468,8 +469,8 @@ const Components = (props: ComponentsProps) => { /** * When a UserVerification flow is completed, we need to close the modal without trigger a cancellation callback */ - afterVerification() { - props.afterVerification?.(); + afterVerification(result?: __internal_UserVerificationResult) { + props.afterVerification?.(result); componentsControls.closeModal(name, { notify: false }); }, }, diff --git a/packages/ui/src/components/UserProfile/PasswordForm.tsx b/packages/ui/src/components/UserProfile/PasswordForm.tsx index c9038469e86..c564a196c5e 100644 --- a/packages/ui/src/components/UserProfile/PasswordForm.tsx +++ b/packages/ui/src/components/UserProfile/PasswordForm.tsx @@ -1,4 +1,4 @@ -import { useReverification, useSession, useUser } from '@clerk/shared/react'; +import { useClerk, useReverification, useSession, useUser } from '@clerk/shared/react'; import type { UserResource } from '@clerk/shared/types'; import { useRef } from 'react'; @@ -16,6 +16,7 @@ import { useFormControl } from '@/ui/utils/useFormControl'; import { useEnvironment } from '../../contexts'; import { localizationKeys, useLocalizations } from '../../customizables'; import { useConfirmPassword } from '../../hooks'; +import type { UserVerificationResetPassword } from '../UserVerification/utils'; const generateSuccessPageText = (userHasPassword: boolean, sessionSignOut: boolean) => { const localizedTexts = []; @@ -38,8 +39,35 @@ export const PasswordForm = withCardStateProvider((props: PasswordFormProps) => const { t, locale } = useLocalizations(); const { onSuccess, onReset } = props; const { user } = useUser(); + const clerk = useClerk(); + // The new password the form is about to set. If reverification asks the user + // to reset a breached password, the modal uses it instead of asking again. + const pendingPasswordRef = useRef(); + // Set when the modal already changed the password, so the retry doesn't + // change it a second time. + const passwordResetDuringVerificationRef = useRef(false); const updatePasswordWithReverification = useReverification( - (user: UserResource, opts: Parameters) => user.updatePassword(...opts), + (user: UserResource, opts: Parameters) => { + if (passwordResetDuringVerificationRef.current) { + passwordResetDuringVerificationRef.current = false; + // The password already changed, so a failed refresh isn't a failed change. + return user.reload().catch(() => user); + } + return user.updatePassword(...opts); + }, + { + onNeedsReverification: ({ level, complete, cancel }) => { + clerk.__internal_openReverification({ + level, + resetPassword: pendingPasswordRef.current, + afterVerification: result => { + passwordResetDuringVerificationRef.current = !!result?.passwordReset; + complete(); + }, + afterVerificationCancelled: cancel, + }); + }, + }, ); if (!user) { @@ -125,6 +153,10 @@ export const PasswordForm = withCardStateProvider((props: PasswordFormProps) => signOutOfOtherSessions: sessionsField.checked, currentPassword: currentPasswordRequired ? currentPasswordField.value : undefined, } satisfies Parameters[0]; + pendingPasswordRef.current = { + password: opts.newPassword, + signOutOfOtherSessions: !!opts.signOutOfOtherSessions, + }; await updatePasswordWithReverification(user, [opts]); onSuccess(); diff --git a/packages/ui/src/components/UserProfile/__tests__/PasswordSection.test.tsx b/packages/ui/src/components/UserProfile/__tests__/PasswordSection.test.tsx index 854fa978b27..af42a93aed9 100644 --- a/packages/ui/src/components/UserProfile/__tests__/PasswordSection.test.tsx +++ b/packages/ui/src/components/UserProfile/__tests__/PasswordSection.test.tsx @@ -1,7 +1,8 @@ -import { describe, expect, it } from 'vitest'; +import { ClerkAPIResponseError } from '@clerk/shared/error'; +import { describe, expect, it, vi } from 'vitest'; import { bindCreateFixtures } from '@/test/create-fixtures'; -import { fireEvent, render, screen, waitFor } from '@/test/utils'; +import { act, fireEvent, render, screen, waitFor } from '@/test/utils'; import { CardStateProvider } from '@/ui/elements/contexts'; import { PasswordSection } from '../PasswordSection'; @@ -348,6 +349,71 @@ describe('PasswordSection', () => { }); }); + describe('Reverification with a breached password', () => { + const reverificationRequired = () => + new ClerkAPIResponseError('Reverification required', { + status: 403, + data: [{ code: 'session_reverification_required', message: 'Reverification required' }], + }); + + // Submits the form, which needs reverification, and returns the props the modal was opened with. + const submitAndOpenReverification = async () => { + const config = createFixtures.config(f => { + f.withReverification(); + f.withPassword(); + f.withUser({ password_enabled: true }); + }); + const { wrapper, fixtures } = await createFixtures(config); + fixtures.clerk.user?.updatePassword.mockRejectedValueOnce(reverificationRequired()).mockResolvedValue({}); + const reload = vi.spyOn(fixtures.clerk.user!, 'reload').mockResolvedValue({} as any); + const openReverification = vi + .spyOn(fixtures.clerk, '__internal_openReverification') + .mockImplementation(() => {}); + + const { getByRole, userEvent, getByLabelText } = render(, { wrapper }); + await userEvent.click(getByRole('button', { name: /update password/i })); + await waitFor(() => getByRole('heading', { name: /update password/i })); + await userEvent.type(getByLabelText(/new password/i), 'testtest'); + await userEvent.type(getByLabelText(/confirm password/i), 'testtest'); + await userEvent.click(getByRole('button', { name: /save$/i })); + + await waitFor(() => expect(openReverification).toHaveBeenCalledTimes(1)); + return { fixtures, reload, modalProps: openReverification.mock.calls[0][0]! }; + }; + + it('passes the new password to the modal and skips the retry once the modal reset it', async () => { + const { fixtures, reload, modalProps } = await submitAndOpenReverification(); + expect(modalProps.resetPassword).toEqual({ password: 'testtest', signOutOfOtherSessions: true }); + + await act(() => modalProps.afterVerification?.({ passwordReset: true })); + + await waitFor(() => expect(reload).toHaveBeenCalledTimes(1)); + expect(fixtures.clerk.user?.updatePassword).toHaveBeenCalledTimes(1); + }); + + it('shows success when the modal reset the password but refreshing the user fails', async () => { + const { fixtures, reload, modalProps } = await submitAndOpenReverification(); + reload.mockRejectedValueOnce(new Error('network')); + + await act(() => modalProps.afterVerification?.({ passwordReset: true })); + + // Success closes the form instead of showing the refresh error on it + await waitFor(() => + expect(screen.queryByRole('heading', { name: /update password/i })).not.toBeInTheDocument(), + ); + expect(fixtures.clerk.user?.updatePassword).toHaveBeenCalledTimes(1); + }); + + it('retries the password change when the modal verified another way', async () => { + const { fixtures, reload, modalProps } = await submitAndOpenReverification(); + + await act(() => modalProps.afterVerification?.()); + + await waitFor(() => expect(fixtures.clerk.user?.updatePassword).toHaveBeenCalledTimes(2)); + expect(reload).not.toHaveBeenCalled(); + }); + }); + describe('with Enterprise SSO', () => { it('prevents changing a password if user has active enterprise connections', async () => { const emailAddress = 'george@jungle.com'; diff --git a/packages/ui/src/components/UserVerification/AlternativeMethods.tsx b/packages/ui/src/components/UserVerification/AlternativeMethods.tsx index a77193ea38f..b85d46c175f 100644 --- a/packages/ui/src/components/UserVerification/AlternativeMethods.tsx +++ b/packages/ui/src/components/UserVerification/AlternativeMethods.tsx @@ -1,4 +1,4 @@ -import type { SessionVerificationFirstFactor, SignInFactor } from '@clerk/shared/types'; +import type { ResetPasswordCodeFactor, SessionVerificationFirstFactor, SignInFactor } from '@clerk/shared/types'; import React from 'react'; import { ArrowBlockButton } from '@/ui/elements/ArrowBlockButton'; @@ -8,17 +8,35 @@ import { Header } from '@/ui/elements/Header'; import { formatSafeIdentifier } from '@/ui/utils/formatSafeIdentifier'; import type { LocalizationKey } from '../../customizables'; -import { Col, descriptors, Flex, Flow, localizationKeys } from '../../customizables'; +import { Button, Col, descriptors, Flex, Flow, localizationKeys } from '../../customizables'; import { useCardState } from '../../elements/contexts'; -import { Building, Envelope, Fingerprint, Lock, SpeechBubble } from '../../icons'; +import { Divider } from '../../elements/Divider'; +import { Building, Envelope, Fingerprint, Lock, LockArrow, SpeechBubble } from '../../icons'; import { useReverificationAlternativeStrategies } from './useReverificationAlternativeStrategies'; import { useUserVerificationSession } from './useUserVerificationSession'; import { withHavingTrouble } from './withHavingTrouble'; +/** + * 'pwned' and 'passwordCompromised' follow a rejected password: the list leads + * with resetting it. + */ +export type AlternativeMethodsMode = 'default' | 'pwned' | 'passwordCompromised'; + export type AlternativeMethodsProps = { onBackLinkClick: React.MouseEventHandler | undefined; onFactorSelected: (factor: SignInFactor) => void; currentFactor: SignInFactor | undefined | null; + mode?: AlternativeMethodsMode; + /** Required by the password modes. */ + resetPassword?: { + factor: ResetPasswordCodeFactor; + /** + * The caller already has the new password, so the user only confirms it's + * them with the code. + */ + hasNewPassword: boolean; + onClick: () => void; + }; }; export type AlternativeMethodListProps = AlternativeMethodsProps & { onHavingTroubleClick: React.MouseEventHandler }; @@ -30,22 +48,40 @@ export const AlternativeMethods = (props: AlternativeMethodsProps) => { }; const AlternativeMethodsList = (props: AlternativeMethodListProps) => { - const { onBackLinkClick, onHavingTroubleClick, onFactorSelected } = props; + const { onBackLinkClick, onHavingTroubleClick, onFactorSelected, mode = 'default', resetPassword } = props; const card = useCardState(); const { data } = useUserVerificationSession(); - const { firstPartyFactors, hasAlternativeStrategies } = + const { firstPartyFactors: alternativeFactors } = useReverificationAlternativeStrategies({ filterOutFactor: props?.currentFactor, supportedFirstFactors: data?.supportedFirstFactors, }); + const reset = mode !== 'default' ? resetPassword : undefined; + // With a new password already chosen, the reset is just a code to the same + // address as the plain code method, so listing both would show the same button twice. + const firstPartyFactors = reset?.hasNewPassword + ? alternativeFactors.filter(f => !sendsCodeToSameIdentification(f, reset.factor)) + : alternativeFactors; + const hasAlternativeStrategies = firstPartyFactors.length > 0; return ( - + - - + + {mode === 'default' && ( + + )} + {mode !== 'default' && reset?.hasNewPassword && ( + + )} {card.error} {/*TODO: extract main in its own component */} @@ -54,6 +90,28 @@ const AlternativeMethodsList = (props: AlternativeMethodListProps) => { elementDescriptor={descriptors.main} gap={6} > + {reset && ( +