Skip to content

Commit

Permalink
feat(clerk-js): Expose password validation from SignIn and SignUp res…
Browse files Browse the repository at this point in the history
…ources (#1445)

* feat(clerk-js): Expose password validation from SignIn and SignUp resources

* chore(clerk-js): Use ZxcvbnResult from `@types`

* chore(repo): Example usage password validation in custom flows
  • Loading branch information
panteliselef authored Aug 9, 2023
1 parent 4ea30e8 commit 8538cd0
Show file tree
Hide file tree
Showing 16 changed files with 277 additions and 161 deletions.
7 changes: 7 additions & 0 deletions .changeset/nice-experts-hope.md
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions packages/clerk-js/src/core/resources/SignIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import type {
} from '@clerk/types';

import { generateSignatureWithMetamask, getMetamaskIdentifier, windowNavigate } from '../../utils';
import { createValidatePassword } from '../../utils/passwords/password';
import {
clerkInvalidFAPIResponse,
clerkInvalidStrategy,
Expand Down Expand Up @@ -234,6 +235,15 @@ export class SignIn extends BaseResource implements SignInResource {
});
};

validatePassword: ReturnType<typeof createValidatePassword> = (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;
Expand Down
10 changes: 10 additions & 0 deletions packages/clerk-js/src/core/resources/SignUp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -230,6 +231,15 @@ export class SignUp extends BaseResource implements SignUpResource {
});
};

validatePassword: ReturnType<typeof createValidatePassword> = (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;
Expand Down
1 change: 0 additions & 1 deletion packages/clerk-js/src/ui/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
81 changes: 4 additions & 77 deletions packages/clerk-js/src/ui/hooks/usePassword.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
81 changes: 3 additions & 78 deletions packages/clerk-js/src/ui/hooks/usePasswordComplexity.ts
Original file line number Diff line number Diff line change
@@ -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<Omit<PasswordSettingsData, 'disable_hibp' | 'min_zxcvbn_strength' | 'show_zxcvbn'>>]?: boolean;
};

type UsePasswordComplexityConfig = Omit<PasswordSettingsData, 'disable_hibp' | 'min_zxcvbn_strength' | 'show_zxcvbn'>;

const createTestComplexityCases = (config: Pick<UsePasswordComplexityConfig, 'allowed_special_characters'>) => {
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<keyof Omit<ComplexityErrors, 'allowed_special_characters'>, [string, string] | string> = {
max_length: ['unstable__errors.passwordComplexity.maximumLength', 'length'],
min_length: ['unstable__errors.passwordComplexity.minimumLength', 'length'],
require_numbers: 'unstable__errors.passwordComplexity.requireNumbers',
Expand Down Expand Up @@ -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<ComplexityErrors>();
Expand Down
1 change: 0 additions & 1 deletion packages/clerk-js/src/ui/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,4 @@ export * from './roleLocalizationKey';
export * from './getRelativeToNowDateKey';
export * from './mergeRefs';
export * from './createSlug';
export * from './zxcvbn';
export * from './passwordUtils';
80 changes: 80 additions & 0 deletions packages/clerk-js/src/utils/passwords/complexity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import type { PasswordSettingsData } from '@clerk/types';

export type ComplexityErrors = {
[key in keyof Partial<Omit<PasswordSettingsData, 'disable_hibp' | 'min_zxcvbn_strength' | 'show_zxcvbn'>>]?: boolean;
};

export type UsePasswordComplexityConfig = Omit<
PasswordSettingsData,
'disable_hibp' | 'min_zxcvbn_strength' | 'show_zxcvbn'
>;

const createTestComplexityCases = (config: Pick<UsePasswordComplexityConfig, 'allowed_special_characters'>) => {
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);
};
Loading

0 comments on commit 8538cd0

Please sign in to comment.