diff --git a/.changeset/nice-experts-hope.md b/.changeset/nice-experts-hope.md new file mode 100644 index 0000000000..e5b07a1270 --- /dev/null +++ b/.changeset/nice-experts-hope.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': minor +'@clerk/types': minor +--- + +Introducing validatePassword for SignIn and SignUp resources +- Validate a password based on the instance's configuration set in Password Policies in Dashboard diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index a57ab9f4c9..a1baac6d79 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -29,6 +29,7 @@ import type { } from '@clerk/types'; import { generateSignatureWithMetamask, getMetamaskIdentifier, windowNavigate } from '../../utils'; +import { createValidatePassword } from '../../utils/passwords/password'; import { clerkInvalidFAPIResponse, clerkInvalidStrategy, @@ -234,6 +235,15 @@ export class SignIn extends BaseResource implements SignInResource { }); }; + validatePassword: ReturnType = (password, cb) => { + if (SignIn.clerk.__unstable__environment?.userSettings.passwordSettings) { + return createValidatePassword({ + ...(SignIn.clerk.__unstable__environment?.userSettings.passwordSettings as any), + validatePassword: true, + })(password, cb); + } + }; + protected fromJSON(data: SignInJSON | null): this { if (data) { this.id = data.id; diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index 56b5e94eef..c8f3d3ba4c 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -22,6 +22,7 @@ import type { } from '@clerk/types'; import { generateSignatureWithMetamask, getCaptchaToken, getMetamaskIdentifier, windowNavigate } from '../../utils'; +import { createValidatePassword } from '../../utils/passwords/password'; import { normalizeUnsafeMetadata } from '../../utils/resourceParams'; import { clerkInvalidFAPIResponse, @@ -230,6 +231,15 @@ export class SignUp extends BaseResource implements SignUpResource { }); }; + validatePassword: ReturnType = (password, cb) => { + if (SignUp.clerk.__unstable__environment?.userSettings.passwordSettings) { + return createValidatePassword({ + ...(SignUp.clerk.__unstable__environment?.userSettings.passwordSettings as any), + validatePassword: true, + })(password, cb); + } + }; + protected fromJSON(data: SignUpJSON | null): this { if (data) { this.id = data.id; diff --git a/packages/clerk-js/src/ui/hooks/index.ts b/packages/clerk-js/src/ui/hooks/index.ts index 609fb9494f..451e54d5da 100644 --- a/packages/clerk-js/src/ui/hooks/index.ts +++ b/packages/clerk-js/src/ui/hooks/index.ts @@ -7,7 +7,6 @@ export * from './useEnabledThirdPartyProviders'; export * from './useLoadingStatus'; export * from './usePassword'; export * from './usePasswordComplexity'; -export * from './usePasswordStrength'; export * from './usePopover'; export * from './usePrefersReducedMotion'; export * from './useLocalStorage'; diff --git a/packages/clerk-js/src/ui/hooks/usePassword.ts b/packages/clerk-js/src/ui/hooks/usePassword.ts index 76b2092c74..b915d51dba 100644 --- a/packages/clerk-js/src/ui/hooks/usePassword.ts +++ b/packages/clerk-js/src/ui/hooks/usePassword.ts @@ -1,88 +1,15 @@ import { noop } from '@clerk/shared'; -import type { PasswordSettingsData } from '@clerk/types'; +import type { PasswordValidation } from '@clerk/types'; import { useCallback, useMemo } from 'react'; +import type { UsePasswordCbs, UsePasswordConfig } from '../../utils/passwords/password'; +import { createValidatePassword } from '../../utils/passwords/password'; import { localizationKeys, useLocalizations } from '../localization'; import type { FormControlState } from '../utils'; -import { loadZxcvbn } from '../utils'; -import type { ComplexityErrors } from './usePasswordComplexity'; -import { createValidateComplexity, generateErrorTextUtil } from './usePasswordComplexity'; -import type { PasswordStrength } from './usePasswordStrength'; -import { createValidatePasswordStrength } from './usePasswordStrength'; - -type UsePasswordConfig = PasswordSettingsData & { - validatePassword: boolean; -}; - -type PasswordValidation = { - complexity?: ComplexityErrors; - strength?: PasswordStrength; -}; - -type UsePasswordCbs = { - onValidationFailed?: (errorMessage: string | undefined) => void; - onValidationSuccess?: () => void; - onValidationWarning?: (warningMessage: string) => void; - onValidationComplexity?: (b: boolean) => void; -}; - -type ValidatePasswordCbs = { - onValidation?: (res: PasswordValidation) => void; - onValidationComplexity?: (b: boolean) => void; -}; +import { generateErrorTextUtil } from './usePasswordComplexity'; export const MIN_PASSWORD_LENGTH = 8; -const createValidatePassword = (config: UsePasswordConfig, callbacks?: ValidatePasswordCbs) => { - const { onValidation = noop, onValidationComplexity = noop } = callbacks || {}; - const { show_zxcvbn, validatePassword: validatePasswordProp } = config; - const getComplexity = createValidateComplexity(config); - const getScore = createValidatePasswordStrength(config); - let result = {} satisfies PasswordValidation; - - return (password: string) => { - if (!validatePasswordProp) { - return; - } - - /** - * Validate Complexity - */ - const failedValidationsComplexity = getComplexity(password); - onValidationComplexity(Object.keys(failedValidationsComplexity).length === 0); - result = { - ...result, - complexity: failedValidationsComplexity, - }; - /** - * Validate score - */ - if (show_zxcvbn) { - /** - * Lazy load zxcvbn without preventing a complexityError to be thrown if it exists - */ - void loadZxcvbn().then(zxcvbn => { - const setPasswordScore = getScore(zxcvbn); - const strength = setPasswordScore(password); - - result = { - ...result, - strength, - }; - onValidation({ - ...result, - strength, - }); - }); - } - - onValidation({ - ...result, - complexity: failedValidationsComplexity, - }); - }; -}; - export const usePassword = (config: UsePasswordConfig, callbacks?: UsePasswordCbs) => { const { t, locale } = useLocalizations(); const { diff --git a/packages/clerk-js/src/ui/hooks/usePasswordComplexity.ts b/packages/clerk-js/src/ui/hooks/usePasswordComplexity.ts index c0b1f91cb7..45d4b0f155 100644 --- a/packages/clerk-js/src/ui/hooks/usePasswordComplexity.ts +++ b/packages/clerk-js/src/ui/hooks/usePasswordComplexity.ts @@ -1,49 +1,12 @@ -import type { PasswordSettingsData } from '@clerk/types'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import type { ComplexityErrors, UsePasswordComplexityConfig } from '../../utils/passwords/complexity'; +import { validate } from '../../utils/passwords/complexity'; import type { LocalizationKey } from '../localization'; import { localizationKeys, useLocalizations } from '../localization'; import { addFullStop, createListFormat } from '../utils'; -export type ComplexityErrors = { - [key in keyof Partial>]?: boolean; -}; - -type UsePasswordComplexityConfig = Omit; - -const createTestComplexityCases = (config: Pick) => { - let specialCharsRegex: RegExp; - if (config.allowed_special_characters) { - // Avoid a nested group by escaping the `[]` characters - let escaped = config.allowed_special_characters.replace('[', '\\['); - escaped = escaped.replace(']', '\\]'); - specialCharsRegex = new RegExp(`[${escaped}]`); - } else { - specialCharsRegex = /[!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~]/; - } - - return ( - password: string, - { - minLength, - maxLength, - }: { - minLength: number; - maxLength: number; - }, - ) => { - return { - max_length: password.length < maxLength, - min_length: password.length >= minLength, - require_numbers: /\d/.test(password), - require_lowercase: /[a-z]/.test(password), - require_uppercase: /[A-Z]/.test(password), - require_special_char: specialCharsRegex.test(password), - }; - }; -}; - -const errorMessages = { +const errorMessages: Record, [string, string] | string> = { max_length: ['unstable__errors.passwordComplexity.maximumLength', 'length'], min_length: ['unstable__errors.passwordComplexity.minimumLength', 'length'], require_numbers: 'unstable__errors.passwordComplexity.requireNumbers', @@ -89,44 +52,6 @@ export const generateErrorTextUtil = ({ ); }; -const validate = (password: string, config: UsePasswordComplexityConfig): ComplexityErrors => { - const { max_length, min_length, require_special_char, require_lowercase, require_numbers, require_uppercase } = - config; - const testComplexityCases = createTestComplexityCases(config); - const testCases = testComplexityCases(password, { - maxLength: config.max_length, - minLength: config.min_length, - }); - - const keys = { - max_length, - min_length, - require_special_char, - require_numbers, - require_lowercase, - require_uppercase, - }; - - const _validationsFailedMap = new Map(); - for (const k in keys) { - const key = k as keyof typeof keys; - - if (!keys[key]) { - continue; - } - - if (!testCases[key]) { - _validationsFailedMap.set(key, true); - } - } - - return Object.freeze(Object.fromEntries(_validationsFailedMap)); -}; - -export const createValidateComplexity = (config: UsePasswordComplexityConfig) => { - return (password: string) => validate(password, config); -}; - export const usePasswordComplexity = (config: UsePasswordComplexityConfig) => { const [password, _setPassword] = useState(''); const [failedValidations, setFailedValidations] = useState(); diff --git a/packages/clerk-js/src/ui/utils/index.ts b/packages/clerk-js/src/ui/utils/index.ts index 9db3fdaf2c..75ed960888 100644 --- a/packages/clerk-js/src/ui/utils/index.ts +++ b/packages/clerk-js/src/ui/utils/index.ts @@ -19,5 +19,4 @@ export * from './roleLocalizationKey'; export * from './getRelativeToNowDateKey'; export * from './mergeRefs'; export * from './createSlug'; -export * from './zxcvbn'; export * from './passwordUtils'; diff --git a/packages/clerk-js/src/utils/passwords/complexity.ts b/packages/clerk-js/src/utils/passwords/complexity.ts new file mode 100644 index 0000000000..7786ac1723 --- /dev/null +++ b/packages/clerk-js/src/utils/passwords/complexity.ts @@ -0,0 +1,80 @@ +import type { PasswordSettingsData } from '@clerk/types'; + +export type ComplexityErrors = { + [key in keyof Partial>]?: boolean; +}; + +export type UsePasswordComplexityConfig = Omit< + PasswordSettingsData, + 'disable_hibp' | 'min_zxcvbn_strength' | 'show_zxcvbn' +>; + +const createTestComplexityCases = (config: Pick) => { + let specialCharsRegex: RegExp; + if (config.allowed_special_characters) { + // Avoid a nested group by escaping the `[]` characters + let escaped = config.allowed_special_characters.replace('[', '\\['); + escaped = escaped.replace(']', '\\]'); + specialCharsRegex = new RegExp(`[${escaped}]`); + } else { + specialCharsRegex = /[!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~]/; + } + + return ( + password: string, + { + minLength, + maxLength, + }: { + minLength: number; + maxLength: number; + }, + ) => { + return { + max_length: password.length < maxLength, + min_length: password.length >= minLength, + require_numbers: /\d/.test(password), + require_lowercase: /[a-z]/.test(password), + require_uppercase: /[A-Z]/.test(password), + require_special_char: specialCharsRegex.test(password), + }; + }; +}; + +export const validate = (password: string, config: UsePasswordComplexityConfig): ComplexityErrors => { + const { max_length, min_length, require_special_char, require_lowercase, require_numbers, require_uppercase } = + config; + const testComplexityCases = createTestComplexityCases(config); + const testCases = testComplexityCases(password, { + maxLength: config.max_length, + minLength: config.min_length, + }); + + const keys = { + max_length, + min_length, + require_special_char, + require_lowercase, + require_numbers, + require_uppercase, + }; + + const _validationsFailedMap = new Map(); + for (const k in keys) { + const key = k as keyof typeof keys; + + if (!keys[key]) { + continue; + } + + if (!testCases[key]) { + _validationsFailedMap.set(key, true); + } + } + + return Object.freeze(Object.fromEntries(_validationsFailedMap)); +}; + +export const createValidateComplexity = (config: UsePasswordComplexityConfig) => { + return (password: string) => validate(password, config); +}; diff --git a/packages/clerk-js/src/utils/passwords/password.ts b/packages/clerk-js/src/utils/passwords/password.ts new file mode 100644 index 0000000000..9296c6f3f6 --- /dev/null +++ b/packages/clerk-js/src/utils/passwords/password.ts @@ -0,0 +1,80 @@ +import { noop } from '@clerk/shared'; +import type { PasswordSettingsData } from '@clerk/types'; +import type { PasswordValidation, ValidatePasswordCallbacks } from '@clerk/types'; + +import { loadZxcvbn } from '../zxcvbn'; +import { createValidateComplexity } from './complexity'; +import { createValidatePasswordStrength } from './strength'; + +export type UsePasswordConfig = PasswordSettingsData & { + /** + * @deprecated + */ + strengthMeter?: boolean; + /** + * @deprecated + */ + complexity?: boolean; + validatePassword: boolean; +}; + +export type UsePasswordCbs = { + onValidationFailed?: (errorMessage: string | undefined) => void; + onValidationSuccess?: () => void; + onValidationWarning?: (warningMessage: string) => void; + onValidationComplexity?: (b: boolean) => void; +}; + +export const createValidatePassword = (config: UsePasswordConfig, callbacks?: ValidatePasswordCallbacks) => { + const { onValidation = noop, onValidationComplexity = noop } = callbacks || {}; + const { show_zxcvbn, validatePassword: validatePasswordProp } = config; + const getComplexity = createValidateComplexity(config); + const getScore = createValidatePasswordStrength(config); + let result = {} satisfies PasswordValidation; + + return (password: string, internalCallbacks?: ValidatePasswordCallbacks) => { + const { + onValidation: internalOnValidation = onValidation, + onValidationComplexity: internalOnValidationComplexity = onValidationComplexity, + } = internalCallbacks || {}; + if (!validatePasswordProp) { + return; + } + + /** + * Validate Complexity + */ + const failedValidationsComplexity = getComplexity(password); + internalOnValidationComplexity(Object.keys(failedValidationsComplexity).length === 0); + result = { + ...result, + complexity: failedValidationsComplexity, + }; + /** + * Validate score + */ + if (show_zxcvbn) { + /** + * Lazy load zxcvbn without preventing a complexityError to be thrown if it exists + */ + void loadZxcvbn().then(zxcvbn => { + const setPasswordScore = getScore(zxcvbn); + const strength = setPasswordScore(password); + + result = { + ...result, + strength, + }; + internalOnValidation({ + ...result, + strength, + }); + }); + } + + internalOnValidation({ + ...result, + complexity: failedValidationsComplexity, + }); + }; +}; diff --git a/packages/clerk-js/src/ui/hooks/usePasswordStrength.ts b/packages/clerk-js/src/utils/passwords/strength.ts similarity index 92% rename from packages/clerk-js/src/ui/hooks/usePasswordStrength.ts rename to packages/clerk-js/src/utils/passwords/strength.ts index 828bb9af6c..ef2d39ef64 100644 --- a/packages/clerk-js/src/ui/hooks/usePasswordStrength.ts +++ b/packages/clerk-js/src/utils/passwords/strength.ts @@ -1,7 +1,7 @@ import type { PasswordSettingsData } from '@clerk/types'; -import type { ZxcvbnResult } from '@zxcvbn-ts/core'; +import type { ZxcvbnResult } from '@clerk/types'; -import type { zxcvbnFN } from '../utils'; +import type { zxcvbnFN } from '../zxcvbn'; export type PasswordStrength = | { diff --git a/packages/clerk-js/src/ui/utils/zxcvbn.ts b/packages/clerk-js/src/utils/zxcvbn.ts similarity index 91% rename from packages/clerk-js/src/ui/utils/zxcvbn.ts rename to packages/clerk-js/src/utils/zxcvbn.ts index 19cf8a9f23..765c342a30 100644 --- a/packages/clerk-js/src/ui/utils/zxcvbn.ts +++ b/packages/clerk-js/src/utils/zxcvbn.ts @@ -1,4 +1,4 @@ -import type { ZxcvbnResult } from '@zxcvbn-ts/core'; +import type { ZxcvbnResult } from '@clerk/types'; export type zxcvbnFN = (password: string, userInputs?: (string | number)[]) => ZxcvbnResult; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 969ac4121c..4b4008ad7f 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -25,6 +25,7 @@ export * from './organization'; export * from './organizationInvitation'; export * from './organizationMembership'; export * from './organizationSettings'; +export * from './passwords'; export * from './phoneNumber'; export * from './redirects'; export * from './resource'; diff --git a/packages/types/src/passwords.ts b/packages/types/src/passwords.ts new file mode 100644 index 0000000000..831f032671 --- /dev/null +++ b/packages/types/src/passwords.ts @@ -0,0 +1,38 @@ +import type { PasswordSettingsData } from './userSettings'; + +export interface ZxcvbnResult { + feedback: { + warning: string; + suggestions: string[]; + }; + score: 0 | 1 | 2 | 3 | 4; + password: string; + guesses: number; + guessesLog10: number; + calcTime: number; +} + +export type ComplexityErrors = { + [key in keyof Partial>]?: boolean; +}; + +export type PasswordValidation = { + complexity?: ComplexityErrors; + strength?: PasswordStrength; +}; + +export type ValidatePasswordCallbacks = { + onValidation?: (res: PasswordValidation) => void; + onValidationComplexity?: (b: boolean) => void; +}; + +export type PasswordStrength = + | { + state: 'excellent'; + result: T; + } + | { + state: 'pass' | 'fail'; + keys: string[]; + result: T; + }; diff --git a/packages/types/src/signIn.ts b/packages/types/src/signIn.ts index 4cf423d3ef..9f4c4ab856 100644 --- a/packages/types/src/signIn.ts +++ b/packages/types/src/signIn.ts @@ -41,6 +41,7 @@ import type { UserDataJSON, VerificationJSON, } from './json'; +import type { ValidatePasswordCallbacks } from './passwords'; import type { AuthenticateWithRedirectParams } from './redirects'; import type { ClerkResource } from './resource'; import type { @@ -90,6 +91,8 @@ export interface SignInResource extends ClerkResource { authenticateWithMetamask: () => Promise; createMagicLinkFlow: () => CreateMagicLinkFlowReturn; + + validatePassword: (password: string, callbacks?: ValidatePasswordCallbacks) => void; } export type SignInStatus = diff --git a/packages/types/src/signUp.ts b/packages/types/src/signUp.ts index 77b29c9100..9bc8ef2cd3 100644 --- a/packages/types/src/signUp.ts +++ b/packages/types/src/signUp.ts @@ -13,6 +13,7 @@ import type { UsernameIdentifier, Web3WalletIdentifier, } from './identifiers'; +import type { ValidatePasswordCallbacks } from './passwords'; import type { AttemptPhoneNumberVerificationParams, PreparePhoneNumberVerificationParams } from './phoneNumber'; import type { AuthenticateWithRedirectParams } from './redirects'; import type { ClerkResource } from './resource'; @@ -82,6 +83,8 @@ export interface SignUpResource extends ClerkResource { createMagicLinkFlow: () => CreateMagicLinkFlowReturn; + validatePassword: (password: string, callbacks?: ValidatePasswordCallbacks) => void; + authenticateWithRedirect: ( params: AuthenticateWithRedirectParams & { unsafeMetadata?: SignUpUnsafeMetadata }, ) => Promise; diff --git a/playground/nextjs/pages/custom/forgotPassword.tsx b/playground/nextjs/pages/custom/forgotPassword.tsx index 58f358ff95..1f4d9555c2 100644 --- a/playground/nextjs/pages/custom/forgotPassword.tsx +++ b/playground/nextjs/pages/custom/forgotPassword.tsx @@ -2,6 +2,13 @@ import React, { SyntheticEvent, useState } from 'react'; import { useSignIn } from '@clerk/nextjs'; import type { NextPage } from 'next'; +type PasswordState = 'neutral' | 'success' | 'warn' | 'fail'; +const colors: { [k in PasswordState]?: string } = { + fail: 'red', + success: 'green', + warn: 'orange', +}; + const SignInPage: NextPage = () => { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); @@ -9,6 +16,7 @@ const SignInPage: NextPage = () => { const [successfulCreation, setSuccessfulCreation] = useState(false); const [complete, setComplete] = useState(false); const [secondFactor, setSecondFactor] = useState(false); + const [passwordState, setPasswordState] = useState('neutral'); const { isLoaded, signIn, setActive } = useSignIn(); @@ -86,7 +94,33 @@ const SignInPage: NextPage = () => { setPassword(e.target.value)} + style={{ + outline: 'none', + borderColor: colors[passwordState], + }} + onChange={e => { + signIn.validatePassword(e.target.value, { + onValidation(res) { + if (Object.values(res?.complexity || {}).length > 0) { + return setPasswordState('fail'); + } + + // Strength that fails + if (res?.strength?.state === 'fail') { + return setPasswordState('fail'); + } + + // Strength that can be better + if (res?.strength?.state === 'pass') { + return setPasswordState('warn'); + } + + // Perfection + return setPasswordState('success'); + }, + }); + setPassword(e.target.value); + }} />