use-password-policy 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/core.js CHANGED
@@ -23,14 +23,20 @@ __export(core_exports, {
23
23
  COMMON_PASSWORDS: () => COMMON_PASSWORDS,
24
24
  DEFAULT_MESSAGES: () => DEFAULT_MESSAGES,
25
25
  DEFAULT_OPTIONS: () => DEFAULT_OPTIONS,
26
+ applyBreachResult: () => applyBreachResult,
26
27
  checkPwnedPassword: () => checkPwnedPassword,
27
28
  fromZxcvbn: () => fromZxcvbn,
28
29
  isCommonPassword: () => isCommonPassword,
30
+ isPredictablePattern: () => isPredictablePattern,
31
+ passwordLength: () => passwordLength,
29
32
  passwordValidator: () => passwordValidator,
33
+ passwordValidatorAsync: () => passwordValidatorAsync,
30
34
  presets: () => presets,
31
35
  resolveOptions: () => resolveOptions,
32
36
  validatePassword: () => validatePassword,
33
- zodPasswordRule: () => zodPasswordRule
37
+ validatePasswordAsync: () => validatePasswordAsync,
38
+ zodPasswordRule: () => zodPasswordRule,
39
+ zodPasswordRuleAsync: () => zodPasswordRuleAsync
34
40
  });
35
41
  module.exports = __toCommonJS(core_exports);
36
42
 
