@zudojs/auth 0.1.1 → 1.0.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 +216 -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 +52 -9
  14. package/dist/authProvider/authProvider.core.js +197 -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 +13 -3
  18. package/dist/authSession/authSession.core.js +51 -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 +102 -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 +63 -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 +23 -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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zudojs Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # @zudojs/auth
2
2
 
3
- Authentication primitives including JWT, sessions, password hashing, and RBAC delegation.
3
+ Authentication primitives: JWT access/refresh tokens, server-side sessions,
4
+ scrypt password hashing, brute-force lockout, and RBAC delegation to
5
+ [`@zudojs/permissions`](https://www.npmjs.com/package/@zudojs/permissions).
4
6
 
5
7
  ## Installation
6
8
 
@@ -8,44 +10,239 @@ Authentication primitives including JWT, sessions, password hashing, and RBAC de
8
10
  npm install @zudojs/auth
9
11
  ```
10
12
 
11
- ## Quick Start
13
+ ## Quick start — the auth service
14
+
15
+ `createAuthService()` is the primary entry point. It ties the tokens, the
16
+ session store, the password verifier and the permission engine together, and
17
+ it is the only path that gives you working logout, refresh-token rotation and
18
+ account-state enforcement.
12
19
 
13
20
  ```typescript
14
21
  import {
15
- createTokenPair,
16
- verifyAccessToken,
22
+ createAuthService,
17
23
  createMemorySessionStore,
24
+ createMemoryTokenRevocationStore,
25
+ createMemoryLoginAttemptStore,
18
26
  hashPassword,
19
27
  verifyPassword,
28
+ type AuthUser,
20
29
  type TokenConfig,
21
30
  } from "@zudojs/auth";
31
+ import { createPermissionEngine } from "@zudojs/permissions";
32
+
33
+ // Secrets must be at least 32 bytes and must differ from each other; the
34
+ // package throws AuthConfigurationError otherwise. Never rely on `!` — an
35
+ // env var that is set-but-empty is `""`, not `undefined`.
36
+ function requireSecret(name: string): string {
37
+ const value = process.env[name];
38
+ if (!value || value.length < 32) {
39
+ throw new Error(`${name} must be set to at least 32 characters`);
40
+ }
41
+ return value;
42
+ }
22
43
 
23
44
  const tokenConfig: TokenConfig = {
24
- accessSecret: process.env.JWT_ACCESS_SECRET!,
25
- refreshSecret: process.env.JWT_REFRESH_SECRET!,
45
+ accessSecret: requireSecret("JWT_ACCESS_SECRET"),
46
+ refreshSecret: requireSecret("JWT_REFRESH_SECRET"),
26
47
  accessTtl: 900, // seconds (15 minutes)
27
48
  refreshTtl: 604_800, // seconds (7 days)
49
+ issuer: "my-api",
50
+ audience: "my-app",
51
+ clockToleranceSeconds: 5, // optional skew allowance for exp/iat/nbf
28
52
  };
29
53
 
30
- const tokens = createTokenPair(user.id, tokenConfig, { roles: user.roles });
54
+ const auth = createAuthService({
55
+ token: tokenConfig,
56
+
57
+ // Required: where sessions live. Swap for a Redis-backed SessionStore in
58
+ // production — the in-memory store is per-process.
59
+ sessionStore: createMemorySessionStore(),
60
+
61
+ // Required: idle timeout for a session, in seconds.
62
+ sessionTtlSeconds: 86_400,
63
+ // Recommended: hard ceiling on session age, regardless of activity.
64
+ absoluteSessionTtlSeconds: 7 * 86_400,
65
+
66
+ // Required: look up a user by the identifier submitted at login.
67
+ findUser: async (identifier) => findUserByEmail(identifier),
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.
73
+ findUserById: async (id) => findUserById(id),
74
+
75
+ // Required: check a plain-text password for a user id.
76
+ verifyPassword: async (userId, password) =>
77
+ verifyPassword(password, await loadHash(userId)),
78
+
79
+ // Optional: enables atomic refresh-token rotation and replay detection.
80
+ revocationStore: createMemoryTokenRevocationStore(),
81
+
82
+ // Optional: failed-attempt lockout + login rate limiting.
83
+ loginThrottle: {
84
+ store: createMemoryLoginAttemptStore({ windowSeconds: 60 }),
85
+ maxFailedAttempts: 5, // -> AccountLockedError (423)
86
+ lockoutSeconds: 900,
87
+ maxAttemptsPerWindow: 20, // -> AuthRateLimitError (429)
88
+ },
89
+
90
+ // Optional: real permission matching. Without it, `checkAccess()` throws
91
+ // unless you also set `allowInsecureFallbackGuard: true` (see below).
92
+ permissions: createPermissionEngine({
93
+ roles: [{ name: "admin", permissions: ["*:*"] }],
94
+ }),
95
+ });
96
+
97
+ const { user, tokens, sessionId } = await auth.login(
98
+ { identifier: "alice@example.com", password: "correct horse battery" },
99
+ { userAgent: req.headers["user-agent"], ip: req.socket.remoteAddress },
100
+ );
101
+
102
+ const payload = await auth.verifyToken(tokens.accessToken); // async
103
+ const rotated = await auth.refresh(tokens.refreshToken);
104
+
105
+ await auth.logout(sessionId, tokens.refreshToken); // kills both tokens
106
+ await auth.logoutAll(user.id); // sign out everywhere
107
+ ```
108
+
109
+ ### Sessions are the kill switch
110
+
111
+ `login()` stamps the session id into both tokens as a `sid` claim.
112
+ `verifyToken()` and `refresh()` require that session to still exist, so
113
+ `logout()` / `logoutAll()` invalidate outstanding access **and** refresh
114
+ tokens immediately rather than leaving them live for their natural lifetime.
115
+ Tokens minted with the standalone `createTokenPair()` carry no `sid` and are
116
+ therefore not session-bound.
117
+
118
+ ### Refresh-token rotation
119
+
120
+ With a `revocationStore` configured, `refresh()` claims the presented token's
121
+ `jti` atomically (via `revokeIfNotRevoked`) before minting the next pair.
122
+ Replaying an already-used refresh token throws `TokenRevokedError` **and**
123
+ destroys every session for that user, on the assumption that the chain is
124
+ compromised (RFC 6819 §5.2.2.3). Implement `revokeIfNotRevoked` in any custom
125
+ store — the `isRevoked` + `revoke` fallback is racy.
126
+
127
+ ### Access control
128
+
129
+ ```typescript
130
+ const decision = await auth.checkAccess({
131
+ userId: user.id,
132
+ roles: user.roles,
133
+ permission: "billing:refund",
134
+ resourceOwnerId: invoice.ownerId, // optional
135
+ });
136
+ ```
137
+
138
+ When a `permissions` engine is configured the decision comes from it. When it
139
+ is not, `checkAccess()` **throws** `AuthConfigurationError` rather than
140
+ guessing. Setting `allowInsecureFallbackGuard: true` opts into a built-in
141
+ fallback that grants a resource owner *every* permission and grants the
142
+ `fallbackAdminRole` (default `"admin"`) everything; its allowed results carry
143
+ a `reason` naming the fallback so the decision is auditable.
144
+
145
+ ## Standalone JWT helpers
146
+
147
+ ```typescript
148
+ import { jwt, createTokenPair, verifyAccessToken } from "@zudojs/auth";
149
+
150
+ const tokens = createTokenPair(userId, tokenConfig, { roles: ["editor"] });
31
151
  const result = verifyAccessToken(tokens.accessToken, tokenConfig);
152
+ ```
153
+
154
+ The `jwt` namespace bundles `createTokenPair`, `verifyAccessToken`,
155
+ `verifyRefreshToken`, `refreshAccessToken`, `createMemoryTokenRevocationStore`,
156
+ `parseBearerToken`, `isTokenExpired` and `extractUserId`.
157
+
158
+ `jwt.refreshAccessToken()` is the **non-rotating** variant: it checks the
159
+ signature, expiry and type and nothing else — no revocation store, no user
160
+ re-load, no session check — so a stolen refresh token stays replayable for its
161
+ full lifetime. Use `auth.refresh()` for anything user-facing.
32
162
 
33
- const sessionStore = createMemorySessionStore();
34
- const hash = await hashPassword("plain-text-password");
163
+ Tokens are capped at 8 KB and every segment is bounds-checked before it is
164
+ decoded, so an oversized `Authorization` header is rejected without
165
+ allocating.
166
+
167
+ ## Passwords
168
+
169
+ ```typescript
170
+ import { hashPassword, verifyPassword, needsRehash } from "@zudojs/auth";
171
+
172
+ const hash = await hashPassword("plain-text-password"); // scrypt N=16384,r=8,p=1
35
173
  const ok = await verifyPassword("plain-text-password", hash);
174
+ if (needsRehash(hash)) { /* re-hash on next successful login */ }
36
175
  ```
37
176
 
38
- ## Features
177
+ - Passwords are limited to 1024 bytes (`MAX_PASSWORD_BYTES`).
178
+ - The optional `saltLength` argument must be 16–64 bytes.
179
+ - `verifyPassword` never throws: junk input is a non-match.
180
+ - The package enforces **no password policy** (length, complexity,
181
+ breach checks) — that belongs in your registration handler.
182
+
183
+ ## HTTP helpers
184
+
185
+ ```typescript
186
+ import {
187
+ parseBearerToken, // RFC 7235 case-insensitive scheme, whitespace tolerant
188
+ parseCookies, // null-prototype result, capped at 100 pairs / 8 KB
189
+ generateCsrfToken,
190
+ isTokenExpired, // UNVERIFIED — a hint, never an authorization decision
191
+ extractUserId, // UNVERIFIED — attacker-controlled, returns null if not a string
192
+ } from "@zudojs/auth";
193
+ ```
194
+
195
+ `parseBearerToken` and `parseCookies` accept `unknown` on purpose: they sit on
196
+ the HTTP trust boundary, where a duplicated header is a `string[]`. They never
197
+ throw.
198
+
199
+ ## Errors
200
+
201
+ Every error carries an accurate HTTP status and is safe to expose:
202
+
203
+ | Error | Status | Category |
204
+ | --- | --- | --- |
205
+ | `AuthError` | 401 | authentication |
206
+ | `InvalidCredentialsError` | 401 | authentication |
207
+ | `TokenExpiredError` | 401 | authentication |
208
+ | `TokenInvalidError` | 401 | authentication |
209
+ | `SessionExpiredError` | 401 | authentication |
210
+ | `TokenRevokedError` | 403 | authorization |
211
+ | `AccountDeactivatedError` | 403 | authorization |
212
+ | `AccessDeniedError` | 403 | authorization |
213
+ | `AccountLockedError` | 423 | rate_limit |
214
+ | `AuthRateLimitError` | 429 | rate_limit |
215
+ | `AuthConfigurationError` | 500 | configuration (not exposed) |
216
+
217
+ `AccountLockedError` and `AuthRateLimitError` carry
218
+ `metadata.retryAfterSeconds` for a `Retry-After` header; `AccessDeniedError`
219
+ carries `metadata.requiredPermission`.
220
+
221
+ `login()` throws the same `InvalidCredentialsError` for an unknown user and a
222
+ wrong password, and performs equivalent scrypt work on both paths, so the
223
+ endpoint is not an account-existence oracle. Deactivation is only reported
224
+ after the password has been proven correct.
225
+
226
+ ## What this package does *not* do
39
227
 
40
- - JWT creation and verification
41
- - Session management with stores
42
- - Password hashing and verification
43
- - RBAC delegation to `@zudojs/permissions`
44
- - Token refresh and revocation
228
+ - **No OAuth2 / social login.** OAuth2 lives in
229
+ [`@zudojs/auth-oauth`](../auth-oauth), which implements the
230
+ authorization-code flow with PKCE, mandatory `state`, and provider presets
231
+ for Google, GitHub, Microsoft, Apple and Discord. Nothing OAuth-related is
232
+ exported from this package any more.
233
+ - **No password-reset flow.** `generateRandomToken()` gives you a random
234
+ token; storage, expiry, single-use enforcement and constant-time comparison
235
+ are yours to build.
236
+ - **No distributed state.** The in-memory session, revocation and attempt
237
+ stores are per-process. Implement `SessionStore`, `TokenRevocationStore` and
238
+ `LoginAttemptStore` against Redis or a database for multi-instance
239
+ deployments.
240
+ - **No `AccessDeniedError` throwing.** `checkAccess()` returns a
241
+ `GuardResult`; throw the error from your own middleware when
242
+ `allowed === false`.
45
243
 
46
244
  ## Use Cases
47
245
 
48
- - API authentication
49
- - Session-based login
50
- - Password reset flows
51
- - Role-based access control
246
+ - API authentication with rotating refresh tokens
247
+ - Session-based login with real logout and "sign out everywhere"
248
+ - Role-based access control via `@zudojs/permissions`
@@ -2,47 +2,81 @@
2
2
  * Authentication and authorization error classes.
3
3
  *
4
4
  * @module authErrors
5
+ *
6
+ * Every error in this module carries an accurate HTTP `statusCode` and is
7
+ * marked `expose: true`. The messages are deliberately generic (they never
8
+ * name a user, a password, or an account state that the caller did not
9
+ * already supply), so they are safe to return to a client verbatim.
10
+ */
11
+ import { BaseError, ErrorCode, ErrorCategory, ErrorSeverity, type ErrorMetadata } from "@zudojs/errors";
12
+ /**
13
+ * Options accepted by {@link AuthError} and every subclass.
14
+ *
15
+ * Subclasses supply sensible defaults for `code`, `category`, `statusCode`
16
+ * and `expose`; anything passed here overrides them. Accepting the full set
17
+ * also keeps `BaseError.withMetadata()` — which reconstructs the error from
18
+ * its own fields — lossless for these classes.
5
19
  */
6
- import { BaseError, ErrorCode, type ErrorMetadata } from "@zudojs/errors";
20
+ export interface AuthErrorOptions {
21
+ readonly code?: ErrorCode;
22
+ readonly category?: ErrorCategory;
23
+ readonly severity?: ErrorSeverity;
24
+ readonly statusCode?: number;
25
+ readonly expose?: boolean;
26
+ readonly isOperational?: boolean;
27
+ readonly metadata?: ErrorMetadata;
28
+ readonly cause?: unknown;
29
+ }
7
30
  /**
8
31
  * Base error for all auth-related failures.
32
+ *
33
+ * Defaults to `401 Unauthorized`, category `authentication`, `expose: true`.
9
34
  */
10
35
  export declare class AuthError extends BaseError {
11
- constructor(message: string, options?: {
12
- readonly code?: ErrorCode;
13
- readonly metadata?: ErrorMetadata;
14
- readonly cause?: unknown;
15
- });
36
+ constructor(message: string, options?: AuthErrorOptions);
37
+ }
38
+ /**
39
+ * The package is misconfigured (missing/weak signing secret, missing
40
+ * permission engine, …). Not caused by the request, so `500` and not exposed.
41
+ */
42
+ export declare class AuthConfigurationError extends AuthError {
43
+ constructor(message: string, options?: AuthErrorOptions);
16
44
  }
17
45
  /**
18
46
  * Invalid credentials (wrong password, unknown user).
47
+ *
48
+ * Deliberately identical for "no such user" and "wrong password" so the
49
+ * login endpoint is not an account-existence oracle.
19
50
  */
20
51
  export declare class InvalidCredentialsError extends AuthError {
21
- constructor(message?: string);
52
+ constructor(message?: string, options?: AuthErrorOptions);
22
53
  }
23
54
  /**
24
55
  * Token has expired.
25
56
  */
26
57
  export declare class TokenExpiredError extends AuthError {
27
- constructor(message?: string);
58
+ constructor(message?: string, options?: AuthErrorOptions);
28
59
  }
29
60
  /**
30
61
  * Token is invalid or malformed.
31
62
  */
32
63
  export declare class TokenInvalidError extends AuthError {
33
- constructor(message?: string);
64
+ constructor(message?: string, options?: AuthErrorOptions);
34
65
  }
35
66
  /**
36
67
  * Token has been revoked.
37
68
  */
38
69
  export declare class TokenRevokedError extends AuthError {
39
- constructor(message?: string);
70
+ constructor(message?: string, options?: AuthErrorOptions);
40
71
  }
41
72
  /**
42
73
  * User account is locked (too many failed attempts).
74
+ *
75
+ * `423 Locked`; `metadata.retryAfterSeconds` is intended for a `Retry-After`
76
+ * response header.
43
77
  */
44
78
  export declare class AccountLockedError extends AuthError {
45
- constructor(message?: string, options?: {
79
+ constructor(message?: string, options?: AuthErrorOptions & {
46
80
  readonly retryAfterSeconds?: number;
47
81
  });
48
82
  }
@@ -50,13 +84,13 @@ export declare class AccountLockedError extends AuthError {
50
84
  * User account is deactivated.
51
85
  */
52
86
  export declare class AccountDeactivatedError extends AuthError {
53
- constructor(message?: string);
87
+ constructor(message?: string, options?: AuthErrorOptions);
54
88
  }
55
89
  /**
56
90
  * Access denied (insufficient permissions).
57
91
  */
58
92
  export declare class AccessDeniedError extends AuthError {
59
- constructor(message?: string, options?: {
93
+ constructor(message?: string, options?: AuthErrorOptions & {
60
94
  readonly requiredPermission?: string;
61
95
  });
62
96
  }
@@ -64,13 +98,13 @@ export declare class AccessDeniedError extends AuthError {
64
98
  * Session has expired or is invalid.
65
99
  */
66
100
  export declare class SessionExpiredError extends AuthError {
67
- constructor(message?: string);
101
+ constructor(message?: string, options?: AuthErrorOptions);
68
102
  }
69
103
  /**
70
- * Rate limit exceeded for auth endpoint.
104
+ * Rate limit exceeded for an auth endpoint.
71
105
  */
72
106
  export declare class AuthRateLimitError extends AuthError {
73
- constructor(message?: string, options?: {
107
+ constructor(message?: string, options?: AuthErrorOptions & {
74
108
  readonly retryAfterSeconds?: number;
75
109
  });
76
110
  }
@@ -2,63 +2,120 @@
2
2
  * Authentication and authorization error classes.
3
3
  *
4
4
  * @module authErrors
5
+ *
6
+ * Every error in this module carries an accurate HTTP `statusCode` and is
7
+ * marked `expose: true`. The messages are deliberately generic (they never
8
+ * name a user, a password, or an account state that the caller did not
9
+ * already supply), so they are safe to return to a client verbatim.
5
10
  */
6
11
  import { BaseError, ErrorCode, ErrorCategory, ErrorSeverity, } from "@zudojs/errors";
7
12
  /**
8
13
  * Base error for all auth-related failures.
14
+ *
15
+ * Defaults to `401 Unauthorized`, category `authentication`, `expose: true`.
9
16
  */
10
17
  export class AuthError extends BaseError {
11
18
  constructor(message, options) {
12
19
  super(message, {
13
20
  code: options?.code ?? ErrorCode.AUTHENTICATION,
14
- category: ErrorCategory.AUTHENTICATION,
15
- severity: ErrorSeverity.ERROR,
21
+ category: options?.category ?? ErrorCategory.AUTHENTICATION,
22
+ severity: options?.severity ?? ErrorSeverity.ERROR,
23
+ statusCode: options?.statusCode ?? 401,
24
+ expose: options?.expose ?? true,
25
+ ...(options?.isOperational !== undefined
26
+ ? { isOperational: options.isOperational }
27
+ : {}),
16
28
  metadata: options?.metadata,
17
29
  cause: options?.cause,
18
30
  });
19
31
  }
20
32
  }
33
+ /**
34
+ * The package is misconfigured (missing/weak signing secret, missing
35
+ * permission engine, …). Not caused by the request, so `500` and not exposed.
36
+ */
37
+ export class AuthConfigurationError extends AuthError {
38
+ constructor(message, options) {
39
+ super(message, {
40
+ code: ErrorCode.CONFIGURATION_INVALID,
41
+ category: ErrorCategory.CONFIGURATION,
42
+ severity: ErrorSeverity.CRITICAL,
43
+ statusCode: 500,
44
+ expose: false,
45
+ isOperational: false,
46
+ ...options,
47
+ });
48
+ }
49
+ }
21
50
  /**
22
51
  * Invalid credentials (wrong password, unknown user).
52
+ *
53
+ * Deliberately identical for "no such user" and "wrong password" so the
54
+ * login endpoint is not an account-existence oracle.
23
55
  */
24
56
  export class InvalidCredentialsError extends AuthError {
25
- constructor(message = "Invalid credentials") {
26
- super(message, { code: ErrorCode.INVALID_CREDENTIALS });
57
+ constructor(message = "Invalid credentials", options) {
58
+ super(message, {
59
+ code: ErrorCode.INVALID_CREDENTIALS,
60
+ statusCode: 401,
61
+ ...options,
62
+ });
27
63
  }
28
64
  }
29
65
  /**
30
66
  * Token has expired.
31
67
  */
32
68
  export class TokenExpiredError extends AuthError {
33
- constructor(message = "Token has expired") {
34
- super(message, { code: ErrorCode.TOKEN_EXPIRED });
69
+ constructor(message = "Token has expired", options) {
70
+ super(message, {
71
+ code: ErrorCode.TOKEN_EXPIRED,
72
+ statusCode: 401,
73
+ ...options,
74
+ });
35
75
  }
36
76
  }
37
77
  /**
38
78
  * Token is invalid or malformed.
39
79
  */
40
80
  export class TokenInvalidError extends AuthError {
41
- constructor(message = "Token is invalid") {
42
- super(message, { code: ErrorCode.TOKEN_INVALID });
81
+ constructor(message = "Token is invalid", options) {
82
+ super(message, {
83
+ code: ErrorCode.TOKEN_INVALID,
84
+ statusCode: 401,
85
+ ...options,
86
+ });
43
87
  }
44
88
  }
45
89
  /**
46
90
  * Token has been revoked.
47
91
  */
48
92
  export class TokenRevokedError extends AuthError {
49
- constructor(message = "Token has been revoked") {
50
- super(message, { code: ErrorCode.FORBIDDEN });
93
+ constructor(message = "Token has been revoked", options) {
94
+ super(message, {
95
+ code: ErrorCode.FORBIDDEN,
96
+ category: ErrorCategory.AUTHORIZATION,
97
+ statusCode: 403,
98
+ ...options,
99
+ });
51
100
  }
52
101
  }
53
102
  /**
54
103
  * User account is locked (too many failed attempts).
104
+ *
105
+ * `423 Locked`; `metadata.retryAfterSeconds` is intended for a `Retry-After`
106
+ * response header.
55
107
  */
56
108
  export class AccountLockedError extends AuthError {
57
109
  constructor(message = "Account is locked due to too many failed attempts", options) {
110
+ const { retryAfterSeconds, metadata, ...rest } = options ?? {};
58
111
  super(message, {
59
112
  code: ErrorCode.FORBIDDEN,
113
+ category: ErrorCategory.RATE_LIMIT,
114
+ statusCode: 423,
115
+ ...rest,
60
116
  metadata: {
61
- retryAfterSeconds: options?.retryAfterSeconds ?? 900,
117
+ retryAfterSeconds: retryAfterSeconds ?? 900,
118
+ ...metadata,
62
119
  },
63
120
  });
64
121
  }
@@ -67,8 +124,13 @@ export class AccountLockedError extends AuthError {
67
124
  * User account is deactivated.
68
125
  */
69
126
  export class AccountDeactivatedError extends AuthError {
70
- constructor(message = "User account is deactivated") {
71
- super(message, { code: ErrorCode.FORBIDDEN });
127
+ constructor(message = "User account is deactivated", options) {
128
+ super(message, {
129
+ code: ErrorCode.FORBIDDEN,
130
+ category: ErrorCategory.AUTHORIZATION,
131
+ statusCode: 403,
132
+ ...options,
133
+ });
72
134
  }
73
135
  }
74
136
  /**
@@ -76,10 +138,15 @@ export class AccountDeactivatedError extends AuthError {
76
138
  */
77
139
  export class AccessDeniedError extends AuthError {
78
140
  constructor(message = "Access denied", options) {
141
+ const { requiredPermission, metadata, ...rest } = options ?? {};
79
142
  super(message, {
80
143
  code: ErrorCode.ACCESS_DENIED,
144
+ category: ErrorCategory.AUTHORIZATION,
145
+ statusCode: 403,
146
+ ...rest,
81
147
  metadata: {
82
- requiredPermission: options?.requiredPermission,
148
+ ...(requiredPermission !== undefined ? { requiredPermission } : {}),
149
+ ...metadata,
83
150
  },
84
151
  });
85
152
  }
@@ -88,19 +155,28 @@ export class AccessDeniedError extends AuthError {
88
155
  * Session has expired or is invalid.
89
156
  */
90
157
  export class SessionExpiredError extends AuthError {
91
- constructor(message = "Session has expired") {
92
- super(message, { code: ErrorCode.SESSION_EXPIRED });
158
+ constructor(message = "Session has expired", options) {
159
+ super(message, {
160
+ code: ErrorCode.SESSION_EXPIRED,
161
+ statusCode: 401,
162
+ ...options,
163
+ });
93
164
  }
94
165
  }
95
166
  /**
96
- * Rate limit exceeded for auth endpoint.
167
+ * Rate limit exceeded for an auth endpoint.
97
168
  */
98
169
  export class AuthRateLimitError extends AuthError {
99
170
  constructor(message = "Too many authentication attempts", options) {
171
+ const { retryAfterSeconds, metadata, ...rest } = options ?? {};
100
172
  super(message, {
101
173
  code: ErrorCode.RATE_LIMITED,
174
+ category: ErrorCategory.RATE_LIMIT,
175
+ statusCode: 429,
176
+ ...rest,
102
177
  metadata: {
103
- retryAfterSeconds: options?.retryAfterSeconds ?? 60,
178
+ retryAfterSeconds: retryAfterSeconds ?? 60,
179
+ ...metadata,
104
180
  },
105
181
  });
106
182
  }
@@ -3,5 +3,5 @@
3
3
  *
4
4
  * @module authErrors
5
5
  */
6
- export { AuthError, InvalidCredentialsError, TokenExpiredError, TokenInvalidError, TokenRevokedError, AccountLockedError, AccountDeactivatedError, AccessDeniedError, SessionExpiredError, AuthRateLimitError, } from "./authError.base.js";
6
+ export { AuthError, type AuthErrorOptions, AuthConfigurationError, InvalidCredentialsError, TokenExpiredError, TokenInvalidError, TokenRevokedError, AccountLockedError, AccountDeactivatedError, AccessDeniedError, SessionExpiredError, AuthRateLimitError, } from "./authError.base.js";
7
7
  //# sourceMappingURL=index.d.ts.map
@@ -3,5 +3,5 @@
3
3
  *
4
4
  * @module authErrors
5
5
  */
6
- export { AuthError, InvalidCredentialsError, TokenExpiredError, TokenInvalidError, TokenRevokedError, AccountLockedError, AccountDeactivatedError, AccessDeniedError, SessionExpiredError, AuthRateLimitError, } from "./authError.base.js";
6
+ export { AuthError, AuthConfigurationError, InvalidCredentialsError, TokenExpiredError, TokenInvalidError, TokenRevokedError, AccountLockedError, AccountDeactivatedError, AccessDeniedError, SessionExpiredError, AuthRateLimitError, } from "./authError.base.js";
7
7
  //# sourceMappingURL=index.js.map
@@ -6,12 +6,29 @@
6
6
  * Uses scrypt with salt for secure password hashing.
7
7
  * Compatible with Node.js ≥ 24 (no external dependencies).
8
8
  */
9
+ /** Accepted range for a caller-supplied salt length, in bytes. */
10
+ export declare const MIN_SALT_LENGTH = 16;
11
+ export declare const MAX_SALT_LENGTH = 64;
12
+ /**
13
+ * Maximum accepted password length in bytes.
14
+ *
15
+ * scrypt's cost is set by N/r, not by the input length, so a long password
16
+ * is not a work-factor amplifier — but it is still an unbounded allocation
17
+ * driven by an unauthenticated request body. 1024 bytes is far past any
18
+ * real passphrase.
19
+ */
20
+ export declare const MAX_PASSWORD_BYTES = 1024;
9
21
  /**
10
22
  * Hash a plain-text password.
11
23
  *
12
- * @param password - Plain-text password
13
- * @param saltLength - Salt length in bytes (default: 32)
24
+ * @param password - Plain-text password. Must be at most
25
+ * {@link MAX_PASSWORD_BYTES} bytes of UTF-8.
26
+ * @param saltLength - Salt length in bytes (default: 32). Must be an integer
27
+ * between {@link MIN_SALT_LENGTH} and {@link MAX_SALT_LENGTH}; `0` would
28
+ * otherwise silently produce unsalted, rainbow-table-able hashes.
14
29
  * @returns Hashed password string in format "scrypt$N$r$p$salt$hash"
30
+ * @throws {AuthError} with `ErrorCode.INVALID_INPUT` when the password is
31
+ * not a string, is too long, or the salt length is out of range.
15
32
  */
16
33
  export declare function hashPassword(password: string, saltLength?: number): Promise<string>;
17
34
  /**
@@ -20,6 +37,12 @@ export declare function hashPassword(password: string, saltLength?: number): Pro
20
37
  * Accepts the current "scrypt$N$r$p$salt$hash" format as well as the
21
38
  * legacy "scrypt<salt>$<hash>" format produced by versions ≤ 0.1.1.
22
39
  *
40
+ * Never throws: any input this function cannot make sense of — a
41
+ * non-string, an over-length password (see {@link MAX_PASSWORD_BYTES}), an
42
+ * unparseable hash — is a non-match. Callers are on the request path and
43
+ * treat a `false` as "wrong password", which is the correct outcome for all
44
+ * of those.
45
+ *
23
46
  * @param password - Plain-text password to verify
24
47
  * @param hashedPassword - Previously hashed password
25
48
  * @returns Whether the password matches