@zudojs/auth 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -10
- package/dist/authErrors/authError.base.d.ts +6 -25
- package/dist/authErrors/authError.base.js +6 -20
- package/dist/authPassword/authPassword.core.d.ts +17 -29
- package/dist/authPassword/authPassword.core.js +33 -119
- package/dist/authPassword/authPassword.legacy.d.ts +21 -0
- package/dist/authPassword/authPassword.legacy.js +85 -0
- package/dist/authPassword/authPassword.policy.d.ts +30 -0
- package/dist/authPassword/authPassword.policy.js +30 -0
- package/dist/authPassword/authPassword.rehash.d.ts +15 -0
- package/dist/authPassword/authPassword.rehash.js +33 -0
- package/dist/authPassword/index.d.ts +3 -1
- package/dist/authPassword/index.js +3 -1
- package/dist/authProvider/authAttempt.eviction.d.ts +26 -0
- package/dist/authProvider/authAttempt.eviction.js +37 -0
- package/dist/authProvider/authAttempt.memory.d.ts +9 -0
- package/dist/authProvider/authAttempt.memory.js +22 -5
- package/dist/authProvider/authProvider.core.d.ts +13 -0
- package/dist/authProvider/authProvider.core.js +23 -45
- package/dist/authProvider/authProvider.throttle.d.ts +52 -0
- package/dist/authProvider/authProvider.throttle.js +88 -0
- package/dist/authSession/authSession.core.d.ts +7 -0
- package/dist/authSession/authSession.core.js +25 -4
- package/dist/authToken/authToken.core.d.ts +6 -0
- package/dist/authToken/authToken.core.js +8 -0
- package/dist/authToken/authToken.signing.d.ts +5 -0
- package/dist/authToken/authToken.signing.js +22 -2
- package/dist/authTypes/authAttempt.type.d.ts +18 -6
- package/dist/authUtils/authUtils.helper.d.ts +4 -0
- package/dist/authUtils/authUtils.helper.js +4 -0
- package/package.json +9 -4
package/README.md
CHANGED
|
@@ -4,6 +4,12 @@ Authentication primitives: JWT access/refresh tokens, server-side sessions,
|
|
|
4
4
|
scrypt password hashing, brute-force lockout, and RBAC delegation to
|
|
5
5
|
[`@zudojs/permissions`](https://www.npmjs.com/package/@zudojs/permissions).
|
|
6
6
|
|
|
7
|
+
<!-- zudo-docs:start -->
|
|
8
|
+
|
|
9
|
+
**Documentation:** [zudojs.oyinlola.site/docs/packages-auth](https://zudojs.oyinlola.site/docs/packages-auth) · **For AI agents:** [Markdown version](https://zudojs.oyinlola.site/docs/packages-auth.md), [llms.txt](https://zudojs.oyinlola.site/llms.txt)
|
|
10
|
+
|
|
11
|
+
<!-- zudo-docs:end -->
|
|
12
|
+
|
|
7
13
|
## Installation
|
|
8
14
|
|
|
9
15
|
```bash
|
|
@@ -66,10 +72,10 @@ const auth = createAuthService({
|
|
|
66
72
|
// Required: look up a user by the identifier submitted at login.
|
|
67
73
|
findUser: async (identifier) => findUserByEmail(identifier),
|
|
68
74
|
|
|
69
|
-
//
|
|
70
|
-
// re-load the user on every rotation, so deactivations and
|
|
71
|
-
// take effect immediately.
|
|
72
|
-
// `findUser
|
|
75
|
+
// Required: look up a user by id (the token's `sub` claim). `refresh()`
|
|
76
|
+
// uses it to re-load the user on every rotation, so deactivations and
|
|
77
|
+
// role changes take effect immediately. It is keyed differently from
|
|
78
|
+
// `findUser` — do not pass an email-keyed lookup here.
|
|
73
79
|
findUserById: async (id) => findUserById(id),
|
|
74
80
|
|
|
75
81
|
// Required: check a plain-text password for a user id.
|
|
@@ -79,12 +85,14 @@ const auth = createAuthService({
|
|
|
79
85
|
// Optional: enables atomic refresh-token rotation and replay detection.
|
|
80
86
|
revocationStore: createMemoryTokenRevocationStore(),
|
|
81
87
|
|
|
82
|
-
// Optional: failed-attempt lockout + login rate limiting.
|
|
88
|
+
// Optional: failed-attempt lockout + login rate limiting. Counters are
|
|
89
|
+
// keyed by the submitted identifier, trimmed and case-folded, so
|
|
90
|
+
// "Alice@Example.com" and "alice@example.com" share one budget.
|
|
83
91
|
loginThrottle: {
|
|
84
92
|
store: createMemoryLoginAttemptStore({ windowSeconds: 60 }),
|
|
85
93
|
maxFailedAttempts: 5, // -> AccountLockedError (423)
|
|
86
94
|
lockoutSeconds: 900,
|
|
87
|
-
maxAttemptsPerWindow: 20, // -> AuthRateLimitError (429)
|
|
95
|
+
maxAttemptsPerWindow: 20, // -> AuthRateLimitError (429), per identifier
|
|
88
96
|
},
|
|
89
97
|
|
|
90
98
|
// Optional: real permission matching. Without it, `checkAccess()` throws
|
|
@@ -112,8 +120,28 @@ await auth.logoutAll(user.id); // sign out everywhere
|
|
|
112
120
|
`verifyToken()` and `refresh()` require that session to still exist, so
|
|
113
121
|
`logout()` / `logoutAll()` invalidate outstanding access **and** refresh
|
|
114
122
|
tokens immediately rather than leaving them live for their natural lifetime.
|
|
115
|
-
Tokens minted with the standalone `createTokenPair()` carry no `sid
|
|
116
|
-
therefore
|
|
123
|
+
Tokens minted with the standalone `createTokenPair()` carry no `sid`, so no
|
|
124
|
+
logout can revoke them. **The service therefore rejects a token without a
|
|
125
|
+
`sid` (`TokenInvalidError`) unless you set `allowSessionlessTokens: true`.**
|
|
126
|
+
Accepting them by default let a session-less refresh chain outlive
|
|
127
|
+
`logoutAll()`.
|
|
128
|
+
|
|
129
|
+
### Brute-force lockout
|
|
130
|
+
|
|
131
|
+
A failure is reserved *before* the password is checked and cleared on
|
|
132
|
+
success, so a parallel burst gets exactly `maxFailedAttempts` guesses before
|
|
133
|
+
the lockout, not `maxAttemptsPerWindow`. Custom `LoginAttemptStore`s must make
|
|
134
|
+
`recordFailure` atomic for this to hold.
|
|
135
|
+
|
|
136
|
+
The budgets are **per identifier**. An attacker rotating identifiers is not
|
|
137
|
+
limited by them, and every unknown identifier still costs one scrypt
|
|
138
|
+
verification, so put a per-IP limiter (`createRateLimiter` from
|
|
139
|
+
`@zudojs/security`) in front of `login()`.
|
|
140
|
+
|
|
141
|
+
`createMemoryLoginAttemptStore({ failureTtlSeconds, maxEntries })` forgets an
|
|
142
|
+
unlocked failure streak after `failureTtlSeconds` of inactivity (default 900)
|
|
143
|
+
and caps the tracked identifiers at `maxEntries` (default 100 000, oldest
|
|
144
|
+
unlocked evicted first), so spraying identifiers cannot grow it without bound.
|
|
117
145
|
|
|
118
146
|
### Refresh-token rotation
|
|
119
147
|
|
|
@@ -158,7 +186,9 @@ The `jwt` namespace bundles `createTokenPair`, `verifyAccessToken`,
|
|
|
158
186
|
`jwt.refreshAccessToken()` is the **non-rotating** variant: it checks the
|
|
159
187
|
signature, expiry and type and nothing else — no revocation store, no user
|
|
160
188
|
re-load, no session check — so a stolen refresh token stays replayable for its
|
|
161
|
-
full lifetime. Use `auth.refresh()` for anything user-facing.
|
|
189
|
+
full lifetime. Use `auth.refresh()` for anything user-facing. A refresh token
|
|
190
|
+
that carries a `sid` produces a pair with the same `sid`, so the new tokens
|
|
191
|
+
still die with that session when verified through the service.
|
|
162
192
|
|
|
163
193
|
Tokens are capped at 8 KB and every segment is bounds-checked before it is
|
|
164
194
|
decoded, so an oversized `Authorization` header is rejected without
|
|
@@ -169,11 +199,25 @@ allocating.
|
|
|
169
199
|
```typescript
|
|
170
200
|
import { hashPassword, verifyPassword, needsRehash } from "@zudojs/auth";
|
|
171
201
|
|
|
172
|
-
const hash = await hashPassword("plain-text-password"); // scrypt
|
|
202
|
+
const hash = await hashPassword("plain-text-password"); // "v1$scrypt$16384$8$5$…"
|
|
173
203
|
const ok = await verifyPassword("plain-text-password", hash);
|
|
174
204
|
if (needsRehash(hash)) { /* re-hash on next successful login */ }
|
|
175
205
|
```
|
|
176
206
|
|
|
207
|
+
Hashing is delegated to `@zudojs/crypto`: new hashes are its
|
|
208
|
+
`v1$scrypt$N$r$p$<salt>.<hash>` strings (Base64URL, 32-byte salt, 64-byte key)
|
|
209
|
+
with OWASP's N=2^14, r=8, p=5 row. Hashes written by earlier versions of this
|
|
210
|
+
package (`scrypt$N$r$p$…`, including the p=1 default and the param-less legacy
|
|
211
|
+
format) still verify, and `needsRehash()` returns `true` for every hash that is
|
|
212
|
+
not a current-parameter `@zudojs/crypto` scrypt hash, so they upgrade on the
|
|
213
|
+
next login. `hashPassword("")` throws `AuthError` (`INVALID_INPUT`).
|
|
214
|
+
|
|
215
|
+
- `createAuthService()` validates its configuration up front: bad or
|
|
216
|
+
identical secrets, a non-positive or `NaN` `sessionTtlSeconds` /
|
|
217
|
+
`absoluteSessionTtlSeconds`, and a non-finite (`NaN`/`Infinity`)
|
|
218
|
+
`accessTtl` / `refreshTtl` all throw `AuthConfigurationError` at construction rather
|
|
219
|
+
than at the first login (a `NaN` session TTL used to yield sessions that
|
|
220
|
+
never expired).
|
|
177
221
|
- Passwords are limited to 1024 bytes (`MAX_PASSWORD_BYTES`).
|
|
178
222
|
- The optional `saltLength` argument must be 16–64 bytes.
|
|
179
223
|
- `verifyPassword` never throws: junk input is a non-match.
|
|
@@ -8,33 +8,14 @@
|
|
|
8
8
|
* name a user, a password, or an account state that the caller did not
|
|
9
9
|
* already supply), so they are safe to return to a client verbatim.
|
|
10
10
|
*/
|
|
11
|
-
import {
|
|
11
|
+
import { AuthError, type AuthErrorOptions } from "@zudojs/errors";
|
|
12
12
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* also keeps `BaseError.withMetadata()` — which reconstructs the error from
|
|
18
|
-
* its own fields — lossless for these classes.
|
|
13
|
+
* `AuthError` (the base of every class below) and `AuthErrorOptions` live
|
|
14
|
+
* in `@zudojs/errors`; they are re-exported here so existing imports keep
|
|
15
|
+
* working. Defaults: `401 Unauthorized`, category `authentication`,
|
|
16
|
+
* `expose: true`; every default can be overridden.
|
|
19
17
|
*/
|
|
20
|
-
export
|
|
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
|
-
}
|
|
30
|
-
/**
|
|
31
|
-
* Base error for all auth-related failures.
|
|
32
|
-
*
|
|
33
|
-
* Defaults to `401 Unauthorized`, category `authentication`, `expose: true`.
|
|
34
|
-
*/
|
|
35
|
-
export declare class AuthError extends BaseError {
|
|
36
|
-
constructor(message: string, options?: AuthErrorOptions);
|
|
37
|
-
}
|
|
18
|
+
export { AuthError, type AuthErrorOptions };
|
|
38
19
|
/**
|
|
39
20
|
* The package is misconfigured (missing/weak signing secret, missing
|
|
40
21
|
* permission engine, …). Not caused by the request, so `500` and not exposed.
|
|
@@ -8,28 +8,14 @@
|
|
|
8
8
|
* name a user, a password, or an account state that the caller did not
|
|
9
9
|
* already supply), so they are safe to return to a client verbatim.
|
|
10
10
|
*/
|
|
11
|
-
import {
|
|
11
|
+
import { AuthError, ErrorCode, ErrorCategory, ErrorSeverity, } from "@zudojs/errors";
|
|
12
12
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* Defaults
|
|
13
|
+
* `AuthError` (the base of every class below) and `AuthErrorOptions` live
|
|
14
|
+
* in `@zudojs/errors`; they are re-exported here so existing imports keep
|
|
15
|
+
* working. Defaults: `401 Unauthorized`, category `authentication`,
|
|
16
|
+
* `expose: true`; every default can be overridden.
|
|
16
17
|
*/
|
|
17
|
-
export
|
|
18
|
-
constructor(message, options) {
|
|
19
|
-
super(message, {
|
|
20
|
-
code: options?.code ?? ErrorCode.AUTHENTICATION,
|
|
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
|
-
: {}),
|
|
28
|
-
metadata: options?.metadata,
|
|
29
|
-
cause: options?.cause,
|
|
30
|
-
});
|
|
31
|
-
}
|
|
32
|
-
}
|
|
18
|
+
export { AuthError };
|
|
33
19
|
/**
|
|
34
20
|
* The package is misconfigured (missing/weak signing secret, missing
|
|
35
21
|
* permission engine, …). Not caused by the request, so `500` and not exposed.
|
|
@@ -1,41 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Password hashing and verification
|
|
2
|
+
* Password hashing and verification, delegated to `@zudojs/crypto`.
|
|
3
3
|
*
|
|
4
4
|
* @module authPassword/authPassword
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* New hashes are `@zudojs/crypto` scrypt hashes
|
|
7
|
+
* (`v1$scrypt$N$r$p$<salt>.<hash>`, Base64URL). Hashes written by earlier
|
|
8
|
+
* versions of this package (`scrypt$…`) still verify through the legacy
|
|
9
|
+
* verifier, and `needsRehash()` reports them so they upgrade on next login.
|
|
8
10
|
*/
|
|
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
11
|
/**
|
|
13
|
-
*
|
|
12
|
+
* Hash a plain-text password with `@zudojs/crypto` (scrypt N=2^14, r=8,
|
|
13
|
+
* p=5, 64-byte key).
|
|
14
14
|
*
|
|
15
|
-
*
|
|
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;
|
|
21
|
-
/**
|
|
22
|
-
* Hash a plain-text password.
|
|
23
|
-
*
|
|
24
|
-
* @param password - Plain-text password. Must be at most
|
|
15
|
+
* @param password - Plain-text password. Must be non-empty and at most
|
|
25
16
|
* {@link MAX_PASSWORD_BYTES} bytes of UTF-8.
|
|
26
17
|
* @param saltLength - Salt length in bytes (default: 32). Must be an integer
|
|
27
18
|
* between {@link MIN_SALT_LENGTH} and {@link MAX_SALT_LENGTH}; `0` would
|
|
28
19
|
* otherwise silently produce unsalted, rainbow-table-able hashes.
|
|
29
|
-
* @returns Hashed password string in format
|
|
20
|
+
* @returns Hashed password string in the `@zudojs/crypto` format
|
|
21
|
+
* `v1$scrypt$N$r$p$<salt>.<hash>`
|
|
30
22
|
* @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.
|
|
23
|
+
* not a non-empty string, is too long, or the salt length is out of range.
|
|
32
24
|
*/
|
|
33
25
|
export declare function hashPassword(password: string, saltLength?: number): Promise<string>;
|
|
34
26
|
/**
|
|
35
27
|
* Verify a plain-text password against a hash.
|
|
36
28
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
29
|
+
* `v1$…` hashes are verified by `@zudojs/crypto`. Hashes from earlier
|
|
30
|
+
* versions of this package ("scrypt$N$r$p$salt$hash" and the ≤ 0.1.1
|
|
31
|
+
* "scrypt<salt>$<hash>") go through the legacy verifier.
|
|
39
32
|
*
|
|
40
33
|
* Never throws: any input this function cannot make sense of — a
|
|
41
34
|
* non-string, an over-length password (see {@link MAX_PASSWORD_BYTES}), an
|
|
@@ -48,17 +41,12 @@ export declare function hashPassword(password: string, saltLength?: number): Pro
|
|
|
48
41
|
* @returns Whether the password matches
|
|
49
42
|
*/
|
|
50
43
|
export declare function verifyPassword(password: string, hashedPassword: string): Promise<boolean>;
|
|
51
|
-
/**
|
|
52
|
-
* Check if a password hash needs rehashing (legacy format, changed
|
|
53
|
-
* scrypt parameters, or changed salt length).
|
|
54
|
-
*
|
|
55
|
-
* @param hashedPassword - The stored hash
|
|
56
|
-
* @returns Whether the hash should be regenerated
|
|
57
|
-
*/
|
|
58
|
-
export declare function needsRehash(hashedPassword: string): boolean;
|
|
59
44
|
/**
|
|
60
45
|
* Generate a random token string (for password reset, etc.).
|
|
61
46
|
*
|
|
47
|
+
* Synchronous by contract, so it draws from `node:crypto` directly: every
|
|
48
|
+
* `@zudojs/crypto` random helper is asynchronous.
|
|
49
|
+
*
|
|
62
50
|
* @param length - Token length in bytes (default: 32)
|
|
63
51
|
* @returns Hex-encoded random string
|
|
64
52
|
*/
|
|
@@ -1,49 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Password hashing and verification
|
|
2
|
+
* Password hashing and verification, delegated to `@zudojs/crypto`.
|
|
3
3
|
*
|
|
4
4
|
* @module authPassword/authPassword
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* New hashes are `@zudojs/crypto` scrypt hashes
|
|
7
|
+
* (`v1$scrypt$N$r$p$<salt>.<hash>`, Base64URL). Hashes written by earlier
|
|
8
|
+
* versions of this package (`scrypt$…`) still verify through the legacy
|
|
9
|
+
* verifier, and `needsRehash()` reports them so they upgrade on next login.
|
|
8
10
|
*/
|
|
9
|
-
import { randomBytes
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
import { hashPassword as cryptoHashPassword, verifyPassword as cryptoVerifyPassword, } from "@zudojs/crypto";
|
|
10
13
|
import { ErrorCode } from "@zudojs/errors";
|
|
11
14
|
import { AuthError } from "../authErrors/authError.base.js";
|
|
12
|
-
|
|
13
|
-
|
|
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;
|
|
15
|
+
import { KEY_LENGTH, MAX_PASSWORD_BYTES, MAX_SALT_LENGTH, MIN_SALT_LENGTH, SALT_LENGTH, SCRYPT_N, SCRYPT_P, SCRYPT_R, } from "./authPassword.policy.js";
|
|
16
|
+
import { isLegacyPasswordHash, verifyLegacyPassword, } from "./authPassword.legacy.js";
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
18
|
+
* Hash a plain-text password with `@zudojs/crypto` (scrypt N=2^14, r=8,
|
|
19
|
+
* p=5, 64-byte key).
|
|
19
20
|
*
|
|
20
|
-
*
|
|
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;
|
|
26
|
-
/** Default key length for scrypt. */
|
|
27
|
-
const KEY_LENGTH = 64;
|
|
28
|
-
/** Scrypt parameters (N, r, p). */
|
|
29
|
-
const SCRYPT_N = 16384;
|
|
30
|
-
const SCRYPT_R = 8;
|
|
31
|
-
const SCRYPT_P = 1;
|
|
32
|
-
/**
|
|
33
|
-
* Hash a plain-text password.
|
|
34
|
-
*
|
|
35
|
-
* @param password - Plain-text password. Must be at most
|
|
21
|
+
* @param password - Plain-text password. Must be non-empty and at most
|
|
36
22
|
* {@link MAX_PASSWORD_BYTES} bytes of UTF-8.
|
|
37
23
|
* @param saltLength - Salt length in bytes (default: 32). Must be an integer
|
|
38
24
|
* between {@link MIN_SALT_LENGTH} and {@link MAX_SALT_LENGTH}; `0` would
|
|
39
25
|
* otherwise silently produce unsalted, rainbow-table-able hashes.
|
|
40
|
-
* @returns Hashed password string in format
|
|
26
|
+
* @returns Hashed password string in the `@zudojs/crypto` format
|
|
27
|
+
* `v1$scrypt$N$r$p$<salt>.<hash>`
|
|
41
28
|
* @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.
|
|
29
|
+
* not a non-empty string, is too long, or the salt length is out of range.
|
|
43
30
|
*/
|
|
44
31
|
export async function hashPassword(password, saltLength = SALT_LENGTH) {
|
|
45
|
-
if (typeof password !== "string") {
|
|
46
|
-
throw new AuthError("Password must be a string.", {
|
|
32
|
+
if (typeof password !== "string" || password.length === 0) {
|
|
33
|
+
throw new AuthError("Password must be a non-empty string.", {
|
|
47
34
|
code: ErrorCode.INVALID_INPUT,
|
|
48
35
|
statusCode: 400,
|
|
49
36
|
});
|
|
@@ -56,19 +43,21 @@ export async function hashPassword(password, saltLength = SALT_LENGTH) {
|
|
|
56
43
|
saltLength > MAX_SALT_LENGTH) {
|
|
57
44
|
throw new AuthError(`saltLength must be an integer between ${MIN_SALT_LENGTH} and ${MAX_SALT_LENGTH} bytes.`, { code: ErrorCode.INVALID_INPUT, statusCode: 400 });
|
|
58
45
|
}
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
46
|
+
const result = await cryptoHashPassword(password, {
|
|
47
|
+
saltBytes: saltLength,
|
|
48
|
+
keyBytes: KEY_LENGTH,
|
|
49
|
+
cost: SCRYPT_N,
|
|
50
|
+
blockSize: SCRYPT_R,
|
|
51
|
+
parallelization: SCRYPT_P,
|
|
64
52
|
});
|
|
65
|
-
return
|
|
53
|
+
return result.encoded;
|
|
66
54
|
}
|
|
67
55
|
/**
|
|
68
56
|
* Verify a plain-text password against a hash.
|
|
69
57
|
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
58
|
+
* `v1$…` hashes are verified by `@zudojs/crypto`. Hashes from earlier
|
|
59
|
+
* versions of this package ("scrypt$N$r$p$salt$hash" and the ≤ 0.1.1
|
|
60
|
+
* "scrypt<salt>$<hash>") go through the legacy verifier.
|
|
72
61
|
*
|
|
73
62
|
* Never throws: any input this function cannot make sense of — a
|
|
74
63
|
* non-string, an over-length password (see {@link MAX_PASSWORD_BYTES}), an
|
|
@@ -87,44 +76,17 @@ export async function verifyPassword(password, hashedPassword) {
|
|
|
87
76
|
if (Buffer.byteLength(password, "utf-8") > MAX_PASSWORD_BYTES) {
|
|
88
77
|
return false;
|
|
89
78
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
return false;
|
|
93
|
-
let derivedKey;
|
|
94
|
-
try {
|
|
95
|
-
derivedKey = await deriveKey(password, parsed.salt, parsed.params);
|
|
96
|
-
}
|
|
97
|
-
catch {
|
|
98
|
-
// scrypt rejects params it cannot satisfy (e.g. over maxmem)
|
|
99
|
-
return false;
|
|
100
|
-
}
|
|
101
|
-
const storedBuffer = Buffer.from(parsed.hash, "hex");
|
|
102
|
-
const derivedBuffer = Buffer.from(derivedKey, "hex");
|
|
103
|
-
if (storedBuffer.length !== derivedBuffer.length) {
|
|
104
|
-
return false;
|
|
79
|
+
if (isLegacyPasswordHash(hashedPassword)) {
|
|
80
|
+
return verifyLegacyPassword(password, hashedPassword);
|
|
105
81
|
}
|
|
106
|
-
return
|
|
107
|
-
}
|
|
108
|
-
/**
|
|
109
|
-
* Check if a password hash needs rehashing (legacy format, changed
|
|
110
|
-
* scrypt parameters, or changed salt length).
|
|
111
|
-
*
|
|
112
|
-
* @param hashedPassword - The stored hash
|
|
113
|
-
* @returns Whether the hash should be regenerated
|
|
114
|
-
*/
|
|
115
|
-
export function needsRehash(hashedPassword) {
|
|
116
|
-
const parsed = parseHash(hashedPassword);
|
|
117
|
-
if (!parsed || parsed.legacy)
|
|
118
|
-
return true;
|
|
119
|
-
const saltBytes = parsed.salt.length / 2;
|
|
120
|
-
return (saltBytes !== SALT_LENGTH ||
|
|
121
|
-
parsed.params.N !== SCRYPT_N ||
|
|
122
|
-
parsed.params.r !== SCRYPT_R ||
|
|
123
|
-
parsed.params.p !== SCRYPT_P);
|
|
82
|
+
return cryptoVerifyPassword(password, hashedPassword);
|
|
124
83
|
}
|
|
125
84
|
/**
|
|
126
85
|
* Generate a random token string (for password reset, etc.).
|
|
127
86
|
*
|
|
87
|
+
* Synchronous by contract, so it draws from `node:crypto` directly: every
|
|
88
|
+
* `@zudojs/crypto` random helper is asynchronous.
|
|
89
|
+
*
|
|
128
90
|
* @param length - Token length in bytes (default: 32)
|
|
129
91
|
* @returns Hex-encoded random string
|
|
130
92
|
*/
|
|
@@ -134,52 +96,4 @@ export function generateRandomToken(length = 32) {
|
|
|
134
96
|
}
|
|
135
97
|
return randomBytes(length).toString("hex");
|
|
136
98
|
}
|
|
137
|
-
function parseHash(hashedPassword) {
|
|
138
|
-
const parts = hashedPassword.split("$");
|
|
139
|
-
if (parts.length === 6 && parts[0] === "scrypt") {
|
|
140
|
-
const N = Number(parts[1]);
|
|
141
|
-
const r = Number(parts[2]);
|
|
142
|
-
const p = Number(parts[3]);
|
|
143
|
-
// Bounds also cap the memory/CPU a corrupted hash string can request.
|
|
144
|
-
if (!Number.isInteger(N) ||
|
|
145
|
-
!Number.isInteger(r) ||
|
|
146
|
-
!Number.isInteger(p) ||
|
|
147
|
-
N < 2 ||
|
|
148
|
-
(N & (N - 1)) !== 0 ||
|
|
149
|
-
N > 1 << 20 ||
|
|
150
|
-
r < 1 ||
|
|
151
|
-
r > 64 ||
|
|
152
|
-
p < 1 ||
|
|
153
|
-
p > 16) {
|
|
154
|
-
return null;
|
|
155
|
-
}
|
|
156
|
-
return {
|
|
157
|
-
params: { N, r, p },
|
|
158
|
-
salt: parts[4],
|
|
159
|
-
hash: parts[5],
|
|
160
|
-
legacy: false,
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
// Legacy format from ≤ 0.1.1: "scrypt<salt>$<hash>" (no separator
|
|
164
|
-
// between the prefix and the salt, params not stored).
|
|
165
|
-
if (parts.length === 2 && parts[0].startsWith("scrypt")) {
|
|
166
|
-
return {
|
|
167
|
-
params: { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P },
|
|
168
|
-
salt: parts[0].slice(6),
|
|
169
|
-
hash: parts[1],
|
|
170
|
-
legacy: true,
|
|
171
|
-
};
|
|
172
|
-
}
|
|
173
|
-
return null;
|
|
174
|
-
}
|
|
175
|
-
function deriveKey(password, salt, params) {
|
|
176
|
-
return new Promise((resolve, reject) => {
|
|
177
|
-
scrypt(password, salt, KEY_LENGTH, { N: params.N, r: params.r, p: params.p }, (err, derivedKey) => {
|
|
178
|
-
if (err)
|
|
179
|
-
reject(err);
|
|
180
|
-
else
|
|
181
|
-
resolve(derivedKey.toString("hex"));
|
|
182
|
-
});
|
|
183
|
-
});
|
|
184
|
-
}
|
|
185
99
|
//# sourceMappingURL=authPassword.core.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verifier for password hashes written before auth delegated to
|
|
3
|
+
* `@zudojs/crypto`: `scrypt$N$r$p$<hexSalt>$<hexHash>` and the param-less
|
|
4
|
+
* `scrypt<hexSalt>$<hexHash>` format from versions ≤ 0.1.1.
|
|
5
|
+
*
|
|
6
|
+
* Nothing new is ever written in these formats. They are kept so stored
|
|
7
|
+
* hashes still verify, and `needsRehash()` reports every one of them.
|
|
8
|
+
*
|
|
9
|
+
* @module authPassword/authPassword.legacy
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Returns whether a stored hash uses one of the legacy auth formats (the
|
|
13
|
+
* string starts with `scrypt`). Crypto-format hashes start with `v1$`.
|
|
14
|
+
*/
|
|
15
|
+
export declare function isLegacyPasswordHash(hashedPassword: string): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Verifies a password against a legacy hash. Returns `false` for an
|
|
18
|
+
* unparseable hash or parameters scrypt cannot satisfy; never throws.
|
|
19
|
+
*/
|
|
20
|
+
export declare function verifyLegacyPassword(password: string, hashedPassword: string): Promise<boolean>;
|
|
21
|
+
//# sourceMappingURL=authPassword.legacy.d.ts.map
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verifier for password hashes written before auth delegated to
|
|
3
|
+
* `@zudojs/crypto`: `scrypt$N$r$p$<hexSalt>$<hexHash>` and the param-less
|
|
4
|
+
* `scrypt<hexSalt>$<hexHash>` format from versions ≤ 0.1.1.
|
|
5
|
+
*
|
|
6
|
+
* Nothing new is ever written in these formats. They are kept so stored
|
|
7
|
+
* hashes still verify, and `needsRehash()` reports every one of them.
|
|
8
|
+
*
|
|
9
|
+
* @module authPassword/authPassword.legacy
|
|
10
|
+
*/
|
|
11
|
+
import { scrypt } from "node:crypto";
|
|
12
|
+
import { timingSafeEqual } from "@zudojs/crypto";
|
|
13
|
+
/** Derived-key length used by every legacy format. */
|
|
14
|
+
const LEGACY_KEY_LENGTH = 64;
|
|
15
|
+
/** The fixed parameters of the param-less legacy format (≤ 0.1.1). */
|
|
16
|
+
const PARAMLESS_PARAMS = Object.freeze({
|
|
17
|
+
N: 16384,
|
|
18
|
+
r: 8,
|
|
19
|
+
p: 1,
|
|
20
|
+
});
|
|
21
|
+
/**
|
|
22
|
+
* Returns whether a stored hash uses one of the legacy auth formats (the
|
|
23
|
+
* string starts with `scrypt`). Crypto-format hashes start with `v1$`.
|
|
24
|
+
*/
|
|
25
|
+
export function isLegacyPasswordHash(hashedPassword) {
|
|
26
|
+
return hashedPassword.startsWith("scrypt");
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Verifies a password against a legacy hash. Returns `false` for an
|
|
30
|
+
* unparseable hash or parameters scrypt cannot satisfy; never throws.
|
|
31
|
+
*/
|
|
32
|
+
export async function verifyLegacyPassword(password, hashedPassword) {
|
|
33
|
+
const parsed = parseLegacyHash(hashedPassword);
|
|
34
|
+
if (!parsed)
|
|
35
|
+
return false;
|
|
36
|
+
let derived;
|
|
37
|
+
try {
|
|
38
|
+
derived = await deriveLegacyKey(password, parsed.salt, parsed.params);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
return timingSafeEqual(Buffer.from(parsed.hash, "hex"), derived);
|
|
44
|
+
}
|
|
45
|
+
function parseLegacyHash(hashedPassword) {
|
|
46
|
+
const parts = hashedPassword.split("$");
|
|
47
|
+
if (parts.length === 6 && parts[0] === "scrypt") {
|
|
48
|
+
const N = Number(parts[1]);
|
|
49
|
+
const r = Number(parts[2]);
|
|
50
|
+
const p = Number(parts[3]);
|
|
51
|
+
// Bounds also cap the memory/CPU a corrupted hash string can request.
|
|
52
|
+
if (!Number.isInteger(N) ||
|
|
53
|
+
!Number.isInteger(r) ||
|
|
54
|
+
!Number.isInteger(p) ||
|
|
55
|
+
N < 2 ||
|
|
56
|
+
(N & (N - 1)) !== 0 ||
|
|
57
|
+
N > 1 << 20 ||
|
|
58
|
+
r < 1 ||
|
|
59
|
+
r > 64 ||
|
|
60
|
+
p < 1 ||
|
|
61
|
+
p > 16) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
return { params: { N, r, p }, salt: parts[4], hash: parts[5] };
|
|
65
|
+
}
|
|
66
|
+
if (parts.length === 2 && parts[0].startsWith("scrypt")) {
|
|
67
|
+
return {
|
|
68
|
+
params: PARAMLESS_PARAMS,
|
|
69
|
+
salt: parts[0].slice(6),
|
|
70
|
+
hash: parts[1],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
function deriveLegacyKey(password, salt, params) {
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
scrypt(password, salt, LEGACY_KEY_LENGTH, { N: params.N, r: params.r, p: params.p }, (err, derivedKey) => {
|
|
78
|
+
if (err)
|
|
79
|
+
reject(err);
|
|
80
|
+
else
|
|
81
|
+
resolve(derivedKey);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=authPassword.legacy.js.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hashing policy for new auth password hashes. `needsRehash()` flags any
|
|
3
|
+
* stored hash that does not match it.
|
|
4
|
+
*
|
|
5
|
+
* @module authPassword/authPassword.policy
|
|
6
|
+
*/
|
|
7
|
+
/** Default salt length in bytes. */
|
|
8
|
+
export declare const SALT_LENGTH = 32;
|
|
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;
|
|
21
|
+
/** Derived key length in bytes. */
|
|
22
|
+
export declare const KEY_LENGTH = 64;
|
|
23
|
+
/**
|
|
24
|
+
* Scrypt parameters (N, r, p) for new hashes: the OWASP Password Storage
|
|
25
|
+
* Cheat Sheet's N=2^14, r=8, p=5 row.
|
|
26
|
+
*/
|
|
27
|
+
export declare const SCRYPT_N = 16384;
|
|
28
|
+
export declare const SCRYPT_R = 8;
|
|
29
|
+
export declare const SCRYPT_P = 5;
|
|
30
|
+
//# sourceMappingURL=authPassword.policy.d.ts.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hashing policy for new auth password hashes. `needsRehash()` flags any
|
|
3
|
+
* stored hash that does not match it.
|
|
4
|
+
*
|
|
5
|
+
* @module authPassword/authPassword.policy
|
|
6
|
+
*/
|
|
7
|
+
/** Default salt length in bytes. */
|
|
8
|
+
export const SALT_LENGTH = 32;
|
|
9
|
+
/** Accepted range for a caller-supplied salt length, in bytes. */
|
|
10
|
+
export const MIN_SALT_LENGTH = 16;
|
|
11
|
+
export 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 const MAX_PASSWORD_BYTES = 1024;
|
|
21
|
+
/** Derived key length in bytes. */
|
|
22
|
+
export const KEY_LENGTH = 64;
|
|
23
|
+
/**
|
|
24
|
+
* Scrypt parameters (N, r, p) for new hashes: the OWASP Password Storage
|
|
25
|
+
* Cheat Sheet's N=2^14, r=8, p=5 row.
|
|
26
|
+
*/
|
|
27
|
+
export const SCRYPT_N = 16384;
|
|
28
|
+
export const SCRYPT_R = 8;
|
|
29
|
+
export const SCRYPT_P = 5;
|
|
30
|
+
//# sourceMappingURL=authPassword.policy.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rehash detection for stored password hashes.
|
|
3
|
+
*
|
|
4
|
+
* @module authPassword/authPassword.rehash
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Check if a password hash needs rehashing: every hash that is not a
|
|
8
|
+
* `@zudojs/crypto` scrypt hash with the current parameters (N, r, p, salt
|
|
9
|
+
* and key length). All legacy `scrypt$…` hashes return `true`.
|
|
10
|
+
*
|
|
11
|
+
* @param hashedPassword - The stored hash
|
|
12
|
+
* @returns Whether the hash should be regenerated
|
|
13
|
+
*/
|
|
14
|
+
export declare function needsRehash(hashedPassword: string): boolean;
|
|
15
|
+
//# sourceMappingURL=authPassword.rehash.d.ts.map
|