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