@zudojs/auth 1.1.0 → 1.2.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.
Files changed (30) hide show
  1. package/README.md +41 -5
  2. package/dist/authErrors/authError.base.d.ts +6 -25
  3. package/dist/authErrors/authError.base.js +6 -20
  4. package/dist/authPassword/authPassword.core.d.ts +17 -29
  5. package/dist/authPassword/authPassword.core.js +33 -119
  6. package/dist/authPassword/authPassword.legacy.d.ts +21 -0
  7. package/dist/authPassword/authPassword.legacy.js +85 -0
  8. package/dist/authPassword/authPassword.policy.d.ts +30 -0
  9. package/dist/authPassword/authPassword.policy.js +30 -0
  10. package/dist/authPassword/authPassword.rehash.d.ts +15 -0
  11. package/dist/authPassword/authPassword.rehash.js +33 -0
  12. package/dist/authPassword/index.d.ts +3 -1
  13. package/dist/authPassword/index.js +3 -1
  14. package/dist/authProvider/authAttempt.eviction.d.ts +26 -0
  15. package/dist/authProvider/authAttempt.eviction.js +37 -0
  16. package/dist/authProvider/authAttempt.memory.d.ts +9 -0
  17. package/dist/authProvider/authAttempt.memory.js +22 -5
  18. package/dist/authProvider/authProvider.core.d.ts +13 -11
  19. package/dist/authProvider/authProvider.core.js +14 -60
  20. package/dist/authProvider/authProvider.throttle.d.ts +52 -0
  21. package/dist/authProvider/authProvider.throttle.js +88 -0
  22. package/dist/authSession/authSession.core.js +5 -4
  23. package/dist/authToken/authToken.core.d.ts +6 -0
  24. package/dist/authToken/authToken.core.js +8 -0
  25. package/dist/authToken/authToken.signing.d.ts +5 -0
  26. package/dist/authToken/authToken.signing.js +7 -2
  27. package/dist/authTypes/authAttempt.type.d.ts +13 -3
  28. package/dist/authUtils/authUtils.helper.d.ts +4 -0
  29. package/dist/authUtils/authUtils.helper.js +4 -0
  30. package/package.json +5 -4
@@ -3,5 +3,7 @@
3
3
  *
4
4
  * @module authPassword
5
5
  */
6
- export { hashPassword, verifyPassword, needsRehash, generateRandomToken, MIN_SALT_LENGTH, MAX_SALT_LENGTH, MAX_PASSWORD_BYTES, } from "./authPassword.core.js";
6
+ export { hashPassword, verifyPassword, generateRandomToken, } from "./authPassword.core.js";
7
+ export { needsRehash } from "./authPassword.rehash.js";
8
+ export { MIN_SALT_LENGTH, MAX_SALT_LENGTH, MAX_PASSWORD_BYTES, } from "./authPassword.policy.js";
7
9
  //# sourceMappingURL=index.d.ts.map
@@ -3,5 +3,7 @@
3
3
  *
4
4
  * @module authPassword
5
5
  */
