@zudojs/auth 0.1.1 → 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.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +224 -19
  3. package/dist/authErrors/authError.base.d.ts +50 -16
  4. package/dist/authErrors/authError.base.js +94 -18
  5. package/dist/authErrors/index.d.ts +1 -1
  6. package/dist/authErrors/index.js +1 -1
  7. package/dist/authPassword/authPassword.core.d.ts +25 -2
  8. package/dist/authPassword/authPassword.core.js +50 -2
  9. package/dist/authPassword/index.d.ts +1 -1
  10. package/dist/authPassword/index.js +1 -1
  11. package/dist/authProvider/authAttempt.memory.d.ts +21 -0
  12. package/dist/authProvider/authAttempt.memory.js +91 -0
  13. package/dist/authProvider/authProvider.core.d.ts +63 -9
  14. package/dist/authProvider/authProvider.core.js +221 -37
  15. package/dist/authProvider/index.d.ts +2 -1
  16. package/dist/authProvider/index.js +1 -0
  17. package/dist/authSession/authSession.core.d.ts +20 -3
  18. package/dist/authSession/authSession.core.js +71 -21
  19. package/dist/authToken/authToken.core.d.ts +27 -1
  20. package/dist/authToken/authToken.core.js +31 -1
  21. package/dist/authToken/authToken.encoding.d.ts +43 -0
  22. package/dist/authToken/authToken.encoding.js +74 -0
  23. package/dist/authToken/authToken.revocation.d.ts +10 -2
  24. package/dist/authToken/authToken.revocation.js +42 -7
  25. package/dist/authToken/authToken.signing.d.ts +24 -1
  26. package/dist/authToken/authToken.signing.js +117 -28
  27. package/dist/authToken/jwt.namespace.d.ts +18 -8
  28. package/dist/authToken/jwt.namespace.js +11 -1
  29. package/dist/authTypes/authAttempt.type.d.ts +65 -0
  30. package/dist/authTypes/authAttempt.type.js +7 -0
  31. package/dist/authTypes/authCredentials.type.d.ts +26 -0
  32. package/dist/authTypes/authCredentials.type.js +11 -0
  33. package/dist/authTypes/authSession.type.d.ts +31 -3
  34. package/dist/authTypes/authSession.type.js +17 -1
  35. package/dist/authTypes/authToken.type.d.ts +29 -1
  36. package/dist/authTypes/authUser.type.d.ts +12 -0
  37. package/dist/authTypes/authUser.type.js +17 -1
  38. package/dist/authTypes/index.d.ts +5 -4
  39. package/dist/authTypes/index.js +5 -4
  40. package/dist/authUtils/authUtils.helper.d.ts +31 -10
  41. package/dist/authUtils/authUtils.helper.js +70 -31
  42. package/package.json +27 -16
  43. package/dist/.tsbuildinfo +0 -1
  44. package/dist/authErrors/authError.base.d.ts.map +0 -1
  45. package/dist/authErrors/authError.base.js.map +0 -1
  46. package/dist/authErrors/index.d.ts.map +0 -1
  47. package/dist/authErrors/index.js.map +0 -1
  48. package/dist/authPassword/authPassword.core.d.ts.map +0 -1
  49. package/dist/authPassword/authPassword.core.js.map +0 -1
  50. package/dist/authPassword/index.d.ts.map +0 -1
  51. package/dist/authPassword/index.js.map +0 -1
  52. package/dist/authProvider/authProvider.core.d.ts.map +0 -1
  53. package/dist/authProvider/authProvider.core.js.map +0 -1
  54. package/dist/authProvider/index.d.ts.map +0 -1
  55. package/dist/authProvider/index.js.map +0 -1
  56. package/dist/authSession/authSession.core.d.ts.map +0 -1
  57. package/dist/authSession/authSession.core.js.map +0 -1
  58. package/dist/authSession/index.d.ts.map +0 -1
  59. package/dist/authSession/index.js.map +0 -1
  60. package/dist/authToken/authToken.core.d.ts.map +0 -1
  61. package/dist/authToken/authToken.core.js.map +0 -1
  62. package/dist/authToken/authToken.revocation.d.ts.map +0 -1
  63. package/dist/authToken/authToken.revocation.js.map +0 -1
  64. package/dist/authToken/authToken.signing.d.ts.map +0 -1
  65. package/dist/authToken/authToken.signing.js.map +0 -1
  66. package/dist/authToken/index.d.ts.map +0 -1
  67. package/dist/authToken/index.js.map +0 -1
  68. package/dist/authToken/jwt.namespace.d.ts.map +0 -1
  69. package/dist/authToken/jwt.namespace.js.map +0 -1
  70. package/dist/authTypes/authRbac.type.d.ts.map +0 -1
  71. package/dist/authTypes/authRbac.type.js.map +0 -1
  72. package/dist/authTypes/authSession.type.d.ts.map +0 -1
  73. package/dist/authTypes/authSession.type.js.map +0 -1
  74. package/dist/authTypes/authStrategy.type.d.ts +0 -83
  75. package/dist/authTypes/authStrategy.type.d.ts.map +0 -1
  76. package/dist/authTypes/authStrategy.type.js +0 -7
  77. package/dist/authTypes/authStrategy.type.js.map +0 -1
  78. package/dist/authTypes/authToken.type.d.ts.map +0 -1
  79. package/dist/authTypes/authToken.type.js.map +0 -1
  80. package/dist/authTypes/authUser.type.d.ts.map +0 -1
  81. package/dist/authTypes/authUser.type.js.map +0 -1
  82. package/dist/authTypes/index.d.ts.map +0 -1
  83. package/dist/authTypes/index.js.map +0 -1
  84. package/dist/authUtils/authUtils.helper.d.ts.map +0 -1
  85. package/dist/authUtils/authUtils.helper.js.map +0 -1
  86. package/dist/authUtils/index.d.ts.map +0 -1
  87. package/dist/authUtils/index.js.map +0 -1
  88. package/dist/index.d.ts.map +0 -1
  89. package/dist/index.js.map +0 -1
