@zudojs/auth 1.0.0 → 1.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/README.md CHANGED
@@ -66,10 +66,10 @@ const auth = createAuthService({
66
66
  // Required: look up a user by the identifier submitted at login.
67
67
  findUser: async (identifier) => findUserByEmail(identifier),
68
68
 
69
- // Strongly recommended: look up a user by id. `refresh()` uses it to
70
- // re-load the user on every rotation, so deactivations and role changes
71
- // take effect immediately. Without it, `refresh()` falls back to
72
- // `findUser(sub)` and rejects the refresh when that returns null.
69
+ // Required: look up a user by id (the token's `sub` claim). `refresh()`
70
+ // uses it to re-load the user on every rotation, so deactivations and
71
+ // role changes take effect immediately. It is keyed differently from
72
+ // `findUser` — do not pass an email-keyed lookup here.
73
73
  findUserById: async (id) => findUserById(id),
74
74
 
75
75
  // Required: check a plain-text password for a user id.
@@ -79,7 +79,9 @@ const auth = createAuthService({
79
79
  // Optional: enables atomic refresh-token rotation and replay detection.
80
80
  revocationStore: createMemoryTokenRevocationStore(),
81
81
 
82
- // Optional: failed-attempt lockout + login rate limiting.
82
+ // Optional: failed-attempt lockout + login rate limiting. Counters are
83
+ // keyed by the submitted identifier, trimmed and case-folded, so
84
+ // "Alice@Example.com" and "alice@example.com" share one budget.
83
85
  loginThrottle: {
84
86
  store: createMemoryLoginAttemptStore({ windowSeconds: 60 }),
85
87
  maxFailedAttempts: 5, // -> AccountLockedError (423)
@@ -174,6 +176,12 @@ const ok = await verifyPassword("plain-text-password", hash);
174
176
  if (needsRehash(hash)) { /* re-hash on next successful login */ }
175
177
  ```
176
178
 
179
+ - `createAuthService()` validates its configuration up front: bad or
180
+ identical secrets, a non-positive or `NaN` `sessionTtlSeconds` /
181
+ `absoluteSessionTtlSeconds`, and a non-finite (`NaN`/`Infinity`)
182
+ `accessTtl` / `refreshTtl` all throw `AuthConfigurationError` at construction rather
183
+ than at the first login (a `NaN` session TTL used to yield sessions that
184
+ never expired).
177
185
  - Passwords are limited to 1024 bytes (`MAX_PASSWORD_BYTES`).
178
186
  - The optional `saltLength` argument must be 16–64 bytes.
179
187
  - `verifyPassword` never throws: junk input is a non-match.
@@ -9,6 +9,17 @@ 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
23
  /** User lookup function provided by the consumer, keyed by login identifier. */
13
24
  export type UserLookup = (identifier: string) => Promise<AuthUser | null>;
14
25
  /** User lookup function provided by the consumer, keyed by user id. */
@@ -5,6 +5,8 @@
5
5
  */
6
6
  import { hashPassword, verifyPassword, } from "../authPassword/authPassword.core.js";
7
7
  import { createTokenPair, verifyAccessToken, verifyRefreshToken, } from "../authToken/authToken.core.js";
8
+ import { assertTokenSecrets } from "../authToken/authToken.signing.js";
9
+ import { assertPositiveSeconds } from "../authSession/authSession.core.js";
8
10
  import { AccountDeactivatedError, AccountLockedError, AuthConfigurationError, AuthRateLimitError, InvalidCredentialsError, SessionExpiredError, TokenExpiredError, TokenInvalidError, TokenRevokedError, } from "../authErrors/authError.base.js";
9
11
  /**
10
12
  * A syntactically valid hash that no password matches.
@@ -18,11 +20,31 @@ const DEFAULT_MAX_FAILED_ATTEMPTS = 5;
18
20
  const DEFAULT_LOCKOUT_SECONDS = 900;
19
21
  const DEFAULT_MAX_ATTEMPTS_PER_WINDOW = 20;
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
+ }
21
36
  /**
22
37
  * Create an auth service.
23
38
  */
24
39
  export function createAuthService(config) {
25
40
  const { token: tokenConfig, sessionStore, findUser, findUserById, verifyPassword: verifyPwd, sessionTtlSeconds, absoluteSessionTtlSeconds, permissions, revocationStore, loginThrottle, allowInsecureFallbackGuard, fallbackAdminRole, } = config;
41
+ // Fail at construction, not at the first login: a bad secret or a NaN
42
+ // lifetime (`Number(process.env.X)` with X unset) otherwise surfaced as
43
+ // a runtime error on the request path — or, for the session TTL, not at
44
+ // all, because a NaN idle timeout produced sessions that never expired.
45
+ assertTokenSecrets(tokenConfig);
46
+ assertPositiveSeconds(sessionTtlSeconds, "sessionTtlSeconds");
47
+ assertPositiveSeconds(absoluteSessionTtlSeconds, "absoluteSessionTtlSeconds");
26
48
  const maxFailedAttempts = loginThrottle?.maxFailedAttempts ?? DEFAULT_MAX_FAILED_ATTEMPTS;
27
49
  const lockoutSeconds = loginThrottle?.lockoutSeconds ?? DEFAULT_LOCKOUT_SECONDS;
28
50
  const maxAttemptsPerWindow = loginThrottle?.maxAttemptsPerWindow ?? DEFAULT_MAX_ATTEMPTS_PER_WINDOW;
@@ -31,14 +53,15 @@ export function createAuthService(config) {
31
53
  async function enforceThrottle(identifier) {
32
54
  if (!loginThrottle)
33
55
  return;
56
+ const key = throttleKey(identifier);
34
57
  const now = Date.now();
35
- const current = await loginThrottle.store.get(identifier);
58
+ const current = await loginThrottle.store.get(key);
36
59
  if (current.lockedUntil !== undefined && current.lockedUntil > now) {
37
60
  throw new AccountLockedError(undefined, {
38
61
  retryAfterSeconds: Math.ceil((current.lockedUntil - now) / 1000),
39
62
  });
40
63
  }
41
- const updated = await loginThrottle.store.recordAttempt(identifier);
64
+ const updated = await loginThrottle.store.recordAttempt(key);
42
65
  if (updated.attempts > maxAttemptsPerWindow) {
43
66
  throw new AuthRateLimitError(undefined, {
44
67
  retryAfterSeconds: windowSeconds,
@@ -49,9 +72,10 @@ export function createAuthService(config) {
49
72
  async function recordFailure(identifier) {
50
73
  if (!loginThrottle)
51
74
  return;
52
- const updated = await loginThrottle.store.recordFailure(identifier);
75
+ const key = throttleKey(identifier);
76
+ const updated = await loginThrottle.store.recordFailure(key);
53
77
  if (updated.failures >= maxFailedAttempts) {
54
- await loginThrottle.store.lock(identifier, Date.now() + lockoutSeconds * 1000);
78
+ await loginThrottle.store.lock(key, Date.now() + lockoutSeconds * 1000);
55
79
  }
56
80
  }
57
81
  /**
@@ -114,7 +138,7 @@ export function createAuthService(config) {
114
138
  }
115
139
  // The credentials were correct, so the attempt counter is cleared even
116
140
  // if the account turns out to be unusable.
117
- await loginThrottle?.store.reset(identifier);
141
+ await loginThrottle?.store.reset(throttleKey(identifier));
118
142
  // Account state is only disclosed once the password has been proven,
119
143
  // so it cannot be probed without a valid credential.
120
144
  if (!user.active) {
@@ -23,4 +23,11 @@ import type { SessionStore } from "../authTypes/authSession.type.js";
23
23
  export declare function createMemorySessionStore(storeOptions?: {
24
24
  readonly purgeIntervalMs?: number;
25
25
  }): SessionStore;
26
+ /**
27
+ * Reject a TTL that cannot produce a real expiry.
28
+ *
29
+ * @throws {AuthConfigurationError} when `value` is defined but is not a
30
+ * finite number greater than zero.
31
+ */
32
+ export declare function assertPositiveSeconds(value: number | undefined, field: string): void;
26
33
  //# sourceMappingURL=authSession.core.d.ts.map
@@ -5,6 +5,7 @@
5
5
  *
6
6
  * For production, implement SessionStore backed by Redis, database, etc.
7
7
  */
8
+ import { AuthConfigurationError } from "../authErrors/authError.base.js";
8
9
  import { randomBytes } from "node:crypto";
9
10
  const DEFAULT_TTL_SECONDS = 86400; // 24 hours
10
11
  /** Minimum interval between full sweeps of the session map. */
@@ -42,6 +43,11 @@ export function createMemorySessionStore(storeOptions) {
42
43
  }
43
44
  return {
44
45
  async create(options) {
46
+ // A non-finite TTL (`Number(undefinedEnvVar)` is the usual source)
47
+ // produced an `Invalid Date` expiry, and `now > NaN` is always false —
48
+ // so the session never expired, not even at its absolute deadline.
49
+ assertPositiveSeconds(options.ttlSeconds, "ttlSeconds");
50
+ assertPositiveSeconds(options.absoluteTtlSeconds, "absoluteTtlSeconds");
45
51
  maybePurgeExpired();
46
52
  const id = generateSessionId();
47
53
  const now = new Date();
@@ -111,6 +117,20 @@ export function createMemorySessionStore(storeOptions) {
111
117
  },
112
118
  };
113
119
  }
120
+ /**
121
+ * Reject a TTL that cannot produce a real expiry.
122
+ *
123
+ * @throws {AuthConfigurationError} when `value` is defined but is not a
124
+ * finite number greater than zero.
125
+ */
126
+ export function assertPositiveSeconds(value, field) {
127
+ if (value === undefined)
128
+ return;
129
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
130
+ throw new AuthConfigurationError(`${field} must be a finite number of seconds greater than zero; ` +
131
+ `got ${String(value)}. A NaN lifetime would create a session that never expires.`);
132
+ }
133
+ }
114
134
  function clampToAbsolute(expiresAt, absolute) {
115
135
  if (!absolute)
116
136
  return expiresAt;
@@ -42,6 +42,21 @@ export function assertTokenSecrets(config) {
42
42
  config.clockToleranceSeconds > 300)) {
43
43
  throw new AuthConfigurationError("TokenConfig.clockToleranceSeconds must be between 0 and 300 seconds.");
44
44
  }
45
+ // A NaN TTL (`Number(process.env.X)` with X unset) minted tokens whose
46
+ // `exp` serialised as `null`, which every verifier then rejected as
47
+ // "Invalid payload" — a misconfiguration that only surfaced as a mystery
48
+ // at first login. Zero and negative TTLs are deliberately still accepted:
49
+ // they mint already-expired tokens, which is a documented way to test
50
+ // expiry handling.
51
+ assertFiniteTtl(config.accessTtl, "accessTtl");
52
+ assertFiniteTtl(config.refreshTtl, "refreshTtl");
53
+ }
54
+ function assertFiniteTtl(value, field) {
55
+ if (value === undefined)
56
+ return;
57
+ if (typeof value !== "number" || !Number.isFinite(value)) {
58
+ throw new AuthConfigurationError(`TokenConfig.${field} must be a finite number of seconds; got ${String(value)}.`);
59
+ }
45
60
  }
46
61
  function assertSecret(secret, field) {
47
62
  if (typeof secret !== "string" || secret.length === 0) {
@@ -17,9 +17,11 @@ export interface LoginAttemptRecord {
17
17
  /**
18
18
  * Store backing failed-attempt lockout and login rate limiting.
19
19
  *
20
- * Keys are the *submitted* identifier, not a resolved user id, so unknown
21
- * and known accounts are throttled identically and the endpoint stays free
22
- * of an existence oracle. The in-memory implementation
20
+ * Keys are the *submitted* identifier — trimmed, NFKC-normalised and
21
+ * lower-cased by `createAuthService()` so that case and whitespace variants
22
+ * of one email share a budget — not a resolved user id, so unknown and
23
+ * known accounts are throttled identically and the endpoint stays free of
24
+ * an existence oracle. The in-memory implementation
23
25
  * (`createMemoryLoginAttemptStore`) is per-process; back this with Redis to
24
26
  * make limits hold across instances.
25
27
  */
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "@zudojs/auth",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Authentication and authorization services for the Zudojs framework — JWT, sessions, RBAC, and password management.",
5
5
  "license": "MIT",
6
+ "author": {
7
+ "name": "Oluwayemi Oyinlola",
8
+ "url": "https://github.com/oyinlola-tech"
9
+ },
6
10
  "type": "module",
7
11
  "main": "./dist/index.js",
8
12
  "module": "./dist/index.js",
@@ -21,9 +25,9 @@
21
25
  "!dist/.tsbuildinfo"
22
26
  ],
23
27
  "dependencies": {
24
- "@zudojs/errors": "1.0.0",
25
- "@zudojs/constants": "1.0.0",
26
- "@zudojs/permissions": "1.0.0"
28
+ "@zudojs/errors": "1.0.1",
29
+ "@zudojs/constants": "1.0.1",
30
+ "@zudojs/permissions": "1.1.0"
27
31
  },
28
32
  "engines": {
29
33
  "node": ">=24.0.0"