6
- export { hashPassword, verifyPassword, needsRehash, generateRandomToken, MIN_SALT_LENGTH, MAX_SALT_LENGTH, MAX_PASSWORD_BYTES, } from "./authPassword.core.js";
6
+ export { hashPassword, verifyPassword, generateRandomToken, } from "./authPassword.core.js";
7
+ export { needsRehash } from "./authPassword.rehash.js";
8
+ export { MIN_SALT_LENGTH, MAX_SALT_LENGTH, MAX_PASSWORD_BYTES, } from "./authPassword.policy.js";
7
9
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Eviction rules for the in-memory login attempt store.
3
+ *
4
+ * @module authProvider/authAttempt.eviction
5
+ */
6
+ /** The fields of an attempt entry that eviction looks at. */
7
+ export interface EvictableAttemptEntry {
8
+ readonly failures: number;
9
+ readonly windowStart: number;
10
+ readonly lastFailureAt: number;
11
+ readonly lockedUntil?: number;
12
+ }
13
+ /** True when the entry carries no live lockout. */
14
+ export declare function isUnlocked(entry: EvictableAttemptEntry, now: number): boolean;
15
+ /**
16
+ * True when the entry can be dropped without changing any decision: it is
17
+ * unlocked, its rate-limit window has passed, and its failure streak (if
18
+ * any) has been idle for at least `failureTtlMs`.
19
+ */
20
+ export declare function isStale(entry: EvictableAttemptEntry, now: number, windowMs: number, failureTtlMs: number): boolean;
21
+ /**
22
+ * Make room for one new entry: drop the oldest unlocked entry (Map order is
23
+ * insertion order), or the oldest entry of all when every entry is locked.
24
+ */
25
+ export declare function evictOne<E extends EvictableAttemptEntry>(entries: Map<string, E>, now: number): void;
26
+ //# sourceMappingURL=authAttempt.eviction.d.ts.map
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Eviction rules for the in-memory login attempt store.
3
+ *
4
+ * @module authProvider/authAttempt.eviction
5
+ */
6
+ /** True when the entry carries no live lockout. */
7
+ export function isUnlocked(entry, now) {
8
+ return entry.lockedUntil === undefined || entry.lockedUntil <= now;
9
+ }
10
+ /**
11
+ * True when the entry can be dropped without changing any decision: it is
12
+ * unlocked, its rate-limit window has passed, and its failure streak (if
13
+ * any) has been idle for at least `failureTtlMs`.
14
+ */
15
+ export function isStale(entry, now, windowMs, failureTtlMs) {
16
+ if (!isUnlocked(entry, now))
17
+ return false;
18
+ if (now - entry.windowStart < windowMs)
19
+ return false;
20
+ return entry.failures === 0 || now - entry.lastFailureAt >= failureTtlMs;
21
+ }
22
+ /**
23
+ * Make room for one new entry: drop the oldest unlocked entry (Map order is
24
+ * insertion order), or the oldest entry of all when every entry is locked.
25
+ */
26
+ export function evictOne(entries, now) {
27
+ for (const [key, entry] of entries) {
28
+ if (isUnlocked(entry, now)) {
29
+ entries.delete(key);
30
+ return;
31
+ }
32
+ }
33
+ const oldest = entries.keys().next();
34
+ if (!oldest.done)
35
+ entries.delete(oldest.value);
36
+ }
37
+ //# sourceMappingURL=authAttempt.eviction.js.map
@@ -13,9 +13,18 @@ import type { LoginAttemptStore } from "../authTypes/authAttempt.type.js";
13
13
  * @param options.windowSeconds - Rate-limit window length (default: 60).
14
14
  * @param options.purgeIntervalMs - Minimum gap between sweeps of stale
15
15
  * entries (default: 60000).
16
+ * @param options.failureTtlSeconds - Idle time after which an unlocked
17
+ * failure streak is forgotten and its entry evicted (default: 900).
18
+ * Without it, every identifier with one failure was kept forever, so
19
+ * spraying random identifiers grew the map without bound.
20
+ * @param options.maxEntries - Hard cap on tracked identifiers (default:
21
+ * 100000). At the cap the oldest unlocked entry is evicted; locked
22
+ * entries are only evicted when every entry is locked.
16
23
  */
17
24
  export declare function createMemoryLoginAttemptStore(options?: {
18
25
  readonly windowSeconds?: number;
19
26
  readonly purgeIntervalMs?: number;
27
+ readonly failureTtlSeconds?: number;
28
+ readonly maxEntries?: number;
20
29
  }): LoginAttemptStore;
21
30
  //# sourceMappingURL=authAttempt.memory.d.ts.map
@@ -6,8 +6,11 @@
6
6
  * Single-process only: counters are not shared between instances. Back
7
7
  * `LoginAttemptStore` with Redis for a real deployment.
8
8
  */
9
+ import { evictOne, isStale, isUnlocked } from "./authAttempt.eviction.js";
9
10
  const DEFAULT_WINDOW_SECONDS = 60;
10
11
  const DEFAULT_PURGE_INTERVAL_MS = 60_000;
12
+ const DEFAULT_FAILURE_TTL_SECONDS = 900;
13
+ const DEFAULT_MAX_ENTRIES = 100_000;
11
14
  const EMPTY = { failures: 0, attempts: 0 };
12
15
  /**
13
16
  * Create an in-memory {@link LoginAttemptStore}.
@@ -15,10 +18,19 @@ const EMPTY = { failures: 0, attempts: 0 };
15
18
  * @param options.windowSeconds - Rate-limit window length (default: 60).
16
19
  * @param options.purgeIntervalMs - Minimum gap between sweeps of stale
17
20
  * entries (default: 60000).
21
+ * @param options.failureTtlSeconds - Idle time after which an unlocked
22
+ * failure streak is forgotten and its entry evicted (default: 900).
23
+ * Without it, every identifier with one failure was kept forever, so
24
+ * spraying random identifiers grew the map without bound.
25
+ * @param options.maxEntries - Hard cap on tracked identifiers (default:
26
+ * 100000). At the cap the oldest unlocked entry is evicted; locked
27
+ * entries are only evicted when every entry is locked.
18
28
  */