@@ -7,8 +7,22 @@
7
7
  * Compatible with Node.js ≥ 24 (no external dependencies).
8
8
  */
9
9
  import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
10
+ import { ErrorCode } from "@zudojs/errors";
11
+ import { AuthError } from "../authErrors/authError.base.js";
10
12
  /** Default salt length in bytes. */
11
13
  const SALT_LENGTH = 32;
14
+ /** Accepted range for a caller-supplied salt length, in bytes. */
15
+ export const MIN_SALT_LENGTH = 16;
16
+ export const MAX_SALT_LENGTH = 64;
17
+ /**
18
+ * Maximum accepted password length in bytes.
19
+ *
20
+ * scrypt's cost is set by N/r, not by the input length, so a long password
21
+ * is not a work-factor amplifier — but it is still an unbounded allocation
22
+ * driven by an unauthenticated request body. 1024 bytes is far past any
23
+ * real passphrase.
24
+ */
25
+ export const MAX_PASSWORD_BYTES = 1024;
12
26
  /** Default key length for scrypt. */
13
27
  const KEY_LENGTH = 64;
14
28
  /** Scrypt parameters (N, r, p). */
@@ -18,11 +32,30 @@ const SCRYPT_P = 1;
18
32
  /**
19
33
  * Hash a plain-text password.
20
34
  *
21
- * @param password - Plain-text password
22
- * @param saltLength - Salt length in bytes (default: 32)
35
+ * @param password - Plain-text password. Must be at most
36
+ * {@link MAX_PASSWORD_BYTES} bytes of UTF-8.
37
+ * @param saltLength - Salt length in bytes (default: 32). Must be an integer
38
+ * between {@link MIN_SALT_LENGTH} and {@link MAX_SALT_LENGTH}; `0` would
39
+ * otherwise silently produce unsalted, rainbow-table-able hashes.
23
40
  * @returns Hashed password string in format "scrypt$N$r$p$salt$hash"
41
+ * @throws {AuthError} with `ErrorCode.INVALID_INPUT` when the password is
42
+ * not a string, is too long, or the salt length is out of range.
24
43
  */
