From cb78dd39837ac6cb5b9062382ae098a252a3e0b7 Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Sat, 17 Jan 2026 13:08:54 -0500 Subject: [PATCH] feat(ui): Redirect users with long imported passwords to reset password User passwords imported with insecure hashers are automatically migrated to bcrypt by the Clerk backend. However, there is a maximum length to a bcrypt password because hashing is computationally intensive. Users with too long imported passwords would encounter an error on login. The backend error handling has been improved for this case; capture the backend error and direct the user to the reset password flow. --- .changeset/beige-trees-mix.md | 6 + packages/localizations/src/en-US.ts | 5 + packages/shared/src/error.ts | 1 + packages/shared/src/errors/helpers.ts | 9 ++ packages/shared/src/types/localization.ts | 4 + .../components/SignIn/AlternativeMethods.tsx | 9 +- .../src/components/SignIn/SignInFactorOne.tsx | 4 + .../SignIn/SignInFactorOnePasswordCard.tsx | 15 +- .../SignIn/__tests__/SignInFactorOne.test.tsx | 132 ++++++++++++++++++ 9 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 .changeset/beige-trees-mix.md diff --git a/.changeset/beige-trees-mix.md b/.changeset/beige-trees-mix.md new file mode 100644 index 00000000000..d042461d336 --- /dev/null +++ b/.changeset/beige-trees-mix.md @@ -0,0 +1,6 @@ +--- +'@clerk/ui': minor +--- + +When imported user password is too long to migrate to bcrypt, direct the user +to the reset password flow. diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 15256e3e1c2..05d17445669 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -703,6 +703,9 @@ export const enUS: LocalizationResource = { passwordPwned: { title: 'Password compromised', }, + passwordTooLong: { + title: 'Password must be reset', + }, passwordUntrusted: { title: 'Password untrusted', }, @@ -933,6 +936,8 @@ export const enUS: LocalizationResource = { 'This password has been found as part of a breach and can not be used, please reset your password.', form_password_size_in_bytes_exceeded: undefined, form_password_compromised__sign_in: undefined, + password_too_long_needs_reset__sign_in: + 'The existing imported password is too long and cannot be used, please reset your password.', form_password_untrusted__sign_in: 'Your password may be compromised. To protect your account, please continue with an alternative sign-in method. You will be required to reset your password after signing in.', form_password_validation_failed: undefined, diff --git a/packages/shared/src/error.ts b/packages/shared/src/error.ts index 328a363015e..a9e0efe0b16 100644 --- a/packages/shared/src/error.ts +++ b/packages/shared/src/error.ts @@ -24,6 +24,7 @@ export { isNetworkError, isPasswordPwnedError, isPasswordCompromisedError, + isPasswordTooLongError, isReverificationCancelledError, isUnauthorizedError, isUserLockedError, diff --git a/packages/shared/src/errors/helpers.ts b/packages/shared/src/errors/helpers.ts index b95f55d5a8c..54bbecae827 100644 --- a/packages/shared/src/errors/helpers.ts +++ b/packages/shared/src/errors/helpers.ts @@ -102,6 +102,15 @@ export function isPasswordCompromisedError(err: any) { return isClerkAPIResponseError(err) && err.errors?.[0]?.code === 'form_password_compromised'; } +/** + * Checks if the provided error is a clerk api response error indicating a password is too long to migrate. + * + * @internal + */ +export function isPasswordTooLongError(err: any) { + return isClerkAPIResponseError(err) && err.errors?.[0]?.code === 'password_too_long_needs_reset'; +} + /** * Checks if the provided error is an EmailLinkError. * diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 7c3b5ae0fc0..5cf0b501ccb 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -412,6 +412,9 @@ export type __internal_LocalizationResource = { passwordCompromised: { title: LocalizationValue; }; + passwordTooLong: { + title: LocalizationValue; + }; passkey: { title: LocalizationValue; subtitle: LocalizationValue; @@ -1360,6 +1363,7 @@ type UnstableErrors = WithParamName<{ /** @deprecated Use `form_password_compromised__sign_in` instead */ form_password_untrusted__sign_in: LocalizationValue; form_password_compromised__sign_in: LocalizationValue; + password_too_long_needs_reset__sign_in: LocalizationValue; form_username_invalid_length: LocalizationValue<'min_length' | 'max_length'>; form_username_needs_non_number_char: LocalizationValue; form_username_invalid_character: LocalizationValue; diff --git a/packages/ui/src/components/SignIn/AlternativeMethods.tsx b/packages/ui/src/components/SignIn/AlternativeMethods.tsx index 4cc452bf379..b770371aa3f 100644 --- a/packages/ui/src/components/SignIn/AlternativeMethods.tsx +++ b/packages/ui/src/components/SignIn/AlternativeMethods.tsx @@ -18,7 +18,7 @@ import { SignInSocialButtons } from './SignInSocialButtons'; import { useResetPasswordFactor } from './useResetPasswordFactor'; import { withHavingTrouble } from './withHavingTrouble'; -export type AlternativeMethodsMode = 'forgot' | 'pwned' | 'passwordCompromised' | 'default'; +export type AlternativeMethodsMode = 'forgot' | 'pwned' | 'passwordCompromised' | 'passwordTooLong' | 'default'; export type AlternativeMethodsProps = { onBackLinkClick: React.MouseEventHandler | undefined; @@ -55,7 +55,7 @@ const AlternativeMethodsList = (props: AlternativeMethodListProps) => { - {!isReset && mode !== 'passwordCompromised' && ( + {!isReset && mode !== 'passwordCompromised' && mode !== 'passwordTooLong' && ( )} @@ -187,6 +187,8 @@ function determineFlowPart(mode: AlternativeMethodsMode) { return 'passwordPwnedMethods'; case 'passwordCompromised': return 'passwordCompromisedMethods'; + case 'passwordTooLong': + return 'passwordTooLongMethods'; default: return 'alternativeMethods'; } @@ -200,6 +202,8 @@ function determineTitle(mode: AlternativeMethodsMode): LocalizationKey { return localizationKeys('signIn.passwordPwned.title'); case 'passwordCompromised': return localizationKeys('signIn.passwordCompromised.title'); + case 'passwordTooLong': + return localizationKeys('signIn.passwordTooLong.title'); default: return localizationKeys('signIn.alternativeMethods.title'); } @@ -209,6 +213,7 @@ function determineIsReset(mode: AlternativeMethodsMode): boolean { switch (mode) { case 'forgot': case 'pwned': + case 'passwordTooLong': return true; case 'passwordCompromised': return false; diff --git a/packages/ui/src/components/SignIn/SignInFactorOne.tsx b/packages/ui/src/components/SignIn/SignInFactorOne.tsx index c493c19dfd9..71d14746996 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOne.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOne.tsx @@ -59,6 +59,10 @@ function determineAlternativeMethodsMode( return 'passwordCompromised'; } + if (passwordErrorCode === 'passwordTooLong') { + return 'passwordTooLong'; + } + return 'forgot'; } diff --git a/packages/ui/src/components/SignIn/SignInFactorOnePasswordCard.tsx b/packages/ui/src/components/SignIn/SignInFactorOnePasswordCard.tsx index 27829ec7675..de7e7ecaa22 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOnePasswordCard.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOnePasswordCard.tsx @@ -1,4 +1,9 @@ -import { isPasswordCompromisedError, isPasswordPwnedError, isUserLockedError } from '@clerk/shared/error'; +import { + isPasswordCompromisedError, + isPasswordPwnedError, + isPasswordTooLongError, + isUserLockedError, +} from '@clerk/shared/error'; import { clerkInvalidFAPIResponse } from '@clerk/shared/internal/clerk-js/errors'; import { useClerk } from '@clerk/shared/react'; import React from 'react'; @@ -18,7 +23,7 @@ import { useRouter } from '../../router/RouteContext'; import { HavingTrouble } from './HavingTrouble'; import { useResetPasswordFactor } from './useResetPasswordFactor'; -export type PasswordErrorCode = 'compromised' | 'pwned'; +export type PasswordErrorCode = 'compromised' | 'pwned' | 'passwordTooLong'; type SignInFactorOnePasswordProps = { onForgotPasswordMethodClick: React.MouseEventHandler | undefined; @@ -108,6 +113,12 @@ export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps) onPasswordError('compromised'); return; } + + if (isPasswordTooLongError(err)) { + card.setError({ ...err.errors[0], code: 'password_too_long_needs_reset__sign_in' }); + onPasswordError('passwordTooLong'); + return; + } } handleError(err, [passwordControl], card.setError); diff --git a/packages/ui/src/components/SignIn/__tests__/SignInFactorOne.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInFactorOne.test.tsx index 1de4ab411a9..59b95c35d2e 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInFactorOne.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInFactorOne.test.tsx @@ -353,6 +353,138 @@ describe('SignInFactorOne', () => { ).not.toBeInTheDocument(); }); + it('Prompts the user to reset their password via email if it is too long', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress(); + f.withPassword(); + f.withPreferredSignInStrategy({ strategy: 'password' }); + f.startSignInWithEmailAddress({ + supportPassword: true, + supportEmailCode: true, + supportResetPassword: true, + }); + }); + fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource)); + + const errJSON = { + code: 'password_too_long_needs_reset', + long_message: 'The existing imported password is too long and cannot be used, please reset your password.', + message: 'The existing imported password is too long and cannot be used, please reset your password.', + meta: { param_name: 'password' }, + }; + + fixtures.signIn.attemptFirstFactor.mockRejectedValueOnce( + new ClerkAPIResponseError('Error', { + data: [errJSON], + status: 422, + }), + ); + + const { userEvent } = render(, { wrapper }); + await userEvent.type(screen.getByLabelText('Password'), '123456'); + await userEvent.click(screen.getByText('Continue')); + + await screen.findByText('Password too long'); + await screen.findByText( + 'The existing imported password is too long and cannot be used, please reset your password.', + ); + await screen.findByText('Or, sign in with another method'); + + await userEvent.click(screen.getByText('Reset your password')); + await screen.findByText('First, enter the code sent to your email address'); + }); + + it('Prompts the user to reset their password via phone if it is too long', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress(); + f.withPassword(); + f.withPreferredSignInStrategy({ strategy: 'password' }); + f.startSignInWithPhoneNumber({ + supportPassword: true, + supportPhoneCode: true, + supportResetPassword: true, + }); + }); + fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource)); + + const errJSON = { + code: 'password_too_long_needs_reset', + long_message: 'The existing imported password is too long and cannot be used, please reset your password.', + message: 'The existing imported password is too long and cannot be used, please reset your password.', + meta: { param_name: 'password' }, + }; + + fixtures.signIn.attemptFirstFactor.mockRejectedValueOnce( + new ClerkAPIResponseError('Error', { + data: [errJSON], + status: 422, + }), + ); + + const { userEvent } = render(, { wrapper }); + await userEvent.type(screen.getByLabelText('Password'), '123456'); + await userEvent.click(screen.getByText('Continue')); + + await screen.findByText('Password too long'); + await screen.findByText( + 'The existing imported password is too long and cannot be used, please reset your password.', + ); + await screen.findByText('Or, sign in with another method'); + + await userEvent.click(screen.getByText('Reset your password')); + await screen.findByText('First, enter the code sent to your phone'); + }); + + it('entering a password that is too long, then going back and clicking forgot password should result in the correct title', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress(); + f.withPassword(); + f.withPreferredSignInStrategy({ strategy: 'password' }); + f.startSignInWithEmailAddress({ + supportPassword: true, + supportEmailCode: true, + supportResetPassword: true, + }); + }); + fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource)); + + const errJSON = { + code: 'password_too_long_needs_reset', + long_message: 'The existing imported password is too long and cannot be used, please reset your password.', + message: 'The existing imported password is too long and cannot be used, please reset your password.', + meta: { param_name: 'password' }, + }; + + fixtures.signIn.attemptFirstFactor.mockRejectedValueOnce( + new ClerkAPIResponseError('Error', { + data: [errJSON], + status: 422, + }), + ); + + const { userEvent } = render(, { wrapper }); + await userEvent.type(screen.getByLabelText('Password'), '123456'); + await userEvent.click(screen.getByText('Continue')); + + await screen.findByText('Password too long'); + await screen.findByText( + 'The existing imported password is too long and cannot be used, please reset your password.', + ); + await screen.findByText('Or, sign in with another method'); + + // Go back + await userEvent.click(screen.getByText('Back')); + + // Choose to reset password via "Forgot password" instead + await userEvent.click(screen.getByText(/Forgot password/i)); + await screen.findByText('Forgot Password?'); + expect( + screen.queryByText( + 'The existing imported password is too long and cannot be used, please reset your password.', + ), + ).not.toBeInTheDocument(); + }); + it('using an compromised password should show the compromised password screen', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withEmailAddress();