@better-auth-ui/core
Version:
Authentication components and data utilities for [Better Auth](https://better-auth.com), available for React and Solid.
112 lines (90 loc) • 3.44 kB
text/typescript
export const PASSWORD_COMPROMISED_ERROR_CODE = "PASSWORD_COMPROMISED"
/**
* Return whether Better Auth's `haveIBeenPwned` plugin rejected a password
* because it appears in a known breach corpus.
*
* Hosts use this to render the rejection against the password field rather
* than as a toast, since it is something the user can act on right there.
*/
export function isPasswordCompromisedError(error: unknown) {
if (typeof error !== "object" || error === null) return false
if ((error as { code?: unknown }).code === PASSWORD_COMPROMISED_ERROR_CODE) {
return true
}
const errorBody = (error as { error?: unknown }).error
if (typeof errorBody !== "object" || errorBody === null) return false
return (
(errorBody as { code?: unknown }).code === PASSWORD_COMPROMISED_ERROR_CODE
)
}
/** Coarse buckets a password falls into. `empty` renders no meter at all. */
export type PasswordStrengthLevel =
| "empty"
| "weak"
| "fair"
| "good"
| "strong"
export type PasswordStrength = {
/** 0 for an empty box, then 1 (weak) through 4 (strong). */
score: 0 | 1 | 2 | 3 | 4
level: PasswordStrengthLevel
}
export type EvaluatePasswordStrengthOptions = {
/** The minimum the form itself enforces. Anything shorter can't beat `weak`. */
minLength?: number
}
const SEQUENCES = ["abcdefghijklmnopqrstuvwxyz", "0123456789", "qwertyuiop"]
/** Whether the password leans on a run like `abcd`, `4321`, or `qwerty`. */
function hasSequentialRun(lowercased: string, runLength = 4) {
for (const sequence of SEQUENCES) {
const reversed = [...sequence].reverse().join("")
for (const haystack of [sequence, reversed]) {
for (let index = 0; index + runLength <= haystack.length; index++) {
if (lowercased.includes(haystack.slice(index, index + runLength))) {
return true
}
}
}
}
return false
}
/**
* Score a password for the strength meter shown while someone types.
*
* This is a hint, not a security control: it never leaves the browser and
* never gates submission. The server's own rules, and the `haveIBeenPwned`
* plugin if you run it, remain the thing that decides what is acceptable.
*
* @param password - The password as typed.
* @param options - Length policy the surrounding form enforces.
*/
export function evaluatePasswordStrength(
password: string,
options: EvaluatePasswordStrengthOptions = {}
): PasswordStrength {
if (!password) return { score: 0, level: "empty" }
const minLength = options.minLength ?? 8
const lowercased = password.toLowerCase()
let score = 0
if (password.length >= minLength) score += 1
if (password.length >= minLength + 4) score += 1
if (password.length >= 16) score += 1
const classes = [
/[a-z]/.test(password),
/[A-Z]/.test(password),
/\d/.test(password),
/[^a-zA-Z\d]/.test(password)
].filter(Boolean).length
if (classes >= 3) score += 1
if (classes === 4) score += 1
// A single repeated character reads as long but isn't.
if (new Set(password).size <= 2) score -= 2
if (hasSequentialRun(lowercased)) score -= 1
// Nothing under the form's own minimum deserves better than "weak".
if (password.length < minLength) {
return { score: 1, level: "weak" }
}
const clamped = Math.min(4, Math.max(1, score)) as 1 | 2 | 3 | 4
const levels = ["weak", "fair", "good", "strong"] as const
return { score: clamped, level: levels[clamped - 1] }
}