@@ -213,6 +219,25 @@ var COMMON_PASSWORDS = [
213
219
 
214
220
  // src/pwned.ts
215
221
  var HIBP_RANGE = "https://api.pwnedpasswords.com/range/";
222
+ var CACHE_LIMIT = 100;
223
+ var responseCache = /* @__PURE__ */ new WeakMap();
224
+ var inflight = /* @__PURE__ */ new WeakMap();
225
+ function inflightFor(fetchFn) {
226
+ let map = inflight.get(fetchFn);
227
+ if (!map) {
228
+ map = /* @__PURE__ */ new Map();
229
+ inflight.set(fetchFn, map);
230
+ }
231
+ return map;
232
+ }
233
+ function cacheFor(fetchFn) {
234
+ let cache = responseCache.get(fetchFn);
235
+ if (!cache) {
236
+ cache = /* @__PURE__ */ new Map();
237
+ responseCache.set(fetchFn, cache);
238
+ }
239
+ return cache;
240
+ }
216
241
  async function sha1Hex(input) {
217
242
  var _a;
218
243
  const subtle = (_a = globalThis.crypto) == null ? void 0 : _a.subtle;
@@ -225,19 +250,33 @@ async function sha1Hex(input) {
225
250
  return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("").toUpperCase();
226
251
  }
227
252
  async function checkPwnedPassword(password, options = {}) {
228
- var _a, _b;
253
+ var _a, _b, _c;
229
254
  if (!password) return 0;
230
255
  const doFetch = (_a = options.fetch) != null ? _a : globalThis.fetch;
231
256
  if (!doFetch) throw new Error("use-password-policy: no fetch implementation available.");
232
257
  const hash = await sha1Hex(password);
233
258
  const prefix = hash.slice(0, 5);
234
259
  const suffix = hash.slice(5);
235
- const res = await doFetch(`${(_b = options.endpoint) != null ? _b : HIBP_RANGE}${prefix}`, {
236
- signal: options.signal,
237
- headers: options.padding ? { "Add-Padding": "true" } : void 0
238
- });
239
- if (!res.ok) throw new Error(`use-password-policy: breach check failed (HTTP ${res.status}).`);
240
- const body = await res.text();
260
+ const url = `${(_b = options.endpoint) != null ? _b : HIBP_RANGE}${prefix}`;
261
+ const cache = cacheFor(doFetch);
262
+ let body = cache.get(url);
263
+ if (body === void 0) {
264
+ const pending = inflightFor(doFetch);
265
+ let request = pending.get(url);
266
+ if (!request) {
267
+ request = (async () => {
268
+ const res = await doFetch(url, { headers: options.padding ? { "Add-Padding": "true" } : void 0 });
269
+ if (!res.ok) throw new Error(`use-password-policy: breach check failed (HTTP ${res.status}).`);
270
+ const text = await res.text();
271
+ cache.set(url, text);
272
+ if (cache.size > CACHE_LIMIT) cache.delete(cache.keys().next().value);
273
+ return text;
274
+ })().finally(() => pending.delete(url));
275
+ pending.set(url, request);
276
+ }
277
+ body = await request;
278
+ if ((_c = options.signal) == null ? void 0 : _c.aborted) throw new DOMException("The check was cancelled.", "AbortError");
279
+ }
241
280
  for (const line of body.split("\n")) {
242
281
  const [lineSuffix, count] = line.trim().split(":");
243
282
  if (lineSuffix === suffix) return parseInt(count, 10) || 0;
@@ -255,6 +294,7 @@ var DEFAULT_OPTIONS = {
255
294
  specialCharCheck: true,
256
295
  commonPasswordCheck: false,
257
296
  commonPasswords: COMMON_PASSWORDS,
297
+ patternCheck: false,
258
298
  customRules: [],
259
299
  messages: {},
260
300
  lowercaseRegex: /[a-z]/,
@@ -273,7 +313,8 @@ var presets = {
273
313
  },
274
314
  /**
275
315
  * NIST SP 800-63B-4 (Aug 2025) for passwords used on their own:
276
- * at least 15 characters, allow up to 64, no composition rules, block common passwords.
316
+ * at least 15 characters, allow up to 64, no composition rules, block common passwords
317
+ * and predictable patterns. Add `breachCheck: true` to also check known breaches.
277
318
  */
278
319
  nist: {
279
320
  minLength: 15,
@@ -282,7 +323,8 @@ var presets = {
282
323
  uppercaseCheck: false,
283
324
  numberCheck: false,
284
325
  specialCharCheck: false,
285
- commonPasswordCheck: true
326
+ commonPasswordCheck: true,
327
+ patternCheck: true
286
328
  },
287
329
  /** NIST SP 800-63B-4 when the password is one factor of MFA: at least 8 characters. */
288
330
  nistMfa: {
@@ -292,7 +334,8 @@ var presets = {
292
334
  uppercaseCheck: false,
293
335
  numberCheck: false,
294
336
  specialCharCheck: false,
295
- commonPasswordCheck: true
337
+ commonPasswordCheck: true,
338
+ patternCheck: true
296
339
  }
297
340
  };
298
341
  var DEFAULT_MESSAGES = {
@@ -303,6 +346,9 @@ var DEFAULT_MESSAGES = {
303
346
  number: "A number",
304
347
  specialChar: "A special character",
305
348
  notCommon: "Not a commonly used password",
349
+ noPattern: "No repeats, sequences or keyboard patterns",
350
+ notBreached: "Not found in known data breaches",
351
+ breachUnavailable: "Couldn't check known data breaches. Try again",
306
352
  match: "Passwords match",
307
353
  strength: "Hard to guess"
308
354
  };
@@ -328,6 +374,22 @@ function getCommonSet(list) {
328
374
  }
329
375
  return set;
330
376
  }
377
+ var passwordLength = (password) => Array.from(password).length;
378
+ function isCommonCombo(word, set) {
379
+ const n = word.length;
380
+ if (n < 6) return false;
381
+ const reachable = new Array(n + 1).fill(false);
382
+ reachable[0] = true;
383
+ for (let end = 3; end <= n; end++) {
384
+ for (let start = Math.max(0, end - 20); start <= end - 3; start++) {
385
+ if (reachable[start] && set.has(word.slice(start, end))) {
386
+ reachable[end] = true;
387
+ break;
388
+ }
389
+ }
390
+ }
391
+ return reachable[n];
392
+ }
331
393
  function isCommonPassword(password, list = COMMON_PASSWORDS) {
332
394
  if (!password) return false;
333
395
  const set = getCommonSet(list);
@@ -336,7 +398,36 @@ function isCommonPassword(password, list = COMMON_PASSWORDS) {
336
398
  const trimmed = lower.replace(/[^a-z]+$/, "");
337
399
  const base = unleet(trimmed);
338
400
  const baseNoLeading = unleet(trimmed.replace(/^[^a-z]+/, ""));
339
- return [lower, noTrailingSymbols, base, baseNoLeading].some((c) => c.length > 0 && set.has(c));
401
+ if ([lower, noTrailingSymbols, base, baseNoLeading].some((c) => c.length > 0 && set.has(c))) return true;
402
+ const lettersOnly = lower.replace(/[^a-z]/g, "");
403
+ const unleetedLetters = unleet(baseNoLeading).replace(/[^a-z]/g, "");
404
+ return isCommonCombo(lettersOnly, set) || isCommonCombo(unleetedLetters, set);
405
+ }
406
+ var KEYBOARD_ROWS = ["1234567890", "qwertyuiop", "asdfghjkl", "zxcvbnm", "abcdefghijklmnopqrstuvwxyz"];
407
+ function isStep(a, b) {
408
+ if (a === b) return false;
409
+ for (const row of KEYBOARD_ROWS) {
410
+ const i = row.indexOf(a);
411
+ const j = row.indexOf(b);
412
+ if (i >= 0 && j >= 0 && (Math.abs(i - j) === 1 || Math.abs(i - j) === row.length - 1)) return true;
413
+ }
414
+ return false;
415
+ }
416
+ function isPredictablePattern(password) {
417
+ if (!password) return false;
418
+ if (password.trim() === "") return true;
419
+ const chars = Array.from(password.toLowerCase());
420
+ if (new Set(chars).size <= 3) return true;
421
+ const compact = chars.filter((c) => /[a-z0-9]/.test(c) || c.toUpperCase() !== c).join("");
422
+ for (const s of [compact, compact.replace(/\d+$/, "")]) {
423
+ if (s.length >= 6 && /^(.+?)\1+$/.test(s)) return true;
424
+ }
425
+ if (chars.length >= 4) {
426
+ let steps = 0;
427
+ for (let i = 1; i < chars.length; i++) if (isStep(chars[i - 1], chars[i])) steps++;
428
+ if (steps / (chars.length - 1) >= 0.75) return true;
429
+ }
430
+ return false;
340
431
  }
341
432
  var prettify = (name) => name.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase()).trim();
342
433
  function resolveMessage(rule, options) {
@@ -348,10 +439,10 @@ function resolveMessage(rule, options) {
348
439
  function builtInRules(options, estimate) {
349
440
  const rules = [];
350
441
  if (options.minLength > 0) {
351
- rules.push({ name: "minLength", optionsKey: "minLength", test: (p, o) => p.length >= o.minLength });
442
+ rules.push({ name: "minLength", optionsKey: "minLength", test: (p, o) => passwordLength(p) >= o.minLength });
352
443
  }
353
444
  if (options.maxLength > 0) {
354
- rules.push({ name: "maxLength", optionsKey: "maxLength", test: (p, o) => p.length <= o.maxLength });
445
+ rules.push({ name: "maxLength", optionsKey: "maxLength", test: (p, o) => passwordLength(p) <= o.maxLength });
355
446
  }
356
447
  if (options.uppercaseCheck) {
357
448
  rules.push({ name: "uppercase", optionsKey: "uppercaseCheck", test: (p, o) => o.uppercaseRegex.test(p) });
@@ -372,6 +463,13 @@ function builtInRules(options, estimate) {
372
463
  test: (p, o) => p.length > 0 && !isCommonPassword(p, o.commonPasswords)
373
464
  });
374
465
  }
466
+ if (options.patternCheck) {
467
+ rules.push({
468
+ name: "noPattern",
469
+ optionsKey: "patternCheck",
470
+ test: (p) => p.length > 0 && !isPredictablePattern(p)
471
+ });
472
+ }
375
473
  if (options.confirmPassword !== void 0) {
376
474
  rules.push({
377
475
  name: "match",
@@ -428,6 +526,49 @@ function validatePassword(password, options = {}) {
428
526
  ...estimate ? { estimate } : {}
429
527
  };
430
528
  }
529
+ function breachOptions(option) {
530
+ if (!option) return null;
531
+ return option === true ? {} : option;
532
+ }
533
+ function applyBreachResult(result, breach, options = {}) {
534
+ var _a;
535
+ const failOpen = ((_a = breachOptions(options.breachCheck)) != null ? _a : {}).failOpen !== false;
536
+ const resolved = resolveOptions(options);
537
+ const message = resolveMessage({ name: "notBreached", test: () => true }, resolved);
538
+ const pending = breach.status === "idle" || breach.status === "checking";
539
+ const passed = breach.status === "safe" || breach.status === "error" && failOpen;
540
+ const requirement = pending ? { name: "notBreached", passed: false, message, pending: true } : { name: "notBreached", passed, message };
541
+ const errors = [...result.errors];
542
+ if (!pending && !passed) {
543
+ errors.push(
544
+ breach.status === "error" ? resolveMessage({ name: "breachUnavailable", test: () => true }, resolved) : message
545
+ );
546
+ }
547
+ const pwned = breach.status === "pwned";
548
+ return {
549
+ ...result,
550
+ isValid: result.isValid && passed,
551
+ policyState: { ...result.policyState, notBreached: passed },
552
+ requirements: [...result.requirements.filter((r) => r.name !== "notBreached"), requirement],
553
+ errors,
554
+ strengthScore: result.strengthScore + (passed ? 1 : 0),
555
+ strengthPercent: pwned ? 0 : result.strengthPercent,
556
+ strengthLabel: pwned ? "Very Weak" : result.strengthLabel,
557
+ breach
558
+ };
559
+ }
560
+ async function validatePasswordAsync(password, options = {}) {
561
+ const result = validatePassword(password, options);
562
+ const b = breachOptions(options.breachCheck);
563
+ if (!b) return result;
564
+ if (!result.isValid) return applyBreachResult(result, { status: "idle", count: 0 }, options);
565
+ try {
566
+ const count = await checkPwnedPassword(password != null ? password : "", { fetch: b.fetch, endpoint: b.endpoint, padding: b.padding });
567
+ return applyBreachResult(result, { status: count > 0 ? "pwned" : "safe", count }, options);
568
+ } catch {
569
+ return applyBreachResult(result, { status: "error", count: 0 }, options);
570
+ }
571
+ }
431
572
  function fromZxcvbn(zxcvbn, userInputs) {
432
573
  const run = typeof zxcvbn === "function" ? zxcvbn : (pw, inputs) => zxcvbn.check(pw, inputs);
433
574
  return (password) => {
@@ -446,6 +587,20 @@ function zodPasswordRule(options = {}, { allErrors = true } = {}) {
446
587
  }
447
588
  };
448
589
  }
590
+ function zodPasswordRuleAsync(options = {}, { allErrors = true } = {}) {
591
+ return async (value, ctx) => {
592
+ const { errors } = await validatePasswordAsync(value, options);
593
+ for (const message of allErrors ? errors : errors.slice(0, 1)) {
594
+ ctx.addIssue({ code: "custom", message });
595
+ }
596
+ };
597
+ }
598
+ function passwordValidatorAsync(options = {}) {
599
+ return async (value) => {
600
+ const { errors } = await validatePasswordAsync(value != null ? value : "", options);
601
+ return errors.length === 0 ? true : errors[0];
602
+ };
603
+ }
449
604
  function passwordValidator(options = {}) {
450
605
  return (value) => {
451
606
  const { errors } = validatePassword(value != null ? value : "", options);
@@ -457,12 +612,18 @@ function passwordValidator(options = {}) {
457
612
  COMMON_PASSWORDS,
458
613
  DEFAULT_MESSAGES,
459
614
  DEFAULT_OPTIONS,
615
+ applyBreachResult,
460
616
  checkPwnedPassword,
461
617
  fromZxcvbn,
462
618
  isCommonPassword,
619
+ isPredictablePattern,
620
+ passwordLength,
463
621
  passwordValidator,
622
+ passwordValidatorAsync,
464
623
  presets,
465
624
  resolveOptions,
466
625
  validatePassword,
467
- zodPasswordRule
626
+ validatePasswordAsync,
627
+ zodPasswordRule,
628
+ zodPasswordRuleAsync
468
629
  });
package/dist/core.mjs CHANGED
@@ -177,6 +177,25 @@ var COMMON_PASSWORDS = [
177
177
 
178
178
  // src/pwned.ts
179
179
  var HIBP_RANGE = "https://api.pwnedpasswords.com/range/";
180
+ var CACHE_LIMIT = 100;
181
+ var responseCache = /* @__PURE__ */ new WeakMap();
182
+ var inflight = /* @__PURE__ */ new WeakMap();
183
+ function inflightFor(fetchFn) {
184
+ let map = inflight.get(fetchFn);
185
+ if (!map) {
186
+ map = /* @__PURE__ */ new Map();
187
+ inflight.set(fetchFn, map);
188
+ }
189
+ return map;
190
+ }
191
+ function cacheFor(fetchFn) {
192
+ let cache = responseCache.get(fetchFn);
193
+ if (!cache) {
194
+ cache = /* @__PURE__ */ new Map();
195
+ responseCache.set(fetchFn, cache);
196
+ }
197
+ return cache;
198
+ }
180
199
  async function sha1Hex(input) {
181
200
  var _a;
182
201
  const subtle = (_a = globalThis.crypto) == null ? void 0 : _a.subtle;
@@ -189,19 +208,33 @@ async function sha1Hex(input) {
189
208
  return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("").toUpperCase();
190
209
  }
191
210
  async function checkPwnedPassword(password, options = {}) {
192
- var _a, _b;
211
+ var _a, _b, _c;
193
212
  if (!password) return 0;
194
213
  const doFetch = (_a = options.fetch) != null ? _a : globalThis.fetch;
195
214
  if (!doFetch) throw new Error("use-password-policy: no fetch implementation available.");
196
215
  const hash = await sha1Hex(password);
197
216
  const prefix = hash.slice(0, 5);
198
217
  const suffix = hash.slice(5);
199
- const res = await doFetch(`${(_b = options.endpoint) != null ? _b : HIBP_RANGE}${prefix}`, {
200
- signal: options.signal,
201
- headers: options.padding ? { "Add-Padding": "true" } : void 0
202
- });
203
- if (!res.ok) throw new Error(`use-password-policy: breach check failed (HTTP ${res.status}).`);
204
- const body = await res.text();
218
+ const url = `${(_b = options.endpoint) != null ? _b : HIBP_RANGE}${prefix}`;
219
+ const cache = cacheFor(doFetch);
220
+ let body = cache.get(url);
221
+ if (body === void 0) {
222
+ const pending = inflightFor(doFetch);
223
+ let request = pending.get(url);
224
+ if (!request) {
225
+ request = (async () => {
226
+ const res = await doFetch(url, { headers: options.padding ? { "Add-Padding": "true" } : void 0 });
227
+ if (!res.ok) throw new Error(`use-password-policy: breach check failed (HTTP ${res.status}).`);
228
+ const text = await res.text();
229
+ cache.set(url, text);
230
+ if (cache.size > CACHE_LIMIT) cache.delete(cache.keys().next().value);
231
+ return text;
232
+ })().finally(() => pending.delete(url));
233
+ pending.set(url, request);
234
+ }
235
+ body = await request;
236
+ if ((_c = options.signal) == null ? void 0 : _c.aborted) throw new DOMException("The check was cancelled.", "AbortError");
237
+ }
205
238
  for (const line of body.split("\n")) {
206
239
  const [lineSuffix, count] = line.trim().split(":");
207
240
  if (lineSuffix === suffix) return parseInt(count, 10) || 0;
@@ -219,6 +252,7 @@ var DEFAULT_OPTIONS = {
219
252
  specialCharCheck: true,
220
253
  commonPasswordCheck: false,
221
254
  commonPasswords: COMMON_PASSWORDS,
255
+ patternCheck: false,
222
256
  customRules: [],
223
257
  messages: {},
224
258
  lowercaseRegex: /[a-z]/,
@@ -237,7 +271,8 @@ var presets = {
237
271
  },
238
272
  /**
239
273
  * NIST SP 800-63B-4 (Aug 2025) for passwords used on their own:
240
- * at least 15 characters, allow up to 64, no composition rules, block common passwords.
274
+ * at least 15 characters, allow up to 64, no composition rules, block common passwords
275
+ * and predictable patterns. Add `breachCheck: true` to also check known breaches.
241
276
  */
242
277
  nist: {
243
278
  minLength: 15,
@@ -246,7 +281,8 @@ var presets = {
246
281
  uppercaseCheck: false,
247
282
  numberCheck: false,
248
283
  specialCharCheck: false,
249
- commonPasswordCheck: true
284
+ commonPasswordCheck: true,
285
+ patternCheck: true
250
286
  },
251
287
  /** NIST SP 800-63B-4 when the password is one factor of MFA: at least 8 characters. */
252
288
  nistMfa: {
@@ -256,7 +292,8 @@ var presets = {
256
292
  uppercaseCheck: false,
257
293
  numberCheck: false,
258
294
  specialCharCheck: false,
259
- commonPasswordCheck: true
295
+ commonPasswordCheck: true,
296
+ patternCheck: true
260
297
  }
261
298
  };
262
299
  var DEFAULT_MESSAGES = {
@@ -267,6 +304,9 @@ var DEFAULT_MESSAGES = {
267
304
  number: "A number",
268
305
  specialChar: "A special character",
269
306
  notCommon: "Not a commonly used password",
307
+ noPattern: "No repeats, sequences or keyboard patterns",
308
+ notBreached: "Not found in known data breaches",
309
+ breachUnavailable: "Couldn't check known data breaches. Try again",
270
310
  match: "Passwords match",
271
311
  strength: "Hard to guess"
272
312
  };
@@ -292,6 +332,22 @@ function getCommonSet(list) {
292
332
  }
293
333
  return set;
294
334
  }
335
+ var passwordLength = (password) => Array.from(password).length;
336
+ function isCommonCombo(word, set) {
337
+ const n = word.length;
338
+ if (n < 6) return false;
339
+ const reachable = new Array(n + 1).fill(false);
340
+ reachable[0] = true;
341
+ for (let end = 3; end <= n; end++) {
342
+ for (let start = Math.max(0, end - 20); start <= end - 3; start++) {
343
+ if (reachable[start] && set.has(word.slice(start, end))) {
344
+ reachable[end] = true;
345
+ break;
346
+ }
347
+ }
348
+ }
349
+ return reachable[n];
350
+ }
295
351
  function isCommonPassword(password, list = COMMON_PASSWORDS) {
296
352
  if (!password) return false;
297
353
  const set = getCommonSet(list);
@@ -300,7 +356,36 @@ function isCommonPassword(password, list = COMMON_PASSWORDS) {
300
356
  const trimmed = lower.replace(/[^a-z]+$/, "");
301
357
  const base = unleet(trimmed);
302
358
  const baseNoLeading = unleet(trimmed.replace(/^[^a-z]+/, ""));
303
- return [lower, noTrailingSymbols, base, baseNoLeading].some((c) => c.length > 0 && set.has(c));
359
+ if ([lower, noTrailingSymbols, base, baseNoLeading].some((c) => c.length > 0 && set.has(c))) return true;
360
+ const lettersOnly = lower.replace(/[^a-z]/g, "");
361
+ const unleetedLetters = unleet(baseNoLeading).replace(/[^a-z]/g, "");
362
+ return isCommonCombo(lettersOnly, set) || isCommonCombo(unleetedLetters, set);
363
+ }
364
+ var KEYBOARD_ROWS = ["1234567890", "qwertyuiop", "asdfghjkl", "zxcvbnm", "abcdefghijklmnopqrstuvwxyz"];
365
+ function isStep(a, b) {
366
+ if (a === b) return false;
367
+ for (const row of KEYBOARD_ROWS) {
368
+ const i = row.indexOf(a);
369
+ const j = row.indexOf(b);
370
+ if (i >= 0 && j >= 0 && (Math.abs(i - j) === 1 || Math.abs(i - j) === row.length - 1)) return true;
371
+ }
372
+ return false;
373
+ }
374
+ function isPredictablePattern(password) {
375
+ if (!password) return false;
376
+ if (password.trim() === "") return true;
377
+ const chars = Array.from(password.toLowerCase());
378
+ if (new Set(chars).size <= 3) return true;
379
+ const compact = chars.filter((c) => /[a-z0-9]/.test(c) || c.toUpperCase() !== c).join("");
380
+ for (const s of [compact, compact.replace(/\d+$/, "")]) {
381
+ if (s.length >= 6 && /^(.+?)\1+$/.test(s)) return true;
382
+ }
383
+ if (chars.length >= 4) {
384
+ let steps = 0;
385
+ for (let i = 1; i < chars.length; i++) if (isStep(chars[i - 1], chars[i])) steps++;
386
+ if (steps / (chars.length - 1) >= 0.75) return true;
387
+ }
388
+ return false;
304
389
  }
305
390
  var prettify = (name) => name.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase()).trim();
306
391
  function resolveMessage(rule, options) {
@@ -312,10 +397,10 @@ function resolveMessage(rule, options) {
312
397
  function builtInRules(options, estimate) {
313
398
  const rules = [];
314
399
  if (options.minLength > 0) {
315
- rules.push({ name: "minLength", optionsKey: "minLength", test: (p, o) => p.length >= o.minLength });
400
+ rules.push({ name: "minLength", optionsKey: "minLength", test: (p, o) => passwordLength(p) >= o.minLength });
316
401
  }
317
402
  if (options.maxLength > 0) {
318
- rules.push({ name: "maxLength", optionsKey: "maxLength", test: (p, o) => p.length <= o.maxLength });
403
+ rules.push({ name: "maxLength", optionsKey: "maxLength", test: (p, o) => passwordLength(p) <= o.maxLength });
319
404
  }
320
405
  if (options.uppercaseCheck) {
321
406
  rules.push({ name: "uppercase", optionsKey: "uppercaseCheck", test: (p, o) => o.uppercaseRegex.test(p) });
@@ -336,6 +421,13 @@ function builtInRules(options, estimate) {
336
421
  test: (p, o) => p.length > 0 && !isCommonPassword(p, o.commonPasswords)
337
422
  });
338
423
  }
424
+ if (options.patternCheck) {
425
+ rules.push({
426
+ name: "noPattern",
427
+ optionsKey: "patternCheck",
428
+ test: (p) => p.length > 0 && !isPredictablePattern(p)
429
+ });
430
+ }
339
431
  if (options.confirmPassword !== void 0) {
340
432
  rules.push({
341
433
  name: "match",
@@ -392,6 +484,49 @@ function validatePassword(password, options = {}) {
392
484
  ...estimate ? { estimate } : {}
393
485
  };
394
486
  }
487
+ function breachOptions(option) {
488
+ if (!option) return null;
489
+ return option === true ? {} : option;
490
+ }
491
+ function applyBreachResult(result, breach, options = {}) {
492
+ var _a;
493
+ const failOpen = ((_a = breachOptions(options.breachCheck)) != null ? _a : {}).failOpen !== false;
494
+ const resolved = resolveOptions(options);
495
+ const message = resolveMessage({ name: "notBreached", test: () => true }, resolved);
496
+ const pending = breach.status === "idle" || breach.status === "checking";
497
+ const passed = breach.status === "safe" || breach.status === "error" && failOpen;
498
+ const requirement = pending ? { name: "notBreached", passed: false, message, pending: true } : { name: "notBreached", passed, message };
499
+ const errors = [...result.errors];
500
+ if (!pending && !passed) {
501
+ errors.push(
502
+ breach.status === "error" ? resolveMessage({ name: "breachUnavailable", test: () => true }, resolved) : message
503
+ );
504
+ }
505
+ const pwned = breach.status === "pwned";
506
+ return {
507
+ ...result,
508
+ isValid: result.isValid && passed,
509
+ policyState: { ...result.policyState, notBreached: passed },
510
+ requirements: [...result.requirements.filter((r) => r.name !== "notBreached"), requirement],
511
+ errors,
512
+ strengthScore: result.strengthScore + (passed ? 1 : 0),
513
+ strengthPercent: pwned ? 0 : result.strengthPercent,
514
+ strengthLabel: pwned ? "Very Weak" : result.strengthLabel,
515
+ breach
516
+ };
517
+ }
518
+ async function validatePasswordAsync(password, options = {}) {
519
+ const result = validatePassword(password, options);
520
+ const b = breachOptions(options.breachCheck);
521
+ if (!b) return result;
522
+ if (!result.isValid) return applyBreachResult(result, { status: "idle", count: 0 }, options);
523
+ try {
524
+ const count = await checkPwnedPassword(password != null ? password : "", { fetch: b.fetch, endpoint: b.endpoint, padding: b.padding });
525
+ return applyBreachResult(result, { status: count > 0 ? "pwned" : "safe", count }, options);
526
+ } catch {
527
+ return applyBreachResult(result, { status: "error", count: 0 }, options);
528
+ }
529
+ }
395
530
  function fromZxcvbn(zxcvbn, userInputs) {
396
531
  const run = typeof zxcvbn === "function" ? zxcvbn : (pw, inputs) => zxcvbn.check(pw, inputs);
397
532
  return (password) => {
@@ -410,6 +545,20 @@ function zodPasswordRule(options = {}, { allErrors = true } = {}) {
410
545
  }
411
546
  };
412
547
  }
548
+ function zodPasswordRuleAsync(options = {}, { allErrors = true } = {}) {
549
+ return async (value, ctx) => {
550
+ const { errors } = await validatePasswordAsync(value, options);
551
+ for (const message of allErrors ? errors : errors.slice(0, 1)) {
552
+ ctx.addIssue({ code: "custom", message });
553
+ }
554
+ };
555
+ }
556
+ function passwordValidatorAsync(options = {}) {
557
+ return async (value) => {
558
+ const { errors } = await validatePasswordAsync(value != null ? value : "", options);
559
+ return errors.length === 0 ? true : errors[0];
560
+ };
561
+ }
413
562
  function passwordValidator(options = {}) {
414
563
  return (value) => {
415
564
  const { errors } = validatePassword(value != null ? value : "", options);
@@ -420,12 +569,18 @@ export {
420
569
  COMMON_PASSWORDS,
421
570
  DEFAULT_MESSAGES,
422
571
  DEFAULT_OPTIONS,
572
+ applyBreachResult,
423
573
  checkPwnedPassword,
424
574
  fromZxcvbn,
425
575
  isCommonPassword,
576
+ isPredictablePattern,
577
+ passwordLength,
426
578
  passwordValidator,
579
+ passwordValidatorAsync,
427
580
  presets,
428
581
  resolveOptions,
429
582
  validatePassword,
430
- zodPasswordRule
583
+ validatePasswordAsync,
584
+ zodPasswordRule,
585
+ zodPasswordRuleAsync
431
586
  };