19
29
  export function createMemoryLoginAttemptStore(options) {
20
30
  const windowMs = (options?.windowSeconds ?? DEFAULT_WINDOW_SECONDS) * 1000;
21
31
  const purgeIntervalMs = options?.purgeIntervalMs ?? DEFAULT_PURGE_INTERVAL_MS;
32
+ const failureTtlMs = (options?.failureTtlSeconds ?? DEFAULT_FAILURE_TTL_SECONDS) * 1000;
33
+ const maxEntries = options?.maxEntries ?? DEFAULT_MAX_ENTRIES;
22
34
  const entries = new Map();
23
35
  let lastPurge = 0;
24
36
  function maybePurge(now) {
@@ -26,20 +38,24 @@ export function createMemoryLoginAttemptStore(options) {
26
38
  return;
27
39
  lastPurge = now;
28
40
  for (const [key, entry] of entries) {
29
- const locked = entry.lockedUntil !== undefined && entry.lockedUntil > now;
30
- const fresh = now - entry.windowStart < windowMs;
31
- if (!locked && !fresh && entry.failures === 0) {
41
+ if (isStale(entry, now, windowMs, failureTtlMs))
32
42
  entries.delete(key);
33
- }
34
43
  }
35
44
  }
36
45
  function load(identifier, now) {
37
46
  maybePurge(now);
38
47
  let entry = entries.get(identifier);
39
48
  if (!entry) {
40
- entry = { failures: 0, attempts: 0, windowStart: now };
49
+ if (entries.size >= maxEntries)
50
+ evictOne(entries, now);
51
+ entry = { failures: 0, attempts: 0, windowStart: now, lastFailureAt: 0 };
41
52
  entries.set(identifier, entry);
42
53
  }
54
+ if (entry.failures > 0 &&
55
+ isUnlocked(entry, now) &&
56
+ now - entry.lastFailureAt >= failureTtlMs) {
57
+ entry.failures = 0;
58
+ }
43
59
  if (now - entry.windowStart >= windowMs) {
44
60
  entry.windowStart = now;
45
61
  entry.attempts = 0;
@@ -77,6 +93,7 @@ export function createMemoryLoginAttemptStore(options) {
77
93
  async recordFailure(identifier) {
78
94
  const entry = load(identifier, Date.now());
79
95
  entry.failures++;
96
+ entry.lastFailureAt = Date.now();
80
97
  return snapshot(entry);
81
98
  },
82
99
  async lock(identifier, until) {
@@ -9,17 +9,7 @@ import type { SessionStore, SessionId } from "../authTypes/authSession.type.js";
9
9
  import type { LoginThrottleConfig } from "../authTypes/authAttempt.type.js";
10
10
  import type { GuardContext, GuardResult } from "../authTypes/authRbac.type.js";
11
11
  import type { PermissionEngine } from "@zudojs/permissions";
12
- /**
13
- * The key under which an identifier's login attempts are counted.
14
- *
15
- * Identifiers are emails or usernames, which consumers almost always
16
- * resolve case-insensitively. Counting the raw string gave
17
- * `alice@example.com`, `Alice@example.com` and ` alice@example.com` three
18
- * independent attempt budgets against one account — a lockout bypass that
19
- * cost the attacker nothing. Trimming, NFKC-folding and lower-casing keeps
20
- * unknown and known identifiers throttled identically while closing that.
21
- */
22
- export declare function throttleKey(identifier: string): string;
12
+ export { throttleKey } from "./authProvider.throttle.js";
23
13
  /** User lookup function provided by the consumer, keyed by login identifier. */
24
14
  export type UserLookup = (identifier: string) => Promise<AuthUser | null>;
25
15
  /** User lookup function provided by the consumer, keyed by user id. */
@@ -80,6 +70,18 @@ export interface AuthServiceConfig {
80
70
  readonly allowInsecureFallbackGuard?: boolean;
81
71
  /** Role name the fallback guard treats as superuser (default: "admin"). */
82
72
  readonly fallbackAdminRole?: string;
73
+ /**
74
+ * Accept access and refresh tokens that carry no `sid` claim (default:
75
+ * `false`).
76
+ *
77
+ * Every pair `login()` mints is session-bound, so by default
78
+ * `verifyToken()` and `refresh()` reject a token without a `sid`: such a
79
+ * token cannot be revoked by `logout()` or `logoutAll()`, and accepting
80
+ * it let a session-less refresh chain outlive "sign out everywhere". Set
81
+ * this only if you also mint tokens with the standalone `createTokenPair()`
82
+ * and verify them through this service.
83
+ */
84
+ readonly allowSessionlessTokens?: boolean;
83
85
  }
84
86
  /**
85
87
  * Auth service interface.
@@ -3,11 +3,12 @@
3
3
  *
4
4
  * @module authProvider/authProvider
5
5
  */
6
+ import { createLoginThrottleGate } from "./authProvider.throttle.js";
6
7
  import { hashPassword, verifyPassword, } from "../authPassword/authPassword.core.js";
7
8
  import { createTokenPair, verifyAccessToken, verifyRefreshToken, } from "../authToken/authToken.core.js";
8
9
  import { assertTokenSecrets } from "../authToken/authToken.signing.js";
9
10
  import { assertPositiveSeconds } from "../authSession/authSession.core.js";
10
- import { AccountDeactivatedError, AccountLockedError, AuthConfigurationError, AuthRateLimitError, InvalidCredentialsError, SessionExpiredError, TokenExpiredError, TokenInvalidError, TokenRevokedError, } from "../authErrors/authError.base.js";
11
+ import { AccountDeactivatedError, AuthConfigurationError, InvalidCredentialsError, SessionExpiredError, TokenExpiredError, TokenInvalidError, TokenRevokedError, } from "../authErrors/authError.base.js";
11
12
  /**
12
13
  * A syntactically valid hash that no password matches.
13
14
  *
@@ -15,29 +16,13 @@ import { AccountDeactivatedError, AccountLockedError, AuthConfigurationError, Au
15
16
  * the unknown-user path takes comparable time to the wrong-password path and
16
17
  * the response time does not disclose whether an account exists.
17
18
  */
18
- const DUMMY_PASSWORD_HASH = `scrypt$16384$8$1$${"0".repeat(64)}$${"0".repeat(128)}`;
19
- const DEFAULT_MAX_FAILED_ATTEMPTS = 5;
20
- const DEFAULT_LOCKOUT_SECONDS = 900;
21
- const DEFAULT_MAX_ATTEMPTS_PER_WINDOW = 20;
22
- const DEFAULT_WINDOW_SECONDS = 60;
23
- /**
24
- * The key under which an identifier's login attempts are counted.
25
- *
26
- * Identifiers are emails or usernames, which consumers almost always
27
- * resolve case-insensitively. Counting the raw string gave
28
- * `alice@example.com`, `Alice@example.com` and ` alice@example.com` three
29
- * independent attempt budgets against one account — a lockout bypass that
30
- * cost the attacker nothing. Trimming, NFKC-folding and lower-casing keeps
31
- * unknown and known identifiers throttled identically while closing that.
32
- */
33
- export function throttleKey(identifier) {
34
- return String(identifier).normalize("NFKC").trim().toLowerCase();
35
- }
19
+ const DUMMY_PASSWORD_HASH = `v1$scrypt$16384$8$5$${"A".repeat(43)}.${"A".repeat(86)}`;
20
+ export { throttleKey } from "./authProvider.throttle.js";
36
21
  /**
37
22
  * Create an auth service.
38
23
  */
39
24
  export function createAuthService(config) {
40
- const { token: tokenConfig, sessionStore, findUser, findUserById, verifyPassword: verifyPwd, sessionTtlSeconds, absoluteSessionTtlSeconds, permissions, revocationStore, loginThrottle, allowInsecureFallbackGuard, fallbackAdminRole, } = config;
25
+ const { token: tokenConfig, sessionStore, findUser, findUserById, verifyPassword: verifyPwd, sessionTtlSeconds, absoluteSessionTtlSeconds, permissions, revocationStore, loginThrottle, allowInsecureFallbackGuard, fallbackAdminRole, allowSessionlessTokens, } = config;
41
26
  // Fail at construction, not at the first login: a bad secret or a NaN
42
27
  // lifetime (`Number(process.env.X)` with X unset) otherwise surfaced as
43
28
  // a runtime error on the request path — or, for the session TTL, not at
@@ -45,49 +30,18 @@ export function createAuthService(config) {
45
30
  assertTokenSecrets(tokenConfig);
46
31
  assertPositiveSeconds(sessionTtlSeconds, "sessionTtlSeconds");
47
32
  assertPositiveSeconds(absoluteSessionTtlSeconds, "absoluteSessionTtlSeconds");
48
- const maxFailedAttempts = loginThrottle?.maxFailedAttempts ?? DEFAULT_MAX_FAILED_ATTEMPTS;
49
- const lockoutSeconds = loginThrottle?.lockoutSeconds ?? DEFAULT_LOCKOUT_SECONDS;
50
- const maxAttemptsPerWindow = loginThrottle?.maxAttemptsPerWindow ?? DEFAULT_MAX_ATTEMPTS_PER_WINDOW;
51
- const windowSeconds = loginThrottle?.windowSeconds ?? DEFAULT_WINDOW_SECONDS;
52
- /** Throw if the identifier is locked out or over its attempt budget. */
53
- async function enforceThrottle(identifier) {
54
- if (!loginThrottle)
55
- return;
56
- const key = throttleKey(identifier);
57
- const now = Date.now();
58
- const current = await loginThrottle.store.get(key);
59
- if (current.lockedUntil !== undefined && current.lockedUntil > now) {
60
- throw new AccountLockedError(undefined, {
61
- retryAfterSeconds: Math.ceil((current.lockedUntil - now) / 1000),
62
- });
63
- }
64
- const updated = await loginThrottle.store.recordAttempt(key);
65
- if (updated.attempts > maxAttemptsPerWindow) {
66
- throw new AuthRateLimitError(undefined, {
67
- retryAfterSeconds: windowSeconds,
68
- });
69
- }
70
- }
71
- /** Record a failed authentication and lock the identifier if warranted. */
72
- async function recordFailure(identifier) {
73
- if (!loginThrottle)
74
- return;
75
- const key = throttleKey(identifier);
76
- const updated = await loginThrottle.store.recordFailure(key);
77
- if (updated.failures >= maxFailedAttempts) {
78
- await loginThrottle.store.lock(key, Date.now() + lockoutSeconds * 1000);
79
- }
80
- }
33
+ const throttle = createLoginThrottleGate(loginThrottle);
81
34
  /**
82
35
  * Reject the token unless the session it was issued against is still
83
36
  * alive; refresh the session's idle timer when it is.
84
37
  */
85
38
  async function requireLiveSession(payload) {
86
39
  const sid = payload.sid;
87
- // Tokens minted by `createTokenPair` directly carry no `sid`; they cannot
88
- // be forged, and there is no session to check for them.
89
- if (!sid)
90
- return undefined;
40
+ if (!sid) {
41
+ if (allowSessionlessTokens)
42
+ return undefined;
43
+ throw new TokenInvalidError("Token is not bound to a session");
44
+ }
91
45
  const session = await sessionStore.get(sid);
92
46
  if (!session) {
93
47
  throw new SessionExpiredError("Session is no longer active");
@@ -121,7 +75,7 @@ export function createAuthService(config) {
121
75
  */
122
76
  async login(credentials, context) {
123
77
  const identifier = credentials.identifier;
124
- await enforceThrottle(identifier);
78
+ const slot = await throttle.begin(identifier);
125
79
  const user = await findUser(identifier);
126
80
  let authenticated = false;
127
81
  if (user) {
@@ -133,12 +87,12 @@ export function createAuthService(config) {
133
87
  await verifyPassword(credentials.password, DUMMY_PASSWORD_HASH);
134
88
  }
135
89
  if (!user || !authenticated) {
136
- await recordFailure(identifier);
90
+ await throttle.fail(identifier, slot);
137
91
  throw new InvalidCredentialsError();
138
92
  }
139
93
  // The credentials were correct, so the attempt counter is cleared even
140
94
  // if the account turns out to be unusable.
141
- await loginThrottle?.store.reset(throttleKey(identifier));
95
+ await throttle.succeed(identifier);
142
96
  // Account state is only disclosed once the password has been proven,
143
97
  // so it cannot be probed without a valid credential.
144
98
  if (!user.active) {
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Login throttling for `createAuthService()` — failed-attempt lockout and
3
+ * per-identifier rate limiting.
4
+ *
5
+ * @module authProvider/authProvider.throttle
6
+ */
7
+ import type { LoginThrottleConfig } from "../authTypes/authAttempt.type.js";
8
+ /**
9
+ * The key under which an identifier's login attempts are counted.
10
+ *
11
+ * Identifiers are emails or usernames, which consumers almost always
12
+ * resolve case-insensitively. Counting the raw string gave
13
+ * `alice@example.com`, `Alice@example.com` and ` alice@example.com` three
14
+ * independent attempt budgets against one account — a lockout bypass that
15
+ * cost the attacker nothing. Trimming, NFKC-folding and lower-casing keeps
16
+ * unknown and known identifiers throttled identically while closing that.
17
+ */
18
+ export declare function throttleKey(identifier: string): string;
19
+ /** A failure slot reserved by {@link LoginThrottleGate.begin}. */
20
+ export interface LoginThrottleSlot {
21
+ /** The failure count including this attempt's reservation. */
22
+ readonly failures: number;
23
+ }
24
+ /** Lockout and rate-limit gate wrapped around one `login()` call. */
25
+ export interface LoginThrottleGate {
26
+ /**
27
+ * Admit an attempt. Throws when the identifier is locked or over its
28
+ * window budget; otherwise reserves a failure slot *before* the password
29
+ * is checked and returns it.
30
+ */
31
+ begin(identifier: string): Promise<LoginThrottleSlot>;
32
+ /** The attempt failed: lock the identifier if its slot reached the limit. */
33
+ fail(identifier: string, slot: LoginThrottleSlot): Promise<void>;
34
+ /** The credentials were correct: clear the identifier's counters. */
35
+ succeed(identifier: string): Promise<void>;
36
+ }
37
+ /**
38
+ * Build the throttle gate for a {@link LoginThrottleConfig}.
39
+ *
40
+ * The failure is counted up front and cleared on success. Counting it only
41
+ * after the (slow) password check was check-then-act: a parallel burst all
42
+ * passed the lock check before any failure landed, so an attacker got
43
+ * `maxAttemptsPerWindow` guesses per lockout instead of `maxFailedAttempts`.
44
+ * With the reservation, the (n+1)th concurrent attempt sees `failures > n`
45
+ * and is refused without evaluating the password. This relies on the
46
+ * store's `recordFailure` being atomic, as the in-memory store's is.
47
+ *
48
+ * An attempt that throws for another reason (e.g. the user lookup fails)
49
+ * keeps its reservation, so it counts as a failure.
50
+ */
51
+ export declare function createLoginThrottleGate(config: LoginThrottleConfig | undefined): LoginThrottleGate;
52
+ //# sourceMappingURL=authProvider.throttle.d.ts.map
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Login throttling for `createAuthService()` — failed-attempt lockout and
3
+ * per-identifier rate limiting.
4
+ *
5
+ * @module authProvider/authProvider.throttle
6
+ */
7
+ import { AccountLockedError, AuthRateLimitError, } from "../authErrors/authError.base.js";
8
+ const DEFAULT_MAX_FAILED_ATTEMPTS = 5;
9
+ const DEFAULT_LOCKOUT_SECONDS = 900;
10
+ const DEFAULT_MAX_ATTEMPTS_PER_WINDOW = 20;
11
+ const DEFAULT_WINDOW_SECONDS = 60;
12
+ /**
13
+ * The key under which an identifier's login attempts are counted.
14
+ *
15
+ * Identifiers are emails or usernames, which consumers almost always
16
+ * resolve case-insensitively. Counting the raw string gave
17
+ * `alice@example.com`, `Alice@example.com` and ` alice@example.com` three
18
+ * independent attempt budgets against one account — a lockout bypass that
19
+ * cost the attacker nothing. Trimming, NFKC-folding and lower-casing keeps
20
+ * unknown and known identifiers throttled identically while closing that.
21
+ */
22
+ export function throttleKey(identifier) {
23
+ return String(identifier).normalize("NFKC").trim().toLowerCase();
24
+ }
25
+ const NO_SLOT = Object.freeze({ failures: 0 });
26
+ /**
27
+ * Build the throttle gate for a {@link LoginThrottleConfig}.
28
+ *
29
+ * The failure is counted up front and cleared on success. Counting it only
30
+ * after the (slow) password check was check-then-act: a parallel burst all
31
+ * passed the lock check before any failure landed, so an attacker got
32
+ * `maxAttemptsPerWindow` guesses per lockout instead of `maxFailedAttempts`.
33
+ * With the reservation, the (n+1)th concurrent attempt sees `failures > n`
34
+ * and is refused without evaluating the password. This relies on the
35
+ * store's `recordFailure` being atomic, as the in-memory store's is.
36
+ *
37
+ * An attempt that throws for another reason (e.g. the user lookup fails)
38
+ * keeps its reservation, so it counts as a failure.
39
+ */
40
+ export function createLoginThrottleGate(config) {
41
+ const maxFailedAttempts = config?.maxFailedAttempts ?? DEFAULT_MAX_FAILED_ATTEMPTS;
42
+ const lockoutMs = (config?.lockoutSeconds ?? DEFAULT_LOCKOUT_SECONDS) * 1000;
43
+ const maxAttemptsPerWindow = config?.maxAttemptsPerWindow ?? DEFAULT_MAX_ATTEMPTS_PER_WINDOW;
44
+ const windowSeconds = config?.windowSeconds ?? DEFAULT_WINDOW_SECONDS;
45
+ function locked(record, now) {
46
+ const until = record.lockedUntil ?? now + lockoutMs;
47
+ return new AccountLockedError(undefined, {
48
+ retryAfterSeconds: Math.max(1, Math.ceil((until - now) / 1000)),
49
+ });
50
+ }
51
+ return {
52
+ async begin(identifier) {
53
+ if (!config)
54
+ return NO_SLOT;
55
+ const key = throttleKey(identifier);
56
+ const now = Date.now();
57
+ const current = await config.store.get(key);
58
+ if (current.lockedUntil !== undefined && current.lockedUntil > now) {
59
+ throw locked(current, now);
60
+ }
61
+ const updated = await config.store.recordAttempt(key);
62
+ if (updated.attempts > maxAttemptsPerWindow) {
63
+ throw new AuthRateLimitError(undefined, {
64
+ retryAfterSeconds: windowSeconds,
65
+ });
66
+ }
67
+ const reserved = await config.store.recordFailure(key);
68
+ if (reserved.failures > maxFailedAttempts) {
69
+ const alreadyLocked = reserved.lockedUntil !== undefined && reserved.lockedUntil > now;
70
+ if (!alreadyLocked)
71
+ await config.store.lock(key, now + lockoutMs);
72
+ throw locked(reserved, now);
73
+ }
74
+ return { failures: reserved.failures };
75
+ },
76
+ async fail(identifier, slot) {
77
+ if (!config)
78
+ return;
79
+ if (slot.failures >= maxFailedAttempts) {
80
+ await config.store.lock(throttleKey(identifier), Date.now() + lockoutMs);
81
+ }
82
+ },
83
+ async succeed(identifier) {
84
+ await config?.store.reset(throttleKey(identifier));
85
+ },
86
+ };
87
+ }
88
+ //# sourceMappingURL=authProvider.throttle.js.map
@@ -5,8 +5,8 @@
5
5
  *
6
6
  * For production, implement SessionStore backed by Redis, database, etc.
7
7
  */
8
+ import { randomHex } from "@zudojs/crypto";
8
9
  import { AuthConfigurationError } from "../authErrors/authError.base.js";
9
- import { randomBytes } from "node:crypto";
10
10
  const DEFAULT_TTL_SECONDS = 86400; // 24 hours
11
11
  /** Minimum interval between full sweeps of the session map. */
12
12
  const DEFAULT_PURGE_INTERVAL_MS = 60_000;
@@ -49,7 +49,7 @@ export function createMemorySessionStore(storeOptions) {
49
49
  assertPositiveSeconds(options.ttlSeconds, "ttlSeconds");
50
50
  assertPositiveSeconds(options.absoluteTtlSeconds, "absoluteTtlSeconds");
51
51
  maybePurgeExpired();
52
- const id = generateSessionId();
52
+ const id = await generateSessionId();
53
53
  const now = new Date();
54
54
  const ttlMs = (options.ttlSeconds ?? DEFAULT_TTL_SECONDS) * 1000;
55
55
  const absoluteExpiresAt = options.absoluteTtlSeconds !== undefined
@@ -136,7 +136,8 @@ function clampToAbsolute(expiresAt, absolute) {
136
136
  return expiresAt;
137
137
  return expiresAt.getTime() > absolute.getTime() ? absolute : expiresAt;
138
138
  }
139
- function generateSessionId() {
140
- return randomBytes(32).toString("hex");
139
+ /** 32 random bytes from `@zudojs/crypto`, hex-encoded (64 characters). */
140
+ async function generateSessionId() {
141
+ return (await randomHex(64));
141
142
  }
142
143
  //# sourceMappingURL=authSession.core.js.map
@@ -50,6 +50,12 @@ export declare function verifyRefreshToken(token: JwtToken, config: TokenConfig)
50
50
  * user so deactivation and role changes take effect, and validates the
51
51
  * session. This function exists for callers who manage all of that
52
52
  * themselves.
53
+ *
54
+ * A session-bound refresh token (one with a `sid` claim) yields a pair bound
55
+ * to the same session, so the new tokens still die with `logout()` /
56
+ * `logoutAll()` when verified through `createAuthService()`. Earlier
57
+ * versions dropped the `sid`, turning a logged-out session's refresh token
58
+ * into a permanent, unbound token chain.
53
59
  */
54
60
  export declare function refreshAccessToken(refreshToken: JwtToken, config: TokenConfig, options?: {
55
61
  readonly roles?: readonly string[];
@@ -88,13 +88,21 @@ export function verifyRefreshToken(token, config) {
88
88
  * user so deactivation and role changes take effect, and validates the
89
89
  * session. This function exists for callers who manage all of that
90
90
  * themselves.
91
+ *
92
+ * A session-bound refresh token (one with a `sid` claim) yields a pair bound
93
+ * to the same session, so the new tokens still die with `logout()` /
94
+ * `logoutAll()` when verified through `createAuthService()`. Earlier
95
+ * versions dropped the `sid`, turning a logged-out session's refresh token
96
+ * into a permanent, unbound token chain.
91
97
  */
92
98
  export function refreshAccessToken(refreshToken, config, options) {
93
99
  const result = verifyRefreshToken(refreshToken, config);
94
100
  if (!result.valid || !result.payload)
95
101
  return null;
102
+ const sid = result.payload.sid;
96
103
  return createTokenPair(result.payload.sub, config, {
97
104
  roles: options?.roles ?? result.payload.roles,
105
+ ...(sid ? { sessionId: sid } : {}),
98
106
  });
99
107
  }
100
108
  //# sourceMappingURL=authToken.core.js.map
@@ -36,6 +36,11 @@ export declare function signToken(payload: TokenPayload, secret: string): JwtTok
36
36
  * Never throws for untrusted input: every failure is reported as
37
37
  * `{ valid: false, error }`. Oversized tokens are rejected before anything
38
38
  * is decoded.
39
+ *
40
+ * The signature segment is compared as the canonical base64url *string*,
41
+ * not as decoded bytes: lenient base64 decoding ignores non-alphabet
42
+ * characters and non-zero trailing bits, which gave one issued token an
43
+ * unbounded number of accepted spellings.
39
44
  */
40
45
  export declare function verifyToken(token: JwtToken, secret: string, expectedType: "access" | "refresh", config: TokenConfig): TokenVerificationResult;
41
46
  /**
@@ -84,6 +84,11 @@ export function signToken(payload, secret) {
84
84
  * Never throws for untrusted input: every failure is reported as
85
85
  * `{ valid: false, error }`. Oversized tokens are rejected before anything
86
86
  * is decoded.
87
+ *
88
+ * The signature segment is compared as the canonical base64url *string*,
89
+ * not as decoded bytes: lenient base64 decoding ignores non-alphabet
90
+ * characters and non-zero trailing bits, which gave one issued token an
91
+ * unbounded number of accepted spellings.
87
92
  */
88
93
  export function verifyToken(token, secret, expectedType, config) {
89
94
  const parts = splitToken(token);
@@ -100,8 +105,8 @@ export function verifyToken(token, secret, expectedType, config) {
100
105
  }
101
106
  const signatureInput = `${headerB64}.${bodyB64}`;
102
107
  const expectedSignature = hmacSha256(signatureInput, secret);
103
- const sigBuffer = Buffer.from(signature, "base64url");
104
- const expectedBuffer = Buffer.from(expectedSignature, "base64url");
108
+ const sigBuffer = Buffer.from(signature, "utf-8");
109
+ const expectedBuffer = Buffer.from(expectedSignature, "utf-8");
105
110
  if (sigBuffer.length !== expectedBuffer.length ||
106
111
  !timingSafeEqual(sigBuffer, expectedBuffer)) {
107
112
  return { valid: false, error: "Invalid signature" };
@@ -30,7 +30,11 @@ export interface LoginAttemptStore {
30
30
  get(identifier: string): Promise<LoginAttemptRecord>;
31
31
  /** Count an attempt (before credentials are checked). */
32
32
  recordAttempt(identifier: string): Promise<LoginAttemptRecord>;
33
- /** Count a failed authentication. */
33
+ /**
34
+ * Count a failed authentication. Must be atomic: `createAuthService()`
35
+ * reserves a failure *before* checking the password and relies on
36
+ * concurrent calls seeing distinct, increasing counts.
37
+ */
34
38
  recordFailure(identifier: string): Promise<LoginAttemptRecord>;
35
39
  /** Lock an identifier until `until` (Unix milliseconds). */
36
40
  lock(identifier: string, until: number): Promise<void>;
@@ -52,8 +56,14 @@ export interface LoginThrottleConfig {
52
56
  readonly lockoutSeconds?: number;
53
57
  /**
54
58
  * Attempts allowed per identifier inside the store's rate-limit window
55
- * (default: 20). Exceeding it throws `AuthRateLimitError`. This bounds the
56
- * scrypt work an attacker can force the server to perform.
59
+ * (default: 20). Exceeding it throws `AuthRateLimitError`.
60
+ *
61
+ * This is a *per-identifier* budget: it bounds the password guesses
62
+ * against one account, not the scrypt work the server can be made to do.
63
+ * Every new identifier starts with a fresh budget, and unknown identifiers
64
+ * still burn a full scrypt verification, so an attacker rotating
65
+ * identifiers is not limited by it. Put a per-IP limiter (for example
66
+ * `createRateLimiter` from `@zudojs/security`) in front of `login()`.
57
67
  */
58
68
  readonly maxAttemptsPerWindow?: number;
59
69
  /**
@@ -55,6 +55,10 @@ export declare function extractUserId(token: unknown): string | null;
55
55
  /**
56
56
  * Generate a CSRF token.
57
57
  *
58
+ * @deprecated This returns an unbound random value that nothing in the
59
+ * framework can validate. Use `generateCsrfToken` / `validateCsrfToken`
60
+ * (or `createCsrfProtection`) from `@zudojs/security`, which produce
61
+ * session-bound, HMAC-signed tokens.
58
62
  * @returns Random hex string for CSRF protection
59
63
  */
60
64
  export declare function generateCsrfToken(): string;
@@ -115,6 +115,10 @@ export function extractUserId(token) {
115
115
  /**
116
116
  * Generate a CSRF token.
117
117
  *
118
+ * @deprecated This returns an unbound random value that nothing in the
119
+ * framework can validate. Use `generateCsrfToken` / `validateCsrfToken`
120
+ * (or `createCsrfProtection`) from `@zudojs/security`, which produce
121
+ * session-bound, HMAC-signed tokens.
118
122
  * @returns Random hex string for CSRF protection
119
123
  */
120
124
  export function generateCsrfToken() {