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..a82ee996417 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -1987,6 +1987,21 @@ 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; + /** + * Called when the user enters a different password in the modal because + * this one was rejected. A caller that retries its own request after + * verification must send this password instead. + */ + onPasswordChange?: (password: string) => void; + }; + /** * 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. */ 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/UserProfile/PasswordForm.tsx b/packages/ui/src/components/UserProfile/PasswordForm.tsx index c9038469e86..ae41d769cfd 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,31 @@ 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(); const updatePasswordWithReverification = useReverification( - (user: UserResource, opts: Parameters) => user.updatePassword(...opts), + (user: UserResource, [params]: Parameters) => + // The retry after reverification must send the password the modal set, + // which differs from the form's when the modal rejected it. + user.updatePassword({ ...params, newPassword: pendingPasswordRef.current?.password ?? params.newPassword }), + { + onNeedsReverification: ({ level, complete, cancel }) => { + const pending = pendingPasswordRef.current; + clerk.__internal_openReverification({ + level, + resetPassword: pending && { + ...pending, + onPasswordChange: password => { + pendingPasswordRef.current = { ...pending, password }; + }, + }, + afterVerification: complete, + afterVerificationCancelled: cancel, + }); + }, + }, ); if (!user) { @@ -125,6 +149,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..df6ba200463 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,87 @@ 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 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, modalProps: openReverification.mock.calls[0][0]! }; + }; + + // The retry sends the same password; the backend treats it as already done. + it('passes the new password to the modal and retries the change after verification', async () => { + const { fixtures, modalProps } = await submitAndOpenReverification(); + expect(modalProps.resetPassword).toMatchObject({ password: 'testtest', signOutOfOtherSessions: true }); + + await act(() => modalProps.afterVerification?.()); + + await waitFor(() => expect(fixtures.clerk.user?.updatePassword).toHaveBeenCalledTimes(2)); + expect(fixtures.clerk.user?.updatePassword).toHaveBeenLastCalledWith({ + newPassword: 'testtest', + signOutOfOtherSessions: true, + }); + }); + + it('retries with the password the modal set after it rejected the form password', async () => { + const { fixtures, modalProps } = await submitAndOpenReverification(); + + // The modal rejected 'testtest' and the user picked another password there + act(() => modalProps.resetPassword?.onPasswordChange?.('picked-in-the-modal-456')); + await act(() => modalProps.afterVerification?.()); + + await waitFor(() => expect(fixtures.clerk.user?.updatePassword).toHaveBeenCalledTimes(2)); + expect(fixtures.clerk.user?.updatePassword).toHaveBeenLastCalledWith({ + newPassword: 'picked-in-the-modal-456', + signOutOfOtherSessions: true, + }); + }); + + it('keeps the form open with the error when the retry fails', async () => { + const { fixtures, modalProps } = await submitAndOpenReverification(); + fixtures.clerk.user?.updatePassword.mockReset().mockRejectedValueOnce( + new ClerkAPIResponseError('Error', { + status: 500, + data: [ + { + code: 'internal_clerk_error', + message: 'Something went wrong', + long_message: 'Something went wrong', + }, + ], + }), + ); + + await act(() => modalProps.afterVerification?.()); + + await screen.findByText('Something went wrong'); + screen.getByRole('heading', { name: /update password/i }); + }); + }); + 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 && ( +