25
44
  export async function hashPassword(password, saltLength = SALT_LENGTH) {
45
+ if (typeof password !== "string") {
46
+ throw new AuthError("Password must be a string.", {
47
+ code: ErrorCode.INVALID_INPUT,
48
+ statusCode: 400,
49
+ });
50
+ }
51
+ if (Buffer.byteLength(password, "utf-8") > MAX_PASSWORD_BYTES) {
52
+ throw new AuthError(`Password exceeds the maximum of ${MAX_PASSWORD_BYTES} bytes.`, { code: ErrorCode.INVALID_INPUT, statusCode: 400 });
53
+ }
54
+ if (!Number.isInteger(saltLength) ||
55
+ saltLength < MIN_SALT_LENGTH ||
56
+ saltLength > MAX_SALT_LENGTH) {
57
+ throw new AuthError(`saltLength must be an integer between ${MIN_SALT_LENGTH} and ${MAX_SALT_LENGTH} bytes.`, { code: ErrorCode.INVALID_INPUT, statusCode: 400 });
58
+ }
26
59
  const salt = randomBytes(saltLength).toString("hex");
27
60
  const derivedKey = await deriveKey(password, salt, {
28
61
  N: SCRYPT_N,
@@ -37,11 +70,23 @@ export async function hashPassword(password, saltLength = SALT_LENGTH) {
37
70
  * Accepts the current "scrypt$N$r$p$salt$hash" format as well as the
38
71
  * legacy "scrypt<salt>$<hash>" format produced by versions ≤ 0.1.1.
39
72
  *
73
+ * Never throws: any input this function cannot make sense of — a
74
+ * non-string, an over-length password (see {@link MAX_PASSWORD_BYTES}), an
75
+ * unparseable hash — is a non-match. Callers are on the request path and
76
+ * treat a `false` as "wrong password", which is the correct outcome for all
77
+ * of those.
78
+ *
40
79
  * @param password - Plain-text password to verify
41
80
  * @param hashedPassword - Previously hashed password
42
81
  * @returns Whether the password matches
43
82
  */
44
83
  export async function verifyPassword(password, hashedPassword) {
84
+ if (typeof password !== "string" || typeof hashedPassword !== "string") {
85
+ return false;
86
+ }
87
+ if (Buffer.byteLength(password, "utf-8") > MAX_PASSWORD_BYTES) {
88
+ return false;
89
+ }
45
90
  const parsed = parseHash(hashedPassword);
46
91
  if (!parsed)
47
92
  return false;
@@ -84,6 +129,9 @@ export function needsRehash(hashedPassword) {
84
129
  * @returns Hex-encoded random string
85
130
  */
86
131
  export function generateRandomToken(length = 32) {
132
+ if (!Number.isInteger(length) || length < 16 || length > 1024) {
133
+ throw new AuthError("generateRandomToken length must be an integer between 16 and 1024 bytes.", { code: ErrorCode.INVALID_INPUT, statusCode: 400 });
134
+ }
87
135
  return randomBytes(length).toString("hex");
88
136
  }
89
137
  function parseHash(hashedPassword) {
@@ -3,5 +3,5 @@
3
3
  *
4
4
  * @module authPassword
5
5
  */
6
- export { hashPassword, verifyPassword, needsRehash, generateRandomToken, } from "./authPassword.core.js";
6
+ export { hashPassword, verifyPassword, needsRehash, generateRandomToken, MIN_SALT_LENGTH, MAX_SALT_LENGTH, MAX_PASSWORD_BYTES, } from "./authPassword.core.js";
7
7
  //# sourceMappingURL=index.d.ts.map
@@ -3,5 +3,5 @@
3
3
  *
4
4
  * @module authPassword
5
5
  */
6
- export { hashPassword, verifyPassword, needsRehash, generateRandomToken, } from "./authPassword.core.js";
6
+ export { hashPassword, verifyPassword, needsRehash, generateRandomToken, MIN_SALT_LENGTH, MAX_SALT_LENGTH, MAX_PASSWORD_BYTES, } from "./authPassword.core.js";
7
7
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,21 @@
1
+ /**
2
+ * In-memory login attempt store.
3
+ *
4
+ * @module authProvider/authAttempt.memory
5
+ *
6
+ * Single-process only: counters are not shared between instances. Back
7
+ * `LoginAttemptStore` with Redis for a real deployment.
8
+ */
9
+ import type { LoginAttemptStore } from "../authTypes/authAttempt.type.js";
10
+ /**
11
+ * Create an in-memory {@link LoginAttemptStore}.
12
+ *
13
+ * @param options.windowSeconds - Rate-limit window length (default: 60).
14
+ * @param options.purgeIntervalMs - Minimum gap between sweeps of stale
15
+ * entries (default: 60000).
16
+ */
17
+ export declare function createMemoryLoginAttemptStore(options?: {
18
+ readonly windowSeconds?: number;
19
+ readonly purgeIntervalMs?: number;
20
+ }): LoginAttemptStore;
21
+ //# sourceMappingURL=authAttempt.memory.d.ts.map
@@ -0,0 +1,91 @@
1
+ /**
2
+ * In-memory login attempt store.
3
+ *
4
+ * @module authProvider/authAttempt.memory
5
+ *
6
+ * Single-process only: counters are not shared between instances. Back
7
+ * `LoginAttemptStore` with Redis for a real deployment.
8
+ */
9
+ const DEFAULT_WINDOW_SECONDS = 60;
10
+ const DEFAULT_PURGE_INTERVAL_MS = 60_000;
11
+ const EMPTY = { failures: 0, attempts: 0 };
12
+ /**
13
+ * Create an in-memory {@link LoginAttemptStore}.
14
+ *
15
+ * @param options.windowSeconds - Rate-limit window length (default: 60).
16
+ * @param options.purgeIntervalMs - Minimum gap between sweeps of stale
17
+ * entries (default: 60000).
18
+ */
19
+ export function createMemoryLoginAttemptStore(options) {
20
+ const windowMs = (options?.windowSeconds ?? DEFAULT_WINDOW_SECONDS) * 1000;
21
+ const purgeIntervalMs = options?.purgeIntervalMs ?? DEFAULT_PURGE_INTERVAL_MS;
22
+ const entries = new Map();
23
+ let lastPurge = 0;
24
+ function maybePurge(now) {
25
+ if (now - lastPurge < purgeIntervalMs)
26
+ return;
27
+ lastPurge = now;
28
+ 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) {
32
+ entries.delete(key);
33
+ }
34
+ }
35
+ }
36
+ function load(identifier, now) {
37
+ maybePurge(now);
38
+ let entry = entries.get(identifier);
39
+ if (!entry) {
40
+ entry = { failures: 0, attempts: 0, windowStart: now };
41
+ entries.set(identifier, entry);
42
+ }
43
+ if (now - entry.windowStart >= windowMs) {
44
+ entry.windowStart = now;
45
+ entry.attempts = 0;
46
+ }
47
+ if (entry.lockedUntil !== undefined && entry.lockedUntil <= now) {
48
+ // Lockout lapsed: clear it and the failure streak that caused it.
49
+ delete entry.lockedUntil;
50
+ entry.failures = 0;
51
+ }
52
+ return entry;
53
+ }
54
+ function snapshot(entry) {
55
+ return entry.lockedUntil !== undefined
56
+ ? {
57
+ failures: entry.failures,
58
+ attempts: entry.attempts,
59
+ lockedUntil: entry.lockedUntil,
60
+ }
61
+ : { failures: entry.failures, attempts: entry.attempts };
62
+ }
63
+ return {
64
+ async get(identifier) {
65
+ const now = Date.now();
66
+ if (!entries.has(identifier)) {
67
+ maybePurge(now);
68
+ return EMPTY;
69
+ }
70
+ return snapshot(load(identifier, now));
71
+ },
72
+ async recordAttempt(identifier) {
73
+ const entry = load(identifier, Date.now());
74
+ entry.attempts++;
75
+ return snapshot(entry);
76
+ },
77
+ async recordFailure(identifier) {
78
+ const entry = load(identifier, Date.now());
79
+ entry.failures++;
80
+ return snapshot(entry);
81
+ },
82
+ async lock(identifier, until) {
83
+ const entry = load(identifier, Date.now());
84
+ entry.lockedUntil = until;
85
+ },
86
+ async reset(identifier) {
87
+ entries.delete(identifier);
88
+ },
89
+ };
90
+ }
91
+ //# sourceMappingURL=authAttempt.memory.js.map
@@ -4,12 +4,26 @@
4
4
  * @module authProvider/authProvider
5
5
  */
6
6
  import type { AuthUser, UserCredentials, UserId } from "../authTypes/authUser.type.js";
7
- import type { TokenPair, TokenConfig, TokenRevocationStore } from "../authTypes/authToken.type.js";
7
+ import type { TokenPair, TokenPayload, TokenConfig, TokenRevocationStore } from "../authTypes/authToken.type.js";
8
8
  import type { SessionStore, SessionId } from "../authTypes/authSession.type.js";
9
- import type { GuardResult } from "../authTypes/authRbac.type.js";
9
+ import type { LoginThrottleConfig } from "../authTypes/authAttempt.type.js";
10
+ import type { GuardContext, GuardResult } from "../authTypes/authRbac.type.js";
10
11
  import type { PermissionEngine } from "@zudojs/permissions";
11
- /** User lookup function provided by the consumer. */
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;
23
+ /** User lookup function provided by the consumer, keyed by login identifier. */
12
24
  export type UserLookup = (identifier: string) => Promise<AuthUser | null>;
25
+ /** User lookup function provided by the consumer, keyed by user id. */
26
+ export type UserByIdLookup = (userId: UserId) => Promise<AuthUser | null>;
13
27
  /** Password verifier function provided by the consumer. */
14
28
  export type PasswordVerifier = (userId: UserId, password: string) => Promise<boolean>;
15
29
  /**
@@ -20,19 +34,52 @@ export interface AuthServiceConfig {
20
34
  readonly token: TokenConfig;
21
35
  /** Session store */
22
36
  readonly sessionStore: SessionStore;
23
- /** User lookup function */
37
+ /** User lookup function, keyed by the identifier submitted at login */
24
38
  readonly findUser: UserLookup;
39
+ /**
40
+ * User lookup keyed by user id (the `sub` claim).
41
+ *
42
+ * Used by `refresh()` to re-load the user on every rotation so that
43
+ * deactivation and role changes take effect instead of being frozen into
44
+ * the refresh token for its whole lifetime.
45
+ *
46
+ * Required. `findUser` is keyed by the login identifier, which for most
47
+ * deployments is an email or username rather than an id — silently reusing
48
+ * it here would make every refresh fail closed at runtime. Requiring this
49
+ * surfaces the mismatch at compile time instead. If your `findUser` really
50
+ * is id-keyed, pass it for both.
51
+ */
52
+ readonly findUserById: UserByIdLookup;
25
53
  /** Password verifier function */
26
54
  readonly verifyPassword: PasswordVerifier;
27
- /** Session TTL in seconds */
55
+ /** Session idle TTL in seconds */
28
56
  readonly sessionTtlSeconds: number;
57
+ /**
58
+ * Absolute maximum session lifetime in seconds. Without it, a session that
59
+ * is used at least once per idle window never expires.
60
+ */
61
+ readonly absoluteSessionTtlSeconds?: number;
29
62
  /** Optional permission engine */
30
63
  readonly permissions?: PermissionEngine;
31
64
  /**
32
65
  * Optional revocation store. When provided, `refresh()` rotates refresh
33
- * tokens: the used token's `jti` is revoked so it cannot be replayed.
66
+ * tokens atomically: the used token's `jti` is revoked so it cannot be
67
+ * replayed, and a replay attempt destroys the user's sessions.
34
68
  */
35
69
  readonly revocationStore?: TokenRevocationStore;
70
+ /** Optional brute-force lockout and login rate limiting. */
71
+ readonly loginThrottle?: LoginThrottleConfig;
72
+ /**
73
+ * Allow `checkAccess()` to fall back to the built-in `simpleGuard` when no
74
+ * `permissions` engine is configured.
75
+ *
76
+ * The fallback grants a resource owner *every* permission and grants the
77
+ * `admin` role everything, so it is opt-in: without this flag,
78
+ * `checkAccess()` throws instead of silently returning `allowed: true`.
79
+ */
80
+ readonly allowInsecureFallbackGuard?: boolean;
81
+ /** Role name the fallback guard treats as superuser (default: "admin"). */
82
+ readonly fallbackAdminRole?: string;
36
83
  }
37
84
  /**
38
85
  * Auth service interface.
@@ -42,10 +89,17 @@ export interface AuthService {
42
89
  readonly userAgent?: string;
43
90
  readonly ip?: string;
44
91
  }): Promise<LoginResult>;
45
- verifyToken(token: string): Record<string, unknown>;
92
+ /**
93
+ * Verify an access token and return its payload.
94
+ *
95
+ * Async because a token carrying a `sid` claim is only accepted while its
96
+ * session is still alive.
97
+ */
98
+ verifyToken(token: string): Promise<TokenPayload>;
46
99
  refresh(refreshToken: string): Promise<TokenPair>;
47
- logout(sessionId: SessionId): Promise<void>;
48
- checkAccess(userId: UserId, userRoles: readonly string[], permission: string, resourceOwnerId?: UserId): Promise<GuardResult>;
100
+ logout(sessionId: SessionId, refreshToken?: string): Promise<void>;
101
+ logoutAll(userId: UserId): Promise<void>;
102
+ checkAccess(context: GuardContext): Promise<GuardResult>;
49
103
  hashPassword(password: string): Promise<string>;
50
104
  verifyPasswordHash(password: string, hash: string): Promise<boolean>;
51
105
  }
@@ -5,57 +5,189 @@
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 { InvalidCredentialsError, TokenExpiredError, TokenInvalidError, TokenRevokedError, AccountDeactivatedError, } from "../authErrors/authError.base.js";
8
+ import { assertTokenSecrets } from "../authToken/authToken.signing.js";
9
+ import { assertPositiveSeconds } from "../authSession/authSession.core.js";
10
+ import { AccountDeactivatedError, AccountLockedError, AuthConfigurationError, AuthRateLimitError, InvalidCredentialsError, SessionExpiredError, TokenExpiredError, TokenInvalidError, TokenRevokedError, } from "../authErrors/authError.base.js";
11
+ /**
12
+ * A syntactically valid hash that no password matches.
13
+ *
14
+ * Verifying against it costs the same scrypt work as a real verification, so
15
+ * the unknown-user path takes comparable time to the wrong-password path and
16
+ * the response time does not disclose whether an account exists.
17
+ */
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
+ }
9
36
  /**
10
37
  * Create an auth service.
11
38
  */
12
39
  export function createAuthService(config) {
13
- const { token: tokenConfig, sessionStore, findUser, verifyPassword: verifyPwd, sessionTtlSeconds, permissions, revocationStore, } = config;
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");
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
+ }
81
+ /**
82
+ * Reject the token unless the session it was issued against is still
83
+ * alive; refresh the session's idle timer when it is.
84
+ */
85
+ async function requireLiveSession(payload) {
86
+ 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;
91
+ const session = await sessionStore.get(sid);
92
+ if (!session) {
93
+ throw new SessionExpiredError("Session is no longer active");
94
+ }
95
+ await sessionStore.touch(sid);
96
+ return sid;
97
+ }
98
+ /**
99
+ * Atomically claim the used refresh token id.
100
+ *
101
+ * @returns true when this call was the one that revoked it.
102
+ */
103
+ async function claimRefreshToken(store, jti, exp) {
104
+ if (store.revokeIfNotRevoked) {
105
+ return store.revokeIfNotRevoked(jti, exp);
106
+ }
107
+ // Racy fallback for stores predating `revokeIfNotRevoked`.
108
+ if (await store.isRevoked(jti))
109
+ return false;
110
+ await store.revoke(jti, exp);
111
+ return true;
112
+ }
14
113
  return {
15
114
  /**
16
115
  * Authenticate a user with credentials and return tokens + session.
116
+ *
117
+ * Unknown user, wrong password and (before the password is proven)
118
+ * deactivated account are indistinguishable to the caller: the same
119
+ * `InvalidCredentialsError` is thrown, and the unknown-user path performs
120
+ * the same scrypt work as the known-user path.
17
121
  */
18
122
  async login(credentials, context) {
19
- const user = await findUser(credentials.identifier);
20
- if (!user) {
123
+ const identifier = credentials.identifier;
124
+ await enforceThrottle(identifier);
125
+ const user = await findUser(identifier);
126
+ let authenticated = false;
127
+ if (user) {
128
+ authenticated = await verifyPwd(user.id, credentials.password);
129
+ }
130
+ else {
131
+ // Burn comparable work so response time does not reveal that the
132
+ // account does not exist.
133
+ await verifyPassword(credentials.password, DUMMY_PASSWORD_HASH);
134
+ }
135
+ if (!user || !authenticated) {
136
+ await recordFailure(identifier);
21
137
  throw new InvalidCredentialsError();
22
138
  }
139
+ // The credentials were correct, so the attempt counter is cleared even
140
+ // if the account turns out to be unusable.
141
+ await loginThrottle?.store.reset(throttleKey(identifier));
142
+ // Account state is only disclosed once the password has been proven,
143
+ // so it cannot be probed without a valid credential.
23
144
  if (!user.active) {
24
145
  throw new AccountDeactivatedError();
25
146
  }
26
- const valid = await verifyPwd(user.id, credentials.password);
27
- if (!valid) {
28
- throw new InvalidCredentialsError();
29
- }
30
- const tokens = createTokenPair(user.id, tokenConfig, {
31
- roles: user.roles,
32
- });
33
147
  const session = await sessionStore.create({
34
148
  userId: user.id,
35
149
  userAgent: context?.userAgent,
36
150
  ip: context?.ip,
37
151
  ttlSeconds: sessionTtlSeconds,
152
+ absoluteTtlSeconds: absoluteSessionTtlSeconds,
153
+ });
154
+ const tokens = createTokenPair(user.id, tokenConfig, {
155
+ roles: user.roles,
156
+ sessionId: session.id,
38
157
  });
39
158
  return { user, tokens, sessionId: session.id };
40
159
  },
41
160
  /**
42
161
  * Verify an access token and return the payload.
162
+ *
163
+ * @throws {TokenExpiredError} expired token
164
+ * @throws {TokenInvalidError} malformed, mis-signed or wrong-type token
165
+ * @throws {SessionExpiredError} the session the token was issued against
166
+ * has been destroyed (logout) or expired
43
167
  */
44
- verifyToken(token) {
168
+ async verifyToken(token) {
45
169
  const result = verifyAccessToken(token, tokenConfig);
46
- if (!result.valid) {
170
+ if (!result.valid || !result.payload) {
47
171
  if (result.error === "Token expired") {
48
172
  throw new TokenExpiredError(result.error);
49
173
  }
50
174
  throw new TokenInvalidError(result.error ?? "Token verification failed");
51
175
  }
52
- return result.payload;
176
+ const payload = result.payload;
177
+ await requireLiveSession(payload);
178
+ return payload;
53
179
  },
54
180
  /**
55
181
  * Refresh an access token using a refresh token.
56
182
  *
57
- * When a revocation store is configured, the used refresh token is
58
- * revoked (rotation) so it cannot be replayed.
183
+ * Rotation is atomic when the revocation store implements
184
+ * `revokeIfNotRevoked`: exactly one of two concurrent refreshes of the
185
+ * same token wins, and the loser is treated as a replay — the user's
186
+ * sessions are destroyed and `TokenRevokedError` is thrown.
187
+ *
188
+ * The user is re-loaded on every refresh, so deactivation and role
189
+ * changes take effect immediately rather than at the end of the refresh
190
+ * token's 7-day life.
59
191
  */
60
192
  async refresh(refreshToken) {
61
193
  const result = verifyRefreshToken(refreshToken, tokenConfig);
@@ -65,27 +197,61 @@ export function createAuthService(config) {
65
197
  }
66
198
  throw new TokenInvalidError("Refresh token is invalid");
67
199
  }
68
- const { sub, jti, exp, roles } = result.payload;
69
- if (revocationStore && (await revocationStore.isRevoked(jti))) {
70
- throw new TokenRevokedError("Refresh token has been revoked");
71
- }
72
- const tokens = createTokenPair(sub, tokenConfig, { roles });
200
+ const payload = result.payload;
201
+ const { sub, jti, exp } = payload;
202
+ const sid = await requireLiveSession(payload);
73
203
  if (revocationStore) {
74
- await revocationStore.revoke(jti, exp);
204
+ const claimed = await claimRefreshToken(revocationStore, jti, exp);
205
+ if (!claimed) {
206
+ // Reuse of an already-rotated refresh token: assume the chain is
207
+ // compromised and terminate every session for the user
208
+ // (RFC 6819 §5.2.2.3).
209
+ await sessionStore.destroyAllForUser(sub);
210
+ throw new TokenRevokedError("Refresh token has already been used");
211
+ }
75
212
  }
76
- return tokens;
213
+ const user = await findUserById(sub);
214
+ if (!user || !user.active) {
215
+ throw new AccountDeactivatedError("User account is no longer active");
216
+ }
217
+ return createTokenPair(user.id, tokenConfig, {
218
+ roles: user.roles,
219
+ ...(sid ? { sessionId: sid } : {}),
220
+ });
77
221
  },
78
222
  /**
79
- * Logout — destroy the session.
223
+ * Logout — destroy the session, which invalidates every access and
224
+ * refresh token carrying that `sid`.
225
+ *
226
+ * @param refreshToken - The refresh token being surrendered. When given
227
+ * alongside a revocation store, its `jti` is revoked too, so the token
228
+ * stays dead even if it is later presented against a new session.
80
229
  */
81
- async logout(sessionId) {
230
+ async logout(sessionId, refreshToken) {
82
231
  await sessionStore.destroy(sessionId);
232
+ if (refreshToken && revocationStore) {
233
+ const result = verifyRefreshToken(refreshToken, tokenConfig);
234
+ if (result.valid && result.payload) {
235
+ await revocationStore.revoke(result.payload.jti, result.payload.exp);
236
+ }
237
+ }
238
+ },
239
+ /**
240
+ * Sign out everywhere — destroy every session for a user. Tokens bound
241
+ * to those sessions stop verifying immediately.
242
+ */
243
+ async logoutAll(userId) {
244
+ await sessionStore.destroyAllForUser(userId);
83
245
  },
84
246
  /**
85
247
  * Check if a user has a specific permission.
86
- * Delegates to @zudojs/permissions engine when configured.
248
+ * Delegates to the @zudojs/permissions engine when configured.
249
+ *
250
+ * @throws {AuthConfigurationError} when no engine is configured and
251
+ * `allowInsecureFallbackGuard` was not set.
87
252
  */
88
- async checkAccess(userId, userRoles, permission, resourceOwnerId) {
253
+ async checkAccess(context) {
254
+ const { userId, roles: userRoles, permission, resourceOwnerId } = context;
89
255
  if (permissions) {
90
256
  const actor = { id: userId, roles: [...userRoles] };
91
257
  const resource = resourceOwnerId
@@ -99,8 +265,12 @@ export function createAuthService(config) {
99
265
  userRoles: [...userRoles],
100
266
  };
101
267
  }
102
- // Fallback: simple wildcard matching (no engine configured)
103
- return simpleGuard(userRoles, permission, userId, resourceOwnerId);
268
+ if (!allowInsecureFallbackGuard) {
269
+ throw new AuthConfigurationError("checkAccess() requires a `permissions` engine. Set " +
270
+ "`allowInsecureFallbackGuard: true` to opt into the built-in " +
271
+ "fallback, which grants resource owners every permission.");
272
+ }
273
+ return simpleGuard(userRoles, permission, userId, resourceOwnerId, fallbackAdminRole ?? "admin");
104
274
  },
105
275
  /**
106
276
  * Hash a password (for user registration).
@@ -117,17 +287,31 @@ export function createAuthService(config) {
117
287
  };
118
288
  }
119
289
  /**
120
- * Simple fallback guard when no permissions engine is configured.
121
- * Grants access to resource owners and to the "admin" role only —
122
- * configure @zudojs/permissions for real role/permission matching.
290
+ * Simple fallback guard used only when no permissions engine is configured
291
+ * *and* the consumer opted in with `allowInsecureFallbackGuard`.
292
+ *
293
+ * It grants a resource owner every permission and grants the configured
294
+ * admin role everything — it does no permission matching at all. Every
295
+ * allowed result carries a `reason` naming the fallback so the decision is
296
+ * distinguishable from an engine-backed one in an audit log.
123
297
  */
124
- function simpleGuard(userRoles, permission, userId, resourceOwnerId) {
125
- // Ownership check
298
+ function simpleGuard(userRoles, permission, userId, resourceOwnerId, adminRole) {
299
+ // Ownership check — note this ignores `permission` entirely.
126
300
  if (resourceOwnerId && resourceOwnerId === userId) {
127
- return { allowed: true, userRoles: [...userRoles] };
301
+ return {
302
+ allowed: true,
303
+ reason: "granted by fallback guard: resource ownership",
304
+ requiredPermission: permission,
305
+ userRoles: [...userRoles],
306
+ };
128
307
  }
129
- if (userRoles.includes("admin")) {
130
- return { allowed: true, userRoles: [...userRoles] };
308
+ if (userRoles.includes(adminRole)) {
309
+ return {
310
+ allowed: true,
311
+ reason: `granted by fallback guard: "${adminRole}" role`,
312
+ requiredPermission: permission,
313
+ userRoles: [...userRoles],
314
+ };
131
315
  }
132
316
  return {
133
317
  allowed: false,
@@ -3,5 +3,6 @@
3
3
  *
4
4
  * @module authProvider
5
5
  */
6
- export { createAuthService, type AuthServiceConfig, type LoginResult, type UserLookup, type PasswordVerifier, } from "./authProvider.core.js";
6
+ export { createAuthService, type AuthService, type AuthServiceConfig, type LoginResult, type UserLookup, type UserByIdLookup, type PasswordVerifier, } from "./authProvider.core.js";
7
+ export { createMemoryLoginAttemptStore } from "./authAttempt.memory.js";
7
8
  //# sourceMappingURL=index.d.ts.map