@zudojs/auth 1.1.0 → 1.2.1
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 +41 -5
- 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 -11
- package/dist/authProvider/authProvider.core.js +14 -60
- package/dist/authProvider/authProvider.throttle.d.ts +52 -0
- package/dist/authProvider/authProvider.throttle.js +88 -0
- package/dist/authSession/authSession.core.js +5 -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 +7 -2
- package/dist/authTypes/authAttempt.type.d.ts +13 -3
- package/dist/authUtils/authUtils.helper.d.ts +4 -0
- package/dist/authUtils/authUtils.helper.js +4 -0
- package/package.json +5 -4
|
@@ -3,5 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @module authPassword
|
|
5
5
|
*/
|
|
6
|
-
export { hashPassword, verifyPassword,
|
|
6
|
+
export { hashPassword, verifyPassword, generateRandomToken, } from "./authPassword.core.js";
|
|
7
|
+
export { needsRehash } from "./authPassword.rehash.js";
|
|
8
|
+
export { MIN_SALT_LENGTH, MAX_SALT_LENGTH, MAX_PASSWORD_BYTES, } from "./authPassword.policy.js";
|
|
7
9
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -3,5 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @module authPassword
|
|
5
5
|
*/
|
|
6
|
-
export { hashPassword, verifyPassword,
|
|
6
|
+
export { hashPassword, verifyPassword, generateRandomToken, } from "./authPassword.core.js";
|
|
7
|
+
export { needsRehash } from "./authPassword.rehash.js";
|
|
8
|
+
export { MIN_SALT_LENGTH, MAX_SALT_LENGTH, MAX_PASSWORD_BYTES, } from "./authPassword.policy.js";
|
|
7
9
|
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Eviction rules for the in-memory login attempt store.
|
|
3
|
+
*
|
|
4
|
+
* @module authProvider/authAttempt.eviction
|
|
5
|
+
*/
|
|
6
|
+
/** The fields of an attempt entry that eviction looks at. */
|
|
7
|
+
export interface EvictableAttemptEntry {
|
|
8
|
+
readonly failures: number;
|
|
9
|
+
readonly windowStart: number;
|
|
10
|
+
readonly lastFailureAt: number;
|
|
11
|
+
readonly lockedUntil?: number;
|
|
12
|
+
}
|
|
13
|
+
/** True when the entry carries no live lockout. */
|
|
14
|
+
export declare function isUnlocked(entry: EvictableAttemptEntry, now: number): boolean;
|
|
15
|
+
/**
|
|
16
|
+
* True when the entry can be dropped without changing any decision: it is
|
|
17
|
+
* unlocked, its rate-limit window has passed, and its failure streak (if
|
|
18
|
+
* any) has been idle for at least `failureTtlMs`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function isStale(entry: EvictableAttemptEntry, now: number, windowMs: number, failureTtlMs: number): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Make room for one new entry: drop the oldest unlocked entry (Map order is
|
|
23
|
+
* insertion order), or the oldest entry of all when every entry is locked.
|
|
24
|
+
*/
|
|
25
|
+
export declare function evictOne<E extends EvictableAttemptEntry>(entries: Map<string, E>, now: number): void;
|
|
26
|
+
//# sourceMappingURL=authAttempt.eviction.d.ts.map
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Eviction rules for the in-memory login attempt store.
|
|
3
|
+
*
|
|
4
|
+
* @module authProvider/authAttempt.eviction
|
|
5
|
+
*/
|
|
6
|
+
/** True when the entry carries no live lockout. */
|
|
7
|
+
export function isUnlocked(entry, now) {
|
|
8
|
+
return entry.lockedUntil === undefined || entry.lockedUntil <= now;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* True when the entry can be dropped without changing any decision: it is
|
|
12
|
+
* unlocked, its rate-limit window has passed, and its failure streak (if
|
|
13
|
+
* any) has been idle for at least `failureTtlMs`.
|
|
14
|
+
*/
|
|
15
|
+
export function isStale(entry, now, windowMs, failureTtlMs) {
|
|
16
|
+
if (!isUnlocked(entry, now))
|
|
17
|
+
return false;
|
|
18
|
+
if (now - entry.windowStart < windowMs)
|
|
19
|
+
return false;
|
|
20
|
+
return entry.failures === 0 || now - entry.lastFailureAt >= failureTtlMs;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Make room for one new entry: drop the oldest unlocked entry (Map order is
|
|
24
|
+
* insertion order), or the oldest entry of all when every entry is locked.
|
|
25
|
+
*/
|
|
26
|
+
export function evictOne(entries, now) {
|
|
27
|
+
for (const [key, entry] of entries) {
|
|
28
|
+
if (isUnlocked(entry, now)) {
|
|
29
|
+
entries.delete(key);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const oldest = entries.keys().next();
|
|
34
|
+
if (!oldest.done)
|
|
35
|
+
entries.delete(oldest.value);
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=authAttempt.eviction.js.map
|
|
@@ -13,9 +13,18 @@ import type { LoginAttemptStore } from "../authTypes/authAttempt.type.js";
|
|
|
13
13
|
* @param options.windowSeconds - Rate-limit window length (default: 60).
|
|
14
14
|
* @param options.purgeIntervalMs - Minimum gap between sweeps of stale
|
|
15
15
|
* entries (default: 60000).
|
|
16
|
+
* @param options.failureTtlSeconds - Idle time after which an unlocked
|
|
17
|
+
* failure streak is forgotten and its entry evicted (default: 900).
|
|
18
|
+
* Without it, every identifier with one failure was kept forever, so
|
|
19
|
+
* spraying random identifiers grew the map without bound.
|
|
20
|
+
* @param options.maxEntries - Hard cap on tracked identifiers (default:
|
|
21
|
+
* 100000). At the cap the oldest unlocked entry is evicted; locked
|
|
22
|
+
* entries are only evicted when every entry is locked.
|
|
16
23
|
*/
|
|
17
24
|
export declare function createMemoryLoginAttemptStore(options?: {
|
|
18
25
|
readonly windowSeconds?: number;
|
|
19
26
|
readonly purgeIntervalMs?: number;
|
|
27
|
+
readonly failureTtlSeconds?: number;
|
|
28
|
+
readonly maxEntries?: number;
|
|
20
29
|
}): LoginAttemptStore;
|
|
21
30
|
//# sourceMappingURL=authAttempt.memory.d.ts.map
|
|
@@ -6,8 +6,11 @@
|
|
|
6
6
|
* Single-process only: counters are not shared between instances. Back
|
|
7
7
|
* `LoginAttemptStore` with Redis for a real deployment.
|
|
8
8
|
*/
|
|
9
|
+
import { evictOne, isStale, isUnlocked } from "./authAttempt.eviction.js";
|
|
9
10
|
const DEFAULT_WINDOW_SECONDS = 60;
|
|
10
11
|
const DEFAULT_PURGE_INTERVAL_MS = 60_000;
|
|
12
|
+
const DEFAULT_FAILURE_TTL_SECONDS = 900;
|
|
13
|
+
const DEFAULT_MAX_ENTRIES = 100_000;
|
|
11
14
|
const EMPTY = { failures: 0, attempts: 0 };
|
|
12
15
|
/**
|
|
13
16
|
* Create an in-memory {@link LoginAttemptStore}.
|
|
@@ -15,10 +18,19 @@ const EMPTY = { failures: 0, attempts: 0 };
|
|
|
15
18
|
* @param options.windowSeconds - Rate-limit window length (default: 60).
|
|
16
19
|
* @param options.purgeIntervalMs - Minimum gap between sweeps of stale
|
|
17
20
|
* entries (default: 60000).
|
|
21
|
+
* @param options.failureTtlSeconds - Idle time after which an unlocked
|
|
22
|
+
* failure streak is forgotten and its entry evicted (default: 900).
|
|
23
|
+
* Without it, every identifier with one failure was kept forever, so
|
|
24
|
+
* spraying random identifiers grew the map without bound.
|
|
25
|
+
* @param options.maxEntries - Hard cap on tracked identifiers (default:
|
|
26
|
+
* 100000). At the cap the oldest unlocked entry is evicted; locked
|
|
27
|
+
* entries are only evicted when every entry is locked.
|
|
18
28
|
*/
|
|
19
29
|
export function createMemoryLoginAttemptStore(options) {
|
|
20
30
|
const windowMs = (options?.windowSeconds ?? DEFAULT_WINDOW_SECONDS) * 1000;
|
|
21
31
|
const purgeIntervalMs = options?.purgeIntervalMs ?? DEFAULT_PURGE_INTERVAL_MS;
|
|
32
|
+
const failureTtlMs = (options?.failureTtlSeconds ?? DEFAULT_FAILURE_TTL_SECONDS) * 1000;
|
|
33
|
+
const maxEntries = options?.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
22
34
|
const entries = new Map();
|
|
23
35
|
let lastPurge = 0;
|
|
24
36
|
function maybePurge(now) {
|
|
@@ -26,20 +38,24 @@ export function createMemoryLoginAttemptStore(options) {
|
|
|
26
38
|
return;
|
|
27
39
|
lastPurge = now;
|
|
28
40
|
for (const [key, entry] of entries) {
|
|
29
|
-
|
|
30
|
-
const fresh = now - entry.windowStart < windowMs;
|
|
31
|
-
if (!locked && !fresh && entry.failures === 0) {
|
|
41
|
+
if (isStale(entry, now, windowMs, failureTtlMs))
|
|
32
42
|
entries.delete(key);
|
|
33
|
-
}
|
|
34
43
|
}
|
|
35
44
|
}
|
|
36
45
|
function load(identifier, now) {
|
|
37
46
|
maybePurge(now);
|
|
38
47
|
let entry = entries.get(identifier);
|
|
39
48
|
if (!entry) {
|
|
40
|
-
|
|
49
|
+
if (entries.size >= maxEntries)
|
|
50
|
+
evictOne(entries, now);
|
|
51
|
+
entry = { failures: 0, attempts: 0, windowStart: now, lastFailureAt: 0 };
|
|
41
52
|
entries.set(identifier, entry);
|
|
42
53
|
}
|
|
54
|
+
if (entry.failures > 0 &&
|
|
55
|
+
isUnlocked(entry, now) &&
|
|
56
|
+
now - entry.lastFailureAt >= failureTtlMs) {
|
|
57
|
+
entry.failures = 0;
|
|
58
|
+
}
|
|
43
59
|
if (now - entry.windowStart >= windowMs) {
|
|
44
60
|
entry.windowStart = now;
|
|
45
61
|
entry.attempts = 0;
|
|
@@ -77,6 +93,7 @@ export function createMemoryLoginAttemptStore(options) {
|
|
|
77
93
|
async recordFailure(identifier) {
|
|
78
94
|
const entry = load(identifier, Date.now());
|
|
79
95
|
entry.failures++;
|
|
96
|
+
entry.lastFailureAt = Date.now();
|
|
80
97
|
return snapshot(entry);
|
|
81
98
|
},
|
|
82
99
|
async lock(identifier, until) {
|
|
@@ -9,17 +9,7 @@ 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
|
+
export { throttleKey } from "./authProvider.throttle.js";
|
|
23
13
|
/** User lookup function provided by the consumer, keyed by login identifier. */
|
|
24
14
|
export type UserLookup = (identifier: string) => Promise<AuthUser | null>;
|
|
25
15
|
/** User lookup function provided by the consumer, keyed by user id. */
|
|
@@ -80,6 +70,18 @@ export interface AuthServiceConfig {
|
|
|
80
70
|
readonly allowInsecureFallbackGuard?: boolean;
|
|
81
71
|
/** Role name the fallback guard treats as superuser (default: "admin"). */
|
|
82
72
|
readonly fallbackAdminRole?: string;
|
|
73
|
+
/**
|
|
74
|
+
* Accept access and refresh tokens that carry no `sid` claim (default:
|
|
75
|
+
* `false`).
|
|
76
|
+
*
|
|
77
|
+
* Every pair `login()` mints is session-bound, so by default
|
|
78
|
+
* `verifyToken()` and `refresh()` reject a token without a `sid`: such a
|
|
79
|
+
* token cannot be revoked by `logout()` or `logoutAll()`, and accepting
|
|
80
|
+
* it let a session-less refresh chain outlive "sign out everywhere". Set
|
|
81
|
+
* this only if you also mint tokens with the standalone `createTokenPair()`
|
|
82
|
+
* and verify them through this service.
|
|
83
|
+
*/
|
|
84
|
+
readonly allowSessionlessTokens?: boolean;
|
|
83
85
|
}
|
|
84
86
|
/**
|
|
85
87
|
* Auth service interface.
|
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @module authProvider/authProvider
|
|
5
5
|
*/
|
|
6
|
+
import { createLoginThrottleGate } from "./authProvider.throttle.js";
|
|
6
7
|
import { hashPassword, verifyPassword, } from "../authPassword/authPassword.core.js";
|
|
7
8
|
import { createTokenPair, verifyAccessToken, verifyRefreshToken, } from "../authToken/authToken.core.js";
|
|
8
9
|
import { assertTokenSecrets } from "../authToken/authToken.signing.js";
|
|
9
10
|
import { assertPositiveSeconds } from "../authSession/authSession.core.js";
|
|
10
|
-
import { AccountDeactivatedError,
|
|
11
|
+
import { AccountDeactivatedError, AuthConfigurationError, InvalidCredentialsError, SessionExpiredError, TokenExpiredError, TokenInvalidError, TokenRevokedError, } from "../authErrors/authError.base.js";
|
|
11
12
|
/**
|
|
12
13
|
* A syntactically valid hash that no password matches.
|
|
13
14
|
*
|
|
@@ -15,29 +16,13 @@ import { AccountDeactivatedError, AccountLockedError, AuthConfigurationError, Au
|
|
|
15
16
|
* the unknown-user path takes comparable time to the wrong-password path and
|
|
16
17
|
* the response time does not disclose whether an account exists.
|
|
17
18
|
*/
|
|
18
|
-
const DUMMY_PASSWORD_HASH = `scrypt$16384$8$
|
|
19
|
-
|
|
20
|
-
const DEFAULT_LOCKOUT_SECONDS = 900;
|
|
21
|
-
const DEFAULT_MAX_ATTEMPTS_PER_WINDOW = 20;
|
|
22
|
-
const DEFAULT_WINDOW_SECONDS = 60;
|
|
23
|
-
/**
|
|
24
|
-
* The key under which an identifier's login attempts are counted.
|
|
25
|
-
*
|
|
26
|
-
* Identifiers are emails or usernames, which consumers almost always
|
|
27
|
-
* resolve case-insensitively. Counting the raw string gave
|
|
28
|
-
* `alice@example.com`, `Alice@example.com` and ` alice@example.com` three
|
|
29
|
-
* independent attempt budgets against one account — a lockout bypass that
|
|
30
|
-
* cost the attacker nothing. Trimming, NFKC-folding and lower-casing keeps
|
|
31
|
-
* unknown and known identifiers throttled identically while closing that.
|
|
32
|
-
*/
|
|
33
|
-
export function throttleKey(identifier) {
|
|
34
|
-
return String(identifier).normalize("NFKC").trim().toLowerCase();
|
|
35
|
-
}
|
|
19
|
+
const DUMMY_PASSWORD_HASH = `v1$scrypt$16384$8$5$${"A".repeat(43)}.${"A".repeat(86)}`;
|
|
20
|
+
export { throttleKey } from "./authProvider.throttle.js";
|
|
36
21
|
/**
|
|
37
22
|
* Create an auth service.
|
|
38
23
|
*/
|
|
39
24
|
export function createAuthService(config) {
|
|
40
|
-
const { token: tokenConfig, sessionStore, findUser, findUserById, verifyPassword: verifyPwd, sessionTtlSeconds, absoluteSessionTtlSeconds, permissions, revocationStore, loginThrottle, allowInsecureFallbackGuard, fallbackAdminRole, } = config;
|
|
25
|
+
const { token: tokenConfig, sessionStore, findUser, findUserById, verifyPassword: verifyPwd, sessionTtlSeconds, absoluteSessionTtlSeconds, permissions, revocationStore, loginThrottle, allowInsecureFallbackGuard, fallbackAdminRole, allowSessionlessTokens, } = config;
|
|
41
26
|
// Fail at construction, not at the first login: a bad secret or a NaN
|
|
42
27
|
// lifetime (`Number(process.env.X)` with X unset) otherwise surfaced as
|
|
43
28
|
// a runtime error on the request path — or, for the session TTL, not at
|
|
@@ -45,49 +30,18 @@ export function createAuthService(config) {
|
|
|
45
30
|
assertTokenSecrets(tokenConfig);
|
|
46
31
|
assertPositiveSeconds(sessionTtlSeconds, "sessionTtlSeconds");
|
|
47
32
|
assertPositiveSeconds(absoluteSessionTtlSeconds, "absoluteSessionTtlSeconds");
|
|
48
|
-
const
|
|
49
|
-
const lockoutSeconds = loginThrottle?.lockoutSeconds ?? DEFAULT_LOCKOUT_SECONDS;
|
|
50
|
-
const maxAttemptsPerWindow = loginThrottle?.maxAttemptsPerWindow ?? DEFAULT_MAX_ATTEMPTS_PER_WINDOW;
|
|
51
|
-
const windowSeconds = loginThrottle?.windowSeconds ?? DEFAULT_WINDOW_SECONDS;
|
|
52
|
-
/** Throw if the identifier is locked out or over its attempt budget. */
|
|
53
|
-
async function enforceThrottle(identifier) {
|
|
54
|
-
if (!loginThrottle)
|
|
55
|
-
return;
|
|
56
|
-
const key = throttleKey(identifier);
|
|
57
|
-
const now = Date.now();
|
|
58
|
-
const current = await loginThrottle.store.get(key);
|
|
59
|
-
if (current.lockedUntil !== undefined && current.lockedUntil > now) {
|
|
60
|
-
throw new AccountLockedError(undefined, {
|
|
61
|
-
retryAfterSeconds: Math.ceil((current.lockedUntil - now) / 1000),
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
const updated = await loginThrottle.store.recordAttempt(key);
|
|
65
|
-
if (updated.attempts > maxAttemptsPerWindow) {
|
|
66
|
-
throw new AuthRateLimitError(undefined, {
|
|
67
|
-
retryAfterSeconds: windowSeconds,
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
/** Record a failed authentication and lock the identifier if warranted. */
|
|
72
|
-
async function recordFailure(identifier) {
|
|
73
|
-
if (!loginThrottle)
|
|
74
|
-
return;
|
|
75
|
-
const key = throttleKey(identifier);
|
|
76
|
-
const updated = await loginThrottle.store.recordFailure(key);
|
|
77
|
-
if (updated.failures >= maxFailedAttempts) {
|
|
78
|
-
await loginThrottle.store.lock(key, Date.now() + lockoutSeconds * 1000);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
33
|
+
const throttle = createLoginThrottleGate(loginThrottle);
|
|
81
34
|
/**
|
|
82
35
|
* Reject the token unless the session it was issued against is still
|
|
83
36
|
* alive; refresh the session's idle timer when it is.
|
|
84
37
|
*/
|
|
85
38
|
async function requireLiveSession(payload) {
|
|
86
39
|
const sid = payload.sid;
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
40
|
+
if (!sid) {
|
|
41
|
+
if (allowSessionlessTokens)
|
|
42
|
+
return undefined;
|
|
43
|
+
throw new TokenInvalidError("Token is not bound to a session");
|
|
44
|
+
}
|
|
91
45
|
const session = await sessionStore.get(sid);
|
|
92
46
|
if (!session) {
|
|
93
47
|
throw new SessionExpiredError("Session is no longer active");
|
|
@@ -121,7 +75,7 @@ export function createAuthService(config) {
|
|
|
121
75
|
*/
|
|
122
76
|
async login(credentials, context) {
|
|
123
77
|
const identifier = credentials.identifier;
|
|
124
|
-
await
|
|
78
|
+
const slot = await throttle.begin(identifier);
|
|
125
79
|
const user = await findUser(identifier);
|
|
126
80
|
let authenticated = false;
|
|
127
81
|
if (user) {
|
|
@@ -133,12 +87,12 @@ export function createAuthService(config) {
|
|
|
133
87
|
await verifyPassword(credentials.password, DUMMY_PASSWORD_HASH);
|
|
134
88
|
}
|
|
135
89
|
if (!user || !authenticated) {
|
|
136
|
-
await
|
|
90
|
+
await throttle.fail(identifier, slot);
|
|
137
91
|
throw new InvalidCredentialsError();
|
|
138
92
|
}
|
|
139
93
|
// The credentials were correct, so the attempt counter is cleared even
|
|
140
94
|
// if the account turns out to be unusable.
|
|
141
|
-
await
|
|
95
|
+
await throttle.succeed(identifier);
|
|
142
96
|
// Account state is only disclosed once the password has been proven,
|
|
143
97
|
// so it cannot be probed without a valid credential.
|
|
144
98
|
if (!user.active) {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Login throttling for `createAuthService()` — failed-attempt lockout and
|
|
3
|
+
* per-identifier rate limiting.
|
|
4
|
+
*
|
|
5
|
+
* @module authProvider/authProvider.throttle
|
|
6
|
+
*/
|
|
7
|
+
import type { LoginThrottleConfig } from "../authTypes/authAttempt.type.js";
|
|
8
|
+
/**
|
|
9
|
+
* The key under which an identifier's login attempts are counted.
|
|
10
|
+
*
|
|
11
|
+
* Identifiers are emails or usernames, which consumers almost always
|
|
12
|
+
* resolve case-insensitively. Counting the raw string gave
|
|
13
|
+
* `alice@example.com`, `Alice@example.com` and ` alice@example.com` three
|
|
14
|
+
* independent attempt budgets against one account — a lockout bypass that
|
|
15
|
+
* cost the attacker nothing. Trimming, NFKC-folding and lower-casing keeps
|
|
16
|
+
* unknown and known identifiers throttled identically while closing that.
|
|
17
|
+
*/
|
|
18
|
+
export declare function throttleKey(identifier: string): string;
|
|
19
|
+
/** A failure slot reserved by {@link LoginThrottleGate.begin}. */
|
|
20
|
+
export interface LoginThrottleSlot {
|
|
21
|
+
/** The failure count including this attempt's reservation. */
|
|
22
|
+
readonly failures: number;
|
|
23
|
+
}
|
|
24
|
+
/** Lockout and rate-limit gate wrapped around one `login()` call. */
|
|
25
|
+
export interface LoginThrottleGate {
|
|
26
|
+
/**
|
|
27
|
+
* Admit an attempt. Throws when the identifier is locked or over its
|
|
28
|
+
* window budget; otherwise reserves a failure slot *before* the password
|
|
29
|
+
* is checked and returns it.
|
|
30
|
+
*/
|
|
31
|
+
begin(identifier: string): Promise<LoginThrottleSlot>;
|
|
32
|
+
/** The attempt failed: lock the identifier if its slot reached the limit. */
|
|
33
|
+
fail(identifier: string, slot: LoginThrottleSlot): Promise<void>;
|
|
34
|
+
/** The credentials were correct: clear the identifier's counters. */
|
|
35
|
+
succeed(identifier: string): Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Build the throttle gate for a {@link LoginThrottleConfig}.
|
|
39
|
+
*
|
|
40
|
+
* The failure is counted up front and cleared on success. Counting it only
|
|
41
|
+
* after the (slow) password check was check-then-act: a parallel burst all
|
|
42
|
+
* passed the lock check before any failure landed, so an attacker got
|
|
43
|
+
* `maxAttemptsPerWindow` guesses per lockout instead of `maxFailedAttempts`.
|
|
44
|
+
* With the reservation, the (n+1)th concurrent attempt sees `failures > n`
|
|
45
|
+
* and is refused without evaluating the password. This relies on the
|
|
46
|
+
* store's `recordFailure` being atomic, as the in-memory store's is.
|
|
47
|
+
*
|
|
48
|
+
* An attempt that throws for another reason (e.g. the user lookup fails)
|
|
49
|
+
* keeps its reservation, so it counts as a failure.
|
|
50
|
+
*/
|
|
51
|
+
export declare function createLoginThrottleGate(config: LoginThrottleConfig | undefined): LoginThrottleGate;
|
|
52
|
+
//# sourceMappingURL=authProvider.throttle.d.ts.map
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Login throttling for `createAuthService()` — failed-attempt lockout and
|
|
3
|
+
* per-identifier rate limiting.
|
|
4
|
+
*
|
|
5
|
+
* @module authProvider/authProvider.throttle
|
|
6
|
+
*/
|
|
7
|
+
import { AccountLockedError, AuthRateLimitError, } from "../authErrors/authError.base.js";
|
|
8
|
+
const DEFAULT_MAX_FAILED_ATTEMPTS = 5;
|
|
9
|
+
const DEFAULT_LOCKOUT_SECONDS = 900;
|
|
10
|
+
const DEFAULT_MAX_ATTEMPTS_PER_WINDOW = 20;
|
|
11
|
+
const DEFAULT_WINDOW_SECONDS = 60;
|
|
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 function throttleKey(identifier) {
|
|
23
|
+
return String(identifier).normalize("NFKC").trim().toLowerCase();
|
|
24
|
+
}
|
|
25
|
+
const NO_SLOT = Object.freeze({ failures: 0 });
|
|
26
|
+
/**
|
|
27
|
+
* Build the throttle gate for a {@link LoginThrottleConfig}.
|
|
28
|
+
*
|
|
29
|
+
* The failure is counted up front and cleared on success. Counting it only
|
|
30
|
+
* after the (slow) password check was check-then-act: a parallel burst all
|
|
31
|
+
* passed the lock check before any failure landed, so an attacker got
|
|
32
|
+
* `maxAttemptsPerWindow` guesses per lockout instead of `maxFailedAttempts`.
|
|
33
|
+
* With the reservation, the (n+1)th concurrent attempt sees `failures > n`
|
|
34
|
+
* and is refused without evaluating the password. This relies on the
|
|
35
|
+
* store's `recordFailure` being atomic, as the in-memory store's is.
|
|
36
|
+
*
|
|
37
|
+
* An attempt that throws for another reason (e.g. the user lookup fails)
|
|
38
|
+
* keeps its reservation, so it counts as a failure.
|
|
39
|
+
*/
|
|
40
|
+
export function createLoginThrottleGate(config) {
|
|
41
|
+
const maxFailedAttempts = config?.maxFailedAttempts ?? DEFAULT_MAX_FAILED_ATTEMPTS;
|
|
42
|
+
const lockoutMs = (config?.lockoutSeconds ?? DEFAULT_LOCKOUT_SECONDS) * 1000;
|
|
43
|
+
const maxAttemptsPerWindow = config?.maxAttemptsPerWindow ?? DEFAULT_MAX_ATTEMPTS_PER_WINDOW;
|
|
44
|
+
const windowSeconds = config?.windowSeconds ?? DEFAULT_WINDOW_SECONDS;
|
|
45
|
+
function locked(record, now) {
|
|
46
|
+
const until = record.lockedUntil ?? now + lockoutMs;
|
|
47
|
+
return new AccountLockedError(undefined, {
|
|
48
|
+
retryAfterSeconds: Math.max(1, Math.ceil((until - now) / 1000)),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
async begin(identifier) {
|
|
53
|
+
if (!config)
|
|
54
|
+
return NO_SLOT;
|
|
55
|
+
const key = throttleKey(identifier);
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
const current = await config.store.get(key);
|
|
58
|
+
if (current.lockedUntil !== undefined && current.lockedUntil > now) {
|
|
59
|
+
throw locked(current, now);
|
|
60
|
+
}
|
|
61
|
+
const updated = await config.store.recordAttempt(key);
|
|
62
|
+
if (updated.attempts > maxAttemptsPerWindow) {
|
|
63
|
+
throw new AuthRateLimitError(undefined, {
|
|
64
|
+
retryAfterSeconds: windowSeconds,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
const reserved = await config.store.recordFailure(key);
|
|
68
|
+
if (reserved.failures > maxFailedAttempts) {
|
|
69
|
+
const alreadyLocked = reserved.lockedUntil !== undefined && reserved.lockedUntil > now;
|
|
70
|
+
if (!alreadyLocked)
|
|
71
|
+
await config.store.lock(key, now + lockoutMs);
|
|
72
|
+
throw locked(reserved, now);
|
|
73
|
+
}
|
|
74
|
+
return { failures: reserved.failures };
|
|
75
|
+
},
|
|
76
|
+
async fail(identifier, slot) {
|
|
77
|
+
if (!config)
|
|
78
|
+
return;
|
|
79
|
+
if (slot.failures >= maxFailedAttempts) {
|
|
80
|
+
await config.store.lock(throttleKey(identifier), Date.now() + lockoutMs);
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
async succeed(identifier) {
|
|
84
|
+
await config?.store.reset(throttleKey(identifier));
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
//# sourceMappingURL=authProvider.throttle.js.map
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
*
|
|
6
6
|
* For production, implement SessionStore backed by Redis, database, etc.
|
|
7
7
|
*/
|
|
8
|
+
import { randomHex } from "@zudojs/crypto";
|
|
8
9
|
import { AuthConfigurationError } from "../authErrors/authError.base.js";
|
|
9
|
-
import { randomBytes } from "node:crypto";
|
|
10
10
|
const DEFAULT_TTL_SECONDS = 86400; // 24 hours
|
|
11
11
|
/** Minimum interval between full sweeps of the session map. */
|
|
12
12
|
const DEFAULT_PURGE_INTERVAL_MS = 60_000;
|
|
@@ -49,7 +49,7 @@ export function createMemorySessionStore(storeOptions) {
|
|
|
49
49
|
assertPositiveSeconds(options.ttlSeconds, "ttlSeconds");
|
|
50
50
|
assertPositiveSeconds(options.absoluteTtlSeconds, "absoluteTtlSeconds");
|
|
51
51
|
maybePurgeExpired();
|
|
52
|
-
const id = generateSessionId();
|
|
52
|
+
const id = await generateSessionId();
|
|
53
53
|
const now = new Date();
|
|
54
54
|
const ttlMs = (options.ttlSeconds ?? DEFAULT_TTL_SECONDS) * 1000;
|
|
55
55
|
const absoluteExpiresAt = options.absoluteTtlSeconds !== undefined
|
|
@@ -136,7 +136,8 @@ function clampToAbsolute(expiresAt, absolute) {
|
|
|
136
136
|
return expiresAt;
|
|
137
137
|
return expiresAt.getTime() > absolute.getTime() ? absolute : expiresAt;
|
|
138
138
|
}
|
|
139
|
-
|
|
140
|
-
|
|
139
|
+
/** 32 random bytes from `@zudojs/crypto`, hex-encoded (64 characters). */
|
|
140
|
+
async function generateSessionId() {
|
|
141
|
+
return (await randomHex(64));
|
|
141
142
|
}
|
|
142
143
|
//# sourceMappingURL=authSession.core.js.map
|
|
@@ -50,6 +50,12 @@ export declare function verifyRefreshToken(token: JwtToken, config: TokenConfig)
|
|
|
50
50
|
* user so deactivation and role changes take effect, and validates the
|
|
51
51
|
* session. This function exists for callers who manage all of that
|
|
52
52
|
* themselves.
|
|
53
|
+
*
|
|
54
|
+
* A session-bound refresh token (one with a `sid` claim) yields a pair bound
|
|
55
|
+
* to the same session, so the new tokens still die with `logout()` /
|
|
56
|
+
* `logoutAll()` when verified through `createAuthService()`. Earlier
|
|
57
|
+
* versions dropped the `sid`, turning a logged-out session's refresh token
|
|
58
|
+
* into a permanent, unbound token chain.
|
|
53
59
|
*/
|
|
54
60
|
export declare function refreshAccessToken(refreshToken: JwtToken, config: TokenConfig, options?: {
|
|
55
61
|
readonly roles?: readonly string[];
|
|
@@ -88,13 +88,21 @@ export function verifyRefreshToken(token, config) {
|
|
|
88
88
|
* user so deactivation and role changes take effect, and validates the
|
|
89
89
|
* session. This function exists for callers who manage all of that
|
|
90
90
|
* themselves.
|
|
91
|
+
*
|
|
92
|
+
* A session-bound refresh token (one with a `sid` claim) yields a pair bound
|
|
93
|
+
* to the same session, so the new tokens still die with `logout()` /
|
|
94
|
+
* `logoutAll()` when verified through `createAuthService()`. Earlier
|
|
95
|
+
* versions dropped the `sid`, turning a logged-out session's refresh token
|
|
96
|
+
* into a permanent, unbound token chain.
|
|
91
97
|
*/
|
|
92
98
|
export function refreshAccessToken(refreshToken, config, options) {
|
|
93
99
|
const result = verifyRefreshToken(refreshToken, config);
|
|
94
100
|
if (!result.valid || !result.payload)
|
|
95
101
|
return null;
|
|
102
|
+
const sid = result.payload.sid;
|
|
96
103
|
return createTokenPair(result.payload.sub, config, {
|
|
97
104
|
roles: options?.roles ?? result.payload.roles,
|
|
105
|
+
...(sid ? { sessionId: sid } : {}),
|
|
98
106
|
});
|
|
99
107
|
}
|
|
100
108
|
//# sourceMappingURL=authToken.core.js.map
|
|
@@ -36,6 +36,11 @@ export declare function signToken(payload: TokenPayload, secret: string): JwtTok
|
|
|
36
36
|
* Never throws for untrusted input: every failure is reported as
|
|
37
37
|
* `{ valid: false, error }`. Oversized tokens are rejected before anything
|
|
38
38
|
* is decoded.
|
|
39
|
+
*
|
|
40
|
+
* The signature segment is compared as the canonical base64url *string*,
|
|
41
|
+
* not as decoded bytes: lenient base64 decoding ignores non-alphabet
|
|
42
|
+
* characters and non-zero trailing bits, which gave one issued token an
|
|
43
|
+
* unbounded number of accepted spellings.
|
|
39
44
|
*/
|
|
40
45
|
export declare function verifyToken(token: JwtToken, secret: string, expectedType: "access" | "refresh", config: TokenConfig): TokenVerificationResult;
|
|
41
46
|
/**
|
|
@@ -84,6 +84,11 @@ export function signToken(payload, secret) {
|
|
|
84
84
|
* Never throws for untrusted input: every failure is reported as
|
|
85
85
|
* `{ valid: false, error }`. Oversized tokens are rejected before anything
|
|
86
86
|
* is decoded.
|
|
87
|
+
*
|
|
88
|
+
* The signature segment is compared as the canonical base64url *string*,
|
|
89
|
+
* not as decoded bytes: lenient base64 decoding ignores non-alphabet
|
|
90
|
+
* characters and non-zero trailing bits, which gave one issued token an
|
|
91
|
+
* unbounded number of accepted spellings.
|
|
87
92
|
*/
|
|
88
93
|
export function verifyToken(token, secret, expectedType, config) {
|
|
89
94
|
const parts = splitToken(token);
|
|
@@ -100,8 +105,8 @@ export function verifyToken(token, secret, expectedType, config) {
|
|
|
100
105
|
}
|
|
101
106
|
const signatureInput = `${headerB64}.${bodyB64}`;
|
|
102
107
|
const expectedSignature = hmacSha256(signatureInput, secret);
|
|
103
|
-
const sigBuffer = Buffer.from(signature, "
|
|
104
|
-
const expectedBuffer = Buffer.from(expectedSignature, "
|
|
108
|
+
const sigBuffer = Buffer.from(signature, "utf-8");
|
|
109
|
+
const expectedBuffer = Buffer.from(expectedSignature, "utf-8");
|
|
105
110
|
if (sigBuffer.length !== expectedBuffer.length ||
|
|
106
111
|
!timingSafeEqual(sigBuffer, expectedBuffer)) {
|
|
107
112
|
return { valid: false, error: "Invalid signature" };
|
|
@@ -30,7 +30,11 @@ export interface LoginAttemptStore {
|
|
|
30
30
|
get(identifier: string): Promise<LoginAttemptRecord>;
|
|
31
31
|
/** Count an attempt (before credentials are checked). */
|
|
32
32
|
recordAttempt(identifier: string): Promise<LoginAttemptRecord>;
|
|
33
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* Count a failed authentication. Must be atomic: `createAuthService()`
|
|
35
|
+
* reserves a failure *before* checking the password and relies on
|
|
36
|
+
* concurrent calls seeing distinct, increasing counts.
|
|
37
|
+
*/
|
|
34
38
|
recordFailure(identifier: string): Promise<LoginAttemptRecord>;
|
|
35
39
|
/** Lock an identifier until `until` (Unix milliseconds). */
|
|
36
40
|
lock(identifier: string, until: number): Promise<void>;
|
|
@@ -52,8 +56,14 @@ export interface LoginThrottleConfig {
|
|
|
52
56
|
readonly lockoutSeconds?: number;
|
|
53
57
|
/**
|
|
54
58
|
* Attempts allowed per identifier inside the store's rate-limit window
|
|
55
|
-
* (default: 20). Exceeding it throws `AuthRateLimitError`.
|
|
56
|
-
*
|
|
59
|
+
* (default: 20). Exceeding it throws `AuthRateLimitError`.
|
|
60
|
+
*
|
|
61
|
+
* This is a *per-identifier* budget: it bounds the password guesses
|
|
62
|
+
* against one account, not the scrypt work the server can be made to do.
|
|
63
|
+
* Every new identifier starts with a fresh budget, and unknown identifiers
|
|
64
|
+
* still burn a full scrypt verification, so an attacker rotating
|
|
65
|
+
* identifiers is not limited by it. Put a per-IP limiter (for example
|
|
66
|
+
* `createRateLimiter` from `@zudojs/security`) in front of `login()`.
|
|
57
67
|
*/
|
|
58
68
|
readonly maxAttemptsPerWindow?: number;
|
|
59
69
|
/**
|
|
@@ -55,6 +55,10 @@ export declare function extractUserId(token: unknown): string | null;
|
|
|
55
55
|
/**
|
|
56
56
|
* Generate a CSRF token.
|
|
57
57
|
*
|
|
58
|
+
* @deprecated This returns an unbound random value that nothing in the
|
|
59
|
+
* framework can validate. Use `generateCsrfToken` / `validateCsrfToken`
|
|
60
|
+
* (or `createCsrfProtection`) from `@zudojs/security`, which produce
|
|
61
|
+
* session-bound, HMAC-signed tokens.
|
|
58
62
|
* @returns Random hex string for CSRF protection
|
|
59
63
|
*/
|
|
60
64
|
export declare function generateCsrfToken(): string;
|
|
@@ -115,6 +115,10 @@ export function extractUserId(token) {
|
|
|
115
115
|
/**
|
|
116
116
|
* Generate a CSRF token.
|
|
117
117
|
*
|
|
118
|
+
* @deprecated This returns an unbound random value that nothing in the
|
|
119
|
+
* framework can validate. Use `generateCsrfToken` / `validateCsrfToken`
|
|
120
|
+
* (or `createCsrfProtection`) from `@zudojs/security`, which produce
|
|
121
|
+
* session-bound, HMAC-signed tokens.
|
|
118
122
|
* @returns Random hex string for CSRF protection
|
|
119
123
|
*/
|
|
120
124
|
export function generateCsrfToken() {
|