@zudojs/auth 0.1.0 → 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.
- package/LICENSE +21 -0
- package/README.md +228 -17
- package/dist/authErrors/authError.base.d.ts +50 -16
- package/dist/authErrors/authError.base.js +94 -18
- package/dist/authErrors/index.d.ts +1 -1
- package/dist/authErrors/index.js +1 -1
- package/dist/authPassword/authPassword.core.d.ts +31 -4
- package/dist/authPassword/authPassword.core.js +119 -20
- package/dist/authPassword/index.d.ts +1 -1
- package/dist/authPassword/index.js +1 -1
- package/dist/authProvider/authAttempt.memory.d.ts +21 -0
- package/dist/authProvider/authAttempt.memory.js +91 -0
- package/dist/authProvider/authProvider.core.d.ts +56 -8
- package/dist/authProvider/authProvider.core.js +211 -37
- package/dist/authProvider/index.d.ts +2 -1
- package/dist/authProvider/index.js +1 -0
- package/dist/authSession/authSession.core.d.ts +13 -3
- package/dist/authSession/authSession.core.js +62 -13
- package/dist/authToken/authToken.core.d.ts +27 -1
- package/dist/authToken/authToken.core.js +35 -4
- package/dist/authToken/authToken.encoding.d.ts +43 -0
- package/dist/authToken/authToken.encoding.js +74 -0
- package/dist/authToken/authToken.revocation.d.ts +25 -0
- package/dist/authToken/authToken.revocation.js +71 -0
- package/dist/authToken/authToken.signing.d.ts +25 -1
- package/dist/authToken/authToken.signing.js +111 -33
- package/dist/authToken/index.d.ts +1 -0
- package/dist/authToken/index.js +1 -0
- package/dist/authToken/jwt.namespace.d.ts +18 -8
- package/dist/authToken/jwt.namespace.js +11 -1
- package/dist/authTypes/authAttempt.type.d.ts +63 -0
- package/dist/authTypes/authAttempt.type.js +7 -0
- package/dist/authTypes/authCredentials.type.d.ts +26 -0
- package/dist/authTypes/authCredentials.type.js +11 -0
- package/dist/authTypes/authSession.type.d.ts +31 -3
- package/dist/authTypes/authSession.type.js +17 -1
- package/dist/authTypes/authToken.type.d.ts +48 -1
- package/dist/authTypes/authUser.type.d.ts +12 -0
- package/dist/authTypes/authUser.type.js +17 -1
- package/dist/authTypes/index.d.ts +6 -5
- package/dist/authTypes/index.js +5 -4
- package/dist/authUtils/authUtils.helper.d.ts +31 -10
- package/dist/authUtils/authUtils.helper.js +70 -31
- package/package.json +23 -16
- package/dist/.tsbuildinfo +0 -1
- package/dist/authErrors/authError.base.d.ts.map +0 -1
- package/dist/authErrors/authError.base.js.map +0 -1
- package/dist/authErrors/index.d.ts.map +0 -1
- package/dist/authErrors/index.js.map +0 -1
- package/dist/authPassword/authPassword.core.d.ts.map +0 -1
- package/dist/authPassword/authPassword.core.js.map +0 -1
- package/dist/authPassword/index.d.ts.map +0 -1
- package/dist/authPassword/index.js.map +0 -1
- package/dist/authProvider/authProvider.core.d.ts.map +0 -1
- package/dist/authProvider/authProvider.core.js.map +0 -1
- package/dist/authProvider/index.d.ts.map +0 -1
- package/dist/authProvider/index.js.map +0 -1
- package/dist/authSession/authSession.core.d.ts.map +0 -1
- package/dist/authSession/authSession.core.js.map +0 -1
- package/dist/authSession/index.d.ts.map +0 -1
- package/dist/authSession/index.js.map +0 -1
- package/dist/authToken/authToken.core.d.ts.map +0 -1
- package/dist/authToken/authToken.core.js.map +0 -1
- package/dist/authToken/authToken.signing.d.ts.map +0 -1
- package/dist/authToken/authToken.signing.js.map +0 -1
- package/dist/authToken/index.d.ts.map +0 -1
- package/dist/authToken/index.js.map +0 -1
- package/dist/authToken/jwt.namespace.d.ts.map +0 -1
- package/dist/authToken/jwt.namespace.js.map +0 -1
- package/dist/authTypes/authRbac.type.d.ts.map +0 -1
- package/dist/authTypes/authRbac.type.js.map +0 -1
- package/dist/authTypes/authSession.type.d.ts.map +0 -1
- package/dist/authTypes/authSession.type.js.map +0 -1
- package/dist/authTypes/authStrategy.type.d.ts +0 -83
- package/dist/authTypes/authStrategy.type.d.ts.map +0 -1
- package/dist/authTypes/authStrategy.type.js +0 -7
- package/dist/authTypes/authStrategy.type.js.map +0 -1
- package/dist/authTypes/authToken.type.d.ts.map +0 -1
- package/dist/authTypes/authToken.type.js.map +0 -1
- package/dist/authTypes/authUser.type.d.ts.map +0 -1
- package/dist/authTypes/authUser.type.js.map +0 -1
- package/dist/authTypes/index.d.ts.map +0 -1
- package/dist/authTypes/index.js.map +0 -1
- package/dist/authUtils/authUtils.helper.d.ts.map +0 -1
- package/dist/authUtils/authUtils.helper.js.map +0 -1
- package/dist/authUtils/index.d.ts.map +0 -1
- package/dist/authUtils/index.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- 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
|
|
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,30 +10,239 @@ Authentication primitives including JWT, sessions, password hashing, and RBAC de
|
|
|
8
10
|
npm install @zudojs/auth
|
|
9
11
|
```
|
|
10
12
|
|
|
11
|
-
## Quick
|
|
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
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
createAuthService,
|
|
23
|
+
createMemorySessionStore,
|
|
24
|
+
createMemoryTokenRevocationStore,
|
|
25
|
+
createMemoryLoginAttemptStore,
|
|
26
|
+
hashPassword,
|
|
27
|
+
verifyPassword,
|
|
28
|
+
type AuthUser,
|
|
29
|
+
type TokenConfig,
|
|
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
|
+
}
|
|
43
|
+
|
|
44
|
+
const tokenConfig: TokenConfig = {
|
|
45
|
+
accessSecret: requireSecret("JWT_ACCESS_SECRET"),
|
|
46
|
+
refreshSecret: requireSecret("JWT_REFRESH_SECRET"),
|
|
47
|
+
accessTtl: 900, // seconds (15 minutes)
|
|
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
|
|
52
|
+
};
|
|
53
|
+
|
|
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,
|
|
15
65
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
+
}),
|
|
19
95
|
});
|
|
20
96
|
|
|
21
|
-
const
|
|
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
|
+
});
|
|
22
136
|
```
|
|
23
137
|
|
|
24
|
-
|
|
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"] });
|
|
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.
|
|
162
|
+
|
|
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
|
|
173
|
+
const ok = await verifyPassword("plain-text-password", hash);
|
|
174
|
+
if (needsRehash(hash)) { /* re-hash on next successful login */ }
|
|
175
|
+
```
|
|
176
|
+
|
|
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
|
|
25
227
|
|
|
26
|
-
-
|
|
27
|
-
-
|
|
28
|
-
-
|
|
29
|
-
|
|
30
|
-
|
|
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`.
|
|
31
243
|
|
|
32
244
|
## Use Cases
|
|
33
245
|
|
|
34
|
-
- API authentication
|
|
35
|
-
- Session-based login
|
|
36
|
-
-
|
|
37
|
-
- 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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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:
|
|
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, {
|
|
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:
|
|
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, {
|
|
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:
|
|
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
|
package/dist/authErrors/index.js
CHANGED
|
@@ -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,24 +6,51 @@
|
|
|
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
|
-
*
|
|
14
|
-
* @
|
|
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.
|
|
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
|
/**
|
|
18
35
|
* Verify a plain-text password against a hash.
|
|
19
36
|
*
|
|
37
|
+
* Accepts the current "scrypt$N$r$p$salt$hash" format as well as the
|
|
38
|
+
* legacy "scrypt<salt>$<hash>" format produced by versions ≤ 0.1.1.
|
|
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
|
+
*
|
|
20
46
|
* @param password - Plain-text password to verify
|
|
21
47
|
* @param hashedPassword - Previously hashed password
|
|
22
48
|
* @returns Whether the password matches
|
|
23
49
|
*/
|
|
24
50
|
export declare function verifyPassword(password: string, hashedPassword: string): Promise<boolean>;
|
|
25
51
|
/**
|
|
26
|
-
* Check if a password hash needs rehashing (
|
|
52
|
+
* Check if a password hash needs rehashing (legacy format, changed
|
|
53
|
+
* scrypt parameters, or changed salt length).
|
|
27
54
|
*
|
|
28
55
|
* @param hashedPassword - The stored hash
|
|
29
56
|
* @returns Whether the hash should be regenerated
|