@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
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rehash detection for stored password hashes.
|
|
3
|
+
*
|
|
4
|
+
* @module authPassword/authPassword.rehash
|
|
5
|
+
*/
|
|
6
|
+
import { CryptoAlgorithm, decodePasswordHash } from "@zudojs/crypto";
|
|
7
|
+
import { KEY_LENGTH, SALT_LENGTH, SCRYPT_N, SCRYPT_P, SCRYPT_R, } from "./authPassword.policy.js";
|
|
8
|
+
/**
|
|
9
|
+
* Check if a password hash needs rehashing: every hash that is not a
|
|
10
|
+
* `@zudojs/crypto` scrypt hash with the current parameters (N, r, p, salt
|
|
11
|
+
* and key length). All legacy `scrypt$…` hashes return `true`.
|
|
12
|
+
*
|
|
13
|
+
* @param hashedPassword - The stored hash
|
|
14
|
+
* @returns Whether the hash should be regenerated
|
|
15
|
+
*/
|
|
16
|
+
export function needsRehash(hashedPassword) {
|
|
17
|
+
if (typeof hashedPassword !== "string")
|
|
18
|
+
return true;
|
|
19
|
+
try {
|
|
20
|
+
const decoded = decodePasswordHash(hashedPassword);
|
|
21
|
+
if (decoded.algorithm !== CryptoAlgorithm.SCRYPT)
|
|
22
|
+
return true;
|
|
23
|
+
return (decoded.salt.byteLength !== SALT_LENGTH ||
|
|
24
|
+
decoded.hash.byteLength !== KEY_LENGTH ||
|
|
25
|
+
decoded.cost !== SCRYPT_N ||
|
|
26
|
+
decoded.blockSize !== SCRYPT_R ||
|
|
27
|
+
decoded.parallelization !== SCRYPT_P);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=authPassword.rehash.js.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.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,6 +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
|
+
export { throttleKey } from "./authProvider.throttle.js";
|
|
12
13
|
/** User lookup function provided by the consumer, keyed by login identifier. */
|
|
13
14
|
export type UserLookup = (identifier: string) => Promise<AuthUser | null>;
|
|
14
15
|
/** User lookup function provided by the consumer, keyed by user id. */
|
|
@@ -69,6 +70,18 @@ export interface AuthServiceConfig {
|
|
|
69
70
|
readonly allowInsecureFallbackGuard?: boolean;
|
|
70
71
|
/** Role name the fallback guard treats as superuser (default: "admin"). */
|
|
71
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;
|
|
72
85
|
}
|
|
73
86
|
/**
|
|
74
87
|
* Auth service interface.
|
|
@@ -3,9 +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
|
-
import {
|
|
9
|
+
import { assertTokenSecrets } from "../authToken/authToken.signing.js";
|
|
10
|
+
import { assertPositiveSeconds } from "../authSession/authSession.core.js";
|
|
11
|
+
import { AccountDeactivatedError, AuthConfigurationError, InvalidCredentialsError, SessionExpiredError, TokenExpiredError, TokenInvalidError, TokenRevokedError, } from "../authErrors/authError.base.js";
|
|
9
12
|
/**
|
|
10
13
|
* A syntactically valid hash that no password matches.
|
|
11
14
|
*
|
|
@@ -13,57 +16,32 @@ import { AccountDeactivatedError, AccountLockedError, AuthConfigurationError, Au
|
|
|
13
16
|
* the unknown-user path takes comparable time to the wrong-password path and
|
|
14
17
|
* the response time does not disclose whether an account exists.
|
|
15
18
|
*/
|
|
16
|
-
const DUMMY_PASSWORD_HASH = `scrypt$16384$8$
|
|
17
|
-
|
|
18
|
-
const DEFAULT_LOCKOUT_SECONDS = 900;
|
|
19
|
-
const DEFAULT_MAX_ATTEMPTS_PER_WINDOW = 20;
|
|
20
|
-
const DEFAULT_WINDOW_SECONDS = 60;
|
|
19
|
+
const DUMMY_PASSWORD_HASH = `v1$scrypt$16384$8$5$${"A".repeat(43)}.${"A".repeat(86)}`;
|
|
20
|
+
export { throttleKey } from "./authProvider.throttle.js";
|
|
21
21
|
/**
|
|
22
22
|
* Create an auth service.
|
|
23
23
|
*/
|
|
24
24
|
export function createAuthService(config) {
|
|
25
|
-
const { token: tokenConfig, sessionStore, findUser, findUserById, verifyPassword: verifyPwd, sessionTtlSeconds, absoluteSessionTtlSeconds, permissions, revocationStore, loginThrottle, allowInsecureFallbackGuard, fallbackAdminRole, } = config;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const now = Date.now();
|
|
35
|
-
const current = await loginThrottle.store.get(identifier);
|
|
36
|
-
if (current.lockedUntil !== undefined && current.lockedUntil > now) {
|
|
37
|
-
throw new AccountLockedError(undefined, {
|
|
38
|
-
retryAfterSeconds: Math.ceil((current.lockedUntil - now) / 1000),
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
|
-
const updated = await loginThrottle.store.recordAttempt(identifier);
|
|
42
|
-
if (updated.attempts > maxAttemptsPerWindow) {
|
|
43
|
-
throw new AuthRateLimitError(undefined, {
|
|
44
|
-
retryAfterSeconds: windowSeconds,
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
/** Record a failed authentication and lock the identifier if warranted. */
|
|
49
|
-
async function recordFailure(identifier) {
|
|
50
|
-
if (!loginThrottle)
|
|
51
|
-
return;
|
|
52
|
-
const updated = await loginThrottle.store.recordFailure(identifier);
|
|
53
|
-
if (updated.failures >= maxFailedAttempts) {
|
|
54
|
-
await loginThrottle.store.lock(identifier, Date.now() + lockoutSeconds * 1000);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
25
|
+
const { token: tokenConfig, sessionStore, findUser, findUserById, verifyPassword: verifyPwd, sessionTtlSeconds, absoluteSessionTtlSeconds, permissions, revocationStore, loginThrottle, allowInsecureFallbackGuard, fallbackAdminRole, allowSessionlessTokens, } = config;
|
|
26
|
+
// Fail at construction, not at the first login: a bad secret or a NaN
|
|
27
|
+
// lifetime (`Number(process.env.X)` with X unset) otherwise surfaced as
|
|
28
|
+
// a runtime error on the request path — or, for the session TTL, not at
|
|
29
|
+
// all, because a NaN idle timeout produced sessions that never expired.
|
|
30
|
+
assertTokenSecrets(tokenConfig);
|
|
31
|
+
assertPositiveSeconds(sessionTtlSeconds, "sessionTtlSeconds");
|
|
32
|
+
assertPositiveSeconds(absoluteSessionTtlSeconds, "absoluteSessionTtlSeconds");
|
|
33
|
+
const throttle = createLoginThrottleGate(loginThrottle);
|
|
57
34
|
/**
|
|
58
35
|
* Reject the token unless the session it was issued against is still
|
|
59
36
|
* alive; refresh the session's idle timer when it is.
|
|
60
37
|
*/
|
|
61
38
|
async function requireLiveSession(payload) {
|
|
62
39
|
const sid = payload.sid;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
40
|
+
if (!sid) {
|
|
41
|
+
if (allowSessionlessTokens)
|
|
42
|
+
return undefined;
|
|
43
|
+
throw new TokenInvalidError("Token is not bound to a session");
|
|
44
|
+
}
|
|
67
45
|
const session = await sessionStore.get(sid);
|
|
68
46
|
if (!session) {
|
|
69
47
|
throw new SessionExpiredError("Session is no longer active");
|
|
@@ -97,7 +75,7 @@ export function createAuthService(config) {
|
|
|
97
75
|
*/
|
|
98
76
|
async login(credentials, context) {
|
|
99
77
|
const identifier = credentials.identifier;
|
|
100
|
-
await
|
|
78
|
+
const slot = await throttle.begin(identifier);
|
|
101
79
|
const user = await findUser(identifier);
|
|
102
80
|
let authenticated = false;
|
|
103
81
|
if (user) {
|
|
@@ -109,12 +87,12 @@ export function createAuthService(config) {
|
|
|
109
87
|
await verifyPassword(credentials.password, DUMMY_PASSWORD_HASH);
|
|
110
88
|
}
|
|
111
89
|
if (!user || !authenticated) {
|
|
112
|
-
await
|
|
90
|
+
await throttle.fail(identifier, slot);
|
|
113
91
|
throw new InvalidCredentialsError();
|
|
114
92
|
}
|
|
115
93
|
// The credentials were correct, so the attempt counter is cleared even
|
|
116
94
|
// if the account turns out to be unusable.
|
|
117
|
-
await
|
|
95
|
+
await throttle.succeed(identifier);
|
|
118
96
|
// Account state is only disclosed once the password has been proven,
|
|
119
97
|
// so it cannot be probed without a valid credential.
|
|
120
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
|
|
@@ -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,7 +5,8 @@
|
|
|
5
5
|
*
|
|
6
6
|
* For production, implement SessionStore backed by Redis, database, etc.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import { randomHex } from "@zudojs/crypto";
|
|
9
|
+
import { AuthConfigurationError } from "../authErrors/authError.base.js";
|
|
9
10
|
const DEFAULT_TTL_SECONDS = 86400; // 24 hours
|
|
10
11
|
/** Minimum interval between full sweeps of the session map. */
|
|
11
12
|
const DEFAULT_PURGE_INTERVAL_MS = 60_000;
|
|
@@ -42,8 +43,13 @@ 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
|
-
const id = generateSessionId();
|
|
52
|
+
const id = await generateSessionId();
|
|
47
53
|
const now = new Date();
|
|
48
54
|
const ttlMs = (options.ttlSeconds ?? DEFAULT_TTL_SECONDS) * 1000;
|
|
49
55
|
const absoluteExpiresAt = options.absoluteTtlSeconds !== undefined
|
|
@@ -111,12 +117,27 @@ 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;
|
|
117
137
|
return expiresAt.getTime() > absolute.getTime() ? absolute : expiresAt;
|
|
118
138
|
}
|
|
119
|
-
|
|
120
|
-
|
|
139
|
+
/** 32 random bytes from `@zudojs/crypto`, hex-encoded (64 characters). */
|
|
140
|
+
async function generateSessionId() {
|
|
141
|
+
return (await randomHex(64));
|
|
121
142
|
}
|
|
122
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
|
/**
|
|
@@ -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) {
|
|
@@ -69,6 +84,11 @@ export function signToken(payload, secret) {
|
|
|
69
84
|
* Never throws for untrusted input: every failure is reported as
|
|
70
85
|
* `{ valid: false, error }`. Oversized tokens are rejected before anything
|
|
71
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.
|
|
72
92
|
*/
|
|
73
93
|
export function verifyToken(token, secret, expectedType, config) {
|
|
74
94
|
const parts = splitToken(token);
|
|
@@ -85,8 +105,8 @@ export function verifyToken(token, secret, expectedType, config) {
|
|
|
85
105
|
}
|
|
86
106
|
const signatureInput = `${headerB64}.${bodyB64}`;
|
|
87
107
|
const expectedSignature = hmacSha256(signatureInput, secret);
|
|
88
|
-
const sigBuffer = Buffer.from(signature, "
|
|
89
|
-
const expectedBuffer = Buffer.from(expectedSignature, "
|
|
108
|
+
const sigBuffer = Buffer.from(signature, "utf-8");
|
|
109
|
+
const expectedBuffer = Buffer.from(expectedSignature, "utf-8");
|
|
90
110
|
if (sigBuffer.length !== expectedBuffer.length ||
|
|
91
111
|
!timingSafeEqual(sigBuffer, expectedBuffer)) {
|
|
92
112
|
return { valid: false, error: "Invalid signature" };
|