@zudojs/auth 0.1.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +228 -17
- package/dist/authErrors/authError.base.d.ts +50 -16
- package/dist/authErrors/authError.base.js +94 -18
- package/dist/authErrors/index.d.ts +1 -1
- package/dist/authErrors/index.js +1 -1
- package/dist/authPassword/authPassword.core.d.ts +31 -4
- package/dist/authPassword/authPassword.core.js +119 -20
- package/dist/authPassword/index.d.ts +1 -1
- package/dist/authPassword/index.js +1 -1
- package/dist/authProvider/authAttempt.memory.d.ts +21 -0
- package/dist/authProvider/authAttempt.memory.js +91 -0
- package/dist/authProvider/authProvider.core.d.ts +56 -8
- package/dist/authProvider/authProvider.core.js +211 -37
- package/dist/authProvider/index.d.ts +2 -1
- package/dist/authProvider/index.js +1 -0
- package/dist/authSession/authSession.core.d.ts +13 -3
- package/dist/authSession/authSession.core.js +62 -13
- package/dist/authToken/authToken.core.d.ts +27 -1
- package/dist/authToken/authToken.core.js +35 -4
- package/dist/authToken/authToken.encoding.d.ts +43 -0
- package/dist/authToken/authToken.encoding.js +74 -0
- package/dist/authToken/authToken.revocation.d.ts +25 -0
- package/dist/authToken/authToken.revocation.js +71 -0
- package/dist/authToken/authToken.signing.d.ts +25 -1
- package/dist/authToken/authToken.signing.js +111 -33
- package/dist/authToken/index.d.ts +1 -0
- package/dist/authToken/index.js +1 -0
- package/dist/authToken/jwt.namespace.d.ts +18 -8
- package/dist/authToken/jwt.namespace.js +11 -1
- package/dist/authTypes/authAttempt.type.d.ts +63 -0
- package/dist/authTypes/authAttempt.type.js +7 -0
- package/dist/authTypes/authCredentials.type.d.ts +26 -0
- package/dist/authTypes/authCredentials.type.js +11 -0
- package/dist/authTypes/authSession.type.d.ts +31 -3
- package/dist/authTypes/authSession.type.js +17 -1
- package/dist/authTypes/authToken.type.d.ts +48 -1
- package/dist/authTypes/authUser.type.d.ts +12 -0
- package/dist/authTypes/authUser.type.js +17 -1
- package/dist/authTypes/index.d.ts +6 -5
- package/dist/authTypes/index.js +5 -4
- package/dist/authUtils/authUtils.helper.d.ts +31 -10
- package/dist/authUtils/authUtils.helper.js +70 -31
- package/package.json +23 -16
- package/dist/.tsbuildinfo +0 -1
- package/dist/authErrors/authError.base.d.ts.map +0 -1
- package/dist/authErrors/authError.base.js.map +0 -1
- package/dist/authErrors/index.d.ts.map +0 -1
- package/dist/authErrors/index.js.map +0 -1
- package/dist/authPassword/authPassword.core.d.ts.map +0 -1
- package/dist/authPassword/authPassword.core.js.map +0 -1
- package/dist/authPassword/index.d.ts.map +0 -1
- package/dist/authPassword/index.js.map +0 -1
- package/dist/authProvider/authProvider.core.d.ts.map +0 -1
- package/dist/authProvider/authProvider.core.js.map +0 -1
- package/dist/authProvider/index.d.ts.map +0 -1
- package/dist/authProvider/index.js.map +0 -1
- package/dist/authSession/authSession.core.d.ts.map +0 -1
- package/dist/authSession/authSession.core.js.map +0 -1
- package/dist/authSession/index.d.ts.map +0 -1
- package/dist/authSession/index.js.map +0 -1
- package/dist/authToken/authToken.core.d.ts.map +0 -1
- package/dist/authToken/authToken.core.js.map +0 -1
- package/dist/authToken/authToken.signing.d.ts.map +0 -1
- package/dist/authToken/authToken.signing.js.map +0 -1
- package/dist/authToken/index.d.ts.map +0 -1
- package/dist/authToken/index.js.map +0 -1
- package/dist/authToken/jwt.namespace.d.ts.map +0 -1
- package/dist/authToken/jwt.namespace.js.map +0 -1
- package/dist/authTypes/authRbac.type.d.ts.map +0 -1
- package/dist/authTypes/authRbac.type.js.map +0 -1
- package/dist/authTypes/authSession.type.d.ts.map +0 -1
- package/dist/authTypes/authSession.type.js.map +0 -1
- package/dist/authTypes/authStrategy.type.d.ts +0 -83
- package/dist/authTypes/authStrategy.type.d.ts.map +0 -1
- package/dist/authTypes/authStrategy.type.js +0 -7
- package/dist/authTypes/authStrategy.type.js.map +0 -1
- package/dist/authTypes/authToken.type.d.ts.map +0 -1
- package/dist/authTypes/authToken.type.js.map +0 -1
- package/dist/authTypes/authUser.type.d.ts.map +0 -1
- package/dist/authTypes/authUser.type.js.map +0 -1
- package/dist/authTypes/index.d.ts.map +0 -1
- package/dist/authTypes/index.js.map +0 -1
- package/dist/authUtils/authUtils.helper.d.ts.map +0 -1
- package/dist/authUtils/authUtils.helper.js.map +0 -1
- package/dist/authUtils/index.d.ts.map +0 -1
- package/dist/authUtils/index.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
|
@@ -4,71 +4,230 @@
|
|
|
4
4
|
* @module authProvider/authProvider
|
|
5
5
|
*/
|
|
6
6
|
import { hashPassword, verifyPassword, } from "../authPassword/authPassword.core.js";
|
|
7
|
-
import { createTokenPair, verifyAccessToken,
|
|
8
|
-
import { InvalidCredentialsError, TokenExpiredError,
|
|
7
|
+
import { createTokenPair, verifyAccessToken, verifyRefreshToken, } from "../authToken/authToken.core.js";
|
|
8
|
+
import { AccountDeactivatedError, AccountLockedError, AuthConfigurationError, AuthRateLimitError, InvalidCredentialsError, SessionExpiredError, TokenExpiredError, TokenInvalidError, TokenRevokedError, } from "../authErrors/authError.base.js";
|
|
9
|
+
/**
|
|
10
|
+
* A syntactically valid hash that no password matches.
|
|
11
|
+
*
|
|
12
|
+
* Verifying against it costs the same scrypt work as a real verification, so
|
|
13
|
+
* the unknown-user path takes comparable time to the wrong-password path and
|
|
14
|
+
* the response time does not disclose whether an account exists.
|
|
15
|
+
*/
|
|
16
|
+
const DUMMY_PASSWORD_HASH = `scrypt$16384$8$1$${"0".repeat(64)}$${"0".repeat(128)}`;
|
|
17
|
+
const DEFAULT_MAX_FAILED_ATTEMPTS = 5;
|
|
18
|
+
const DEFAULT_LOCKOUT_SECONDS = 900;
|
|
19
|
+
const DEFAULT_MAX_ATTEMPTS_PER_WINDOW = 20;
|
|
20
|
+
const DEFAULT_WINDOW_SECONDS = 60;
|
|
9
21
|
/**
|
|
10
22
|
* Create an auth service.
|
|
11
23
|
*/
|
|
12
24
|
export function createAuthService(config) {
|
|
13
|
-
const { token: tokenConfig, sessionStore, findUser, verifyPassword: verifyPwd, sessionTtlSeconds, permissions, } = config;
|
|
25
|
+
const { token: tokenConfig, sessionStore, findUser, findUserById, verifyPassword: verifyPwd, sessionTtlSeconds, absoluteSessionTtlSeconds, permissions, revocationStore, loginThrottle, allowInsecureFallbackGuard, fallbackAdminRole, } = config;
|
|
26
|
+
const maxFailedAttempts = loginThrottle?.maxFailedAttempts ?? DEFAULT_MAX_FAILED_ATTEMPTS;
|
|
27
|
+
const lockoutSeconds = loginThrottle?.lockoutSeconds ?? DEFAULT_LOCKOUT_SECONDS;
|
|
28
|
+
const maxAttemptsPerWindow = loginThrottle?.maxAttemptsPerWindow ?? DEFAULT_MAX_ATTEMPTS_PER_WINDOW;
|
|
29
|
+
const windowSeconds = loginThrottle?.windowSeconds ?? DEFAULT_WINDOW_SECONDS;
|
|
30
|
+
/** Throw if the identifier is locked out or over its attempt budget. */
|
|
31
|
+
async function enforceThrottle(identifier) {
|
|
32
|
+
if (!loginThrottle)
|
|
33
|
+
return;
|
|
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
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Reject the token unless the session it was issued against is still
|
|
59
|
+
* alive; refresh the session's idle timer when it is.
|
|
60
|
+
*/
|
|
61
|
+
async function requireLiveSession(payload) {
|
|
62
|
+
const sid = payload.sid;
|
|
63
|
+
// Tokens minted by `createTokenPair` directly carry no `sid`; they cannot
|
|
64
|
+
// be forged, and there is no session to check for them.
|
|
65
|
+
if (!sid)
|
|
66
|
+
return undefined;
|
|
67
|
+
const session = await sessionStore.get(sid);
|
|
68
|
+
if (!session) {
|
|
69
|
+
throw new SessionExpiredError("Session is no longer active");
|
|
70
|
+
}
|
|
71
|
+
await sessionStore.touch(sid);
|
|
72
|
+
return sid;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Atomically claim the used refresh token id.
|
|
76
|
+
*
|
|
77
|
+
* @returns true when this call was the one that revoked it.
|
|
78
|
+
*/
|
|
79
|
+
async function claimRefreshToken(store, jti, exp) {
|
|
80
|
+
if (store.revokeIfNotRevoked) {
|
|
81
|
+
return store.revokeIfNotRevoked(jti, exp);
|
|
82
|
+
}
|
|
83
|
+
// Racy fallback for stores predating `revokeIfNotRevoked`.
|
|
84
|
+
if (await store.isRevoked(jti))
|
|
85
|
+
return false;
|
|
86
|
+
await store.revoke(jti, exp);
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
14
89
|
return {
|
|
15
90
|
/**
|
|
16
91
|
* Authenticate a user with credentials and return tokens + session.
|
|
92
|
+
*
|
|
93
|
+
* Unknown user, wrong password and (before the password is proven)
|
|
94
|
+
* deactivated account are indistinguishable to the caller: the same
|
|
95
|
+
* `InvalidCredentialsError` is thrown, and the unknown-user path performs
|
|
96
|
+
* the same scrypt work as the known-user path.
|
|
17
97
|
*/
|
|
18
98
|
async login(credentials, context) {
|
|
19
|
-
const
|
|
20
|
-
|
|
99
|
+
const identifier = credentials.identifier;
|
|
100
|
+
await enforceThrottle(identifier);
|
|
101
|
+
const user = await findUser(identifier);
|
|
102
|
+
let authenticated = false;
|
|
103
|
+
if (user) {
|
|
104
|
+
authenticated = await verifyPwd(user.id, credentials.password);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
// Burn comparable work so response time does not reveal that the
|
|
108
|
+
// account does not exist.
|
|
109
|
+
await verifyPassword(credentials.password, DUMMY_PASSWORD_HASH);
|
|
110
|
+
}
|
|
111
|
+
if (!user || !authenticated) {
|
|
112
|
+
await recordFailure(identifier);
|
|
21
113
|
throw new InvalidCredentialsError();
|
|
22
114
|
}
|
|
115
|
+
// The credentials were correct, so the attempt counter is cleared even
|
|
116
|
+
// if the account turns out to be unusable.
|
|
117
|
+
await loginThrottle?.store.reset(identifier);
|
|
118
|
+
// Account state is only disclosed once the password has been proven,
|
|
119
|
+
// so it cannot be probed without a valid credential.
|
|
23
120
|
if (!user.active) {
|
|
24
121
|
throw new AccountDeactivatedError();
|
|
25
122
|
}
|
|
26
|
-
const valid = await verifyPwd(user.id, credentials.password);
|
|
27
|
-
if (!valid) {
|
|
28
|
-
throw new InvalidCredentialsError();
|
|
29
|
-
}
|
|
30
|
-
const tokens = createTokenPair(user.id, tokenConfig, {
|
|
31
|
-
roles: user.roles,
|
|
32
|
-
});
|
|
33
123
|
const session = await sessionStore.create({
|
|
34
124
|
userId: user.id,
|
|
35
125
|
userAgent: context?.userAgent,
|
|
36
126
|
ip: context?.ip,
|
|
37
127
|
ttlSeconds: sessionTtlSeconds,
|
|
128
|
+
absoluteTtlSeconds: absoluteSessionTtlSeconds,
|
|
129
|
+
});
|
|
130
|
+
const tokens = createTokenPair(user.id, tokenConfig, {
|
|
131
|
+
roles: user.roles,
|
|
132
|
+
sessionId: session.id,
|
|
38
133
|
});
|
|
39
134
|
return { user, tokens, sessionId: session.id };
|
|
40
135
|
},
|
|
41
136
|
/**
|
|
42
137
|
* Verify an access token and return the payload.
|
|
138
|
+
*
|
|
139
|
+
* @throws {TokenExpiredError} expired token
|
|
140
|
+
* @throws {TokenInvalidError} malformed, mis-signed or wrong-type token
|
|
141
|
+
* @throws {SessionExpiredError} the session the token was issued against
|
|
142
|
+
* has been destroyed (logout) or expired
|
|
43
143
|
*/
|
|
44
|
-
verifyToken(token) {
|
|
144
|
+
async verifyToken(token) {
|
|
45
145
|
const result = verifyAccessToken(token, tokenConfig);
|
|
46
|
-
if (!result.valid) {
|
|
47
|
-
|
|
146
|
+
if (!result.valid || !result.payload) {
|
|
147
|
+
if (result.error === "Token expired") {
|
|
148
|
+
throw new TokenExpiredError(result.error);
|
|
149
|
+
}
|
|
150
|
+
throw new TokenInvalidError(result.error ?? "Token verification failed");
|
|
48
151
|
}
|
|
49
|
-
|
|
152
|
+
const payload = result.payload;
|
|
153
|
+
await requireLiveSession(payload);
|
|
154
|
+
return payload;
|
|
50
155
|
},
|
|
51
156
|
/**
|
|
52
157
|
* Refresh an access token using a refresh token.
|
|
158
|
+
*
|
|
159
|
+
* Rotation is atomic when the revocation store implements
|
|
160
|
+
* `revokeIfNotRevoked`: exactly one of two concurrent refreshes of the
|
|
161
|
+
* same token wins, and the loser is treated as a replay — the user's
|
|
162
|
+
* sessions are destroyed and `TokenRevokedError` is thrown.
|
|
163
|
+
*
|
|
164
|
+
* The user is re-loaded on every refresh, so deactivation and role
|
|
165
|
+
* changes take effect immediately rather than at the end of the refresh
|
|
166
|
+
* token's 7-day life.
|
|
53
167
|
*/
|
|
54
168
|
async refresh(refreshToken) {
|
|
55
|
-
const result =
|
|
56
|
-
if (!result) {
|
|
57
|
-
|
|
169
|
+
const result = verifyRefreshToken(refreshToken, tokenConfig);
|
|
170
|
+
if (!result.valid || !result.payload) {
|
|
171
|
+
if (result.error === "Token expired") {
|
|
172
|
+
throw new TokenExpiredError("Refresh token has expired");
|
|
173
|
+
}
|
|
174
|
+
throw new TokenInvalidError("Refresh token is invalid");
|
|
175
|
+
}
|
|
176
|
+
const payload = result.payload;
|
|
177
|
+
const { sub, jti, exp } = payload;
|
|
178
|
+
const sid = await requireLiveSession(payload);
|
|
179
|
+
if (revocationStore) {
|
|
180
|
+
const claimed = await claimRefreshToken(revocationStore, jti, exp);
|
|
181
|
+
if (!claimed) {
|
|
182
|
+
// Reuse of an already-rotated refresh token: assume the chain is
|
|
183
|
+
// compromised and terminate every session for the user
|
|
184
|
+
// (RFC 6819 §5.2.2.3).
|
|
185
|
+
await sessionStore.destroyAllForUser(sub);
|
|
186
|
+
throw new TokenRevokedError("Refresh token has already been used");
|
|
187
|
+
}
|
|
58
188
|
}
|
|
59
|
-
|
|
189
|
+
const user = await findUserById(sub);
|
|
190
|
+
if (!user || !user.active) {
|
|
191
|
+
throw new AccountDeactivatedError("User account is no longer active");
|
|
192
|
+
}
|
|
193
|
+
return createTokenPair(user.id, tokenConfig, {
|
|
194
|
+
roles: user.roles,
|
|
195
|
+
...(sid ? { sessionId: sid } : {}),
|
|
196
|
+
});
|
|
60
197
|
},
|
|
61
198
|
/**
|
|
62
|
-
* Logout — destroy the session
|
|
199
|
+
* Logout — destroy the session, which invalidates every access and
|
|
200
|
+
* refresh token carrying that `sid`.
|
|
201
|
+
*
|
|
202
|
+
* @param refreshToken - The refresh token being surrendered. When given
|
|
203
|
+
* alongside a revocation store, its `jti` is revoked too, so the token
|
|
204
|
+
* stays dead even if it is later presented against a new session.
|
|
63
205
|
*/
|
|
64
|
-
async logout(sessionId) {
|
|
206
|
+
async logout(sessionId, refreshToken) {
|
|
65
207
|
await sessionStore.destroy(sessionId);
|
|
208
|
+
if (refreshToken && revocationStore) {
|
|
209
|
+
const result = verifyRefreshToken(refreshToken, tokenConfig);
|
|
210
|
+
if (result.valid && result.payload) {
|
|
211
|
+
await revocationStore.revoke(result.payload.jti, result.payload.exp);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
/**
|
|
216
|
+
* Sign out everywhere — destroy every session for a user. Tokens bound
|
|
217
|
+
* to those sessions stop verifying immediately.
|
|
218
|
+
*/
|
|
219
|
+
async logoutAll(userId) {
|
|
220
|
+
await sessionStore.destroyAllForUser(userId);
|
|
66
221
|
},
|
|
67
222
|
/**
|
|
68
223
|
* Check if a user has a specific permission.
|
|
69
|
-
* Delegates to @zudojs/permissions engine when configured.
|
|
224
|
+
* Delegates to the @zudojs/permissions engine when configured.
|
|
225
|
+
*
|
|
226
|
+
* @throws {AuthConfigurationError} when no engine is configured and
|
|
227
|
+
* `allowInsecureFallbackGuard` was not set.
|
|
70
228
|
*/
|
|
71
|
-
async checkAccess(
|
|
229
|
+
async checkAccess(context) {
|
|
230
|
+
const { userId, roles: userRoles, permission, resourceOwnerId } = context;
|
|
72
231
|
if (permissions) {
|
|
73
232
|
const actor = { id: userId, roles: [...userRoles] };
|
|
74
233
|
const resource = resourceOwnerId
|
|
@@ -82,8 +241,12 @@ export function createAuthService(config) {
|
|
|
82
241
|
userRoles: [...userRoles],
|
|
83
242
|
};
|
|
84
243
|
}
|
|
85
|
-
|
|
86
|
-
|
|
244
|
+
if (!allowInsecureFallbackGuard) {
|
|
245
|
+
throw new AuthConfigurationError("checkAccess() requires a `permissions` engine. Set " +
|
|
246
|
+
"`allowInsecureFallbackGuard: true` to opt into the built-in " +
|
|
247
|
+
"fallback, which grants resource owners every permission.");
|
|
248
|
+
}
|
|
249
|
+
return simpleGuard(userRoles, permission, userId, resourceOwnerId, fallbackAdminRole ?? "admin");
|
|
87
250
|
},
|
|
88
251
|
/**
|
|
89
252
|
* Hash a password (for user registration).
|
|
@@ -100,20 +263,31 @@ export function createAuthService(config) {
|
|
|
100
263
|
};
|
|
101
264
|
}
|
|
102
265
|
/**
|
|
103
|
-
* Simple fallback guard when no permissions engine is configured
|
|
104
|
-
*
|
|
266
|
+
* Simple fallback guard used only when no permissions engine is configured
|
|
267
|
+
* *and* the consumer opted in with `allowInsecureFallbackGuard`.
|
|
268
|
+
*
|
|
269
|
+
* It grants a resource owner every permission and grants the configured
|
|
270
|
+
* admin role everything — it does no permission matching at all. Every
|
|
271
|
+
* allowed result carries a `reason` naming the fallback so the decision is
|
|
272
|
+
* distinguishable from an engine-backed one in an audit log.
|
|
105
273
|
*/
|
|
106
|
-
function simpleGuard(userRoles, permission, userId, resourceOwnerId) {
|
|
107
|
-
// Ownership check
|
|
274
|
+
function simpleGuard(userRoles, permission, userId, resourceOwnerId, adminRole) {
|
|
275
|
+
// Ownership check — note this ignores `permission` entirely.
|
|
108
276
|
if (resourceOwnerId && resourceOwnerId === userId) {
|
|
109
|
-
return {
|
|
277
|
+
return {
|
|
278
|
+
allowed: true,
|
|
279
|
+
reason: "granted by fallback guard: resource ownership",
|
|
280
|
+
requiredPermission: permission,
|
|
281
|
+
userRoles: [...userRoles],
|
|
282
|
+
};
|
|
110
283
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
284
|
+
if (userRoles.includes(adminRole)) {
|
|
285
|
+
return {
|
|
286
|
+
allowed: true,
|
|
287
|
+
reason: `granted by fallback guard: "${adminRole}" role`,
|
|
288
|
+
requiredPermission: permission,
|
|
289
|
+
userRoles: [...userRoles],
|
|
290
|
+
};
|
|
117
291
|
}
|
|
118
292
|
return {
|
|
119
293
|
allowed: false,
|
|
@@ -3,5 +3,6 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @module authProvider
|
|
5
5
|
*/
|
|
6
|
-
export { createAuthService, type AuthServiceConfig, type LoginResult, type UserLookup, type PasswordVerifier, } from "./authProvider.core.js";
|
|
6
|
+
export { createAuthService, type AuthService, type AuthServiceConfig, type LoginResult, type UserLookup, type UserByIdLookup, type PasswordVerifier, } from "./authProvider.core.js";
|
|
7
|
+
export { createMemoryLoginAttemptStore } from "./authAttempt.memory.js";
|
|
7
8
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -9,8 +9,18 @@ import type { SessionStore } from "../authTypes/authSession.type.js";
|
|
|
9
9
|
/**
|
|
10
10
|
* Create an in-memory session store.
|
|
11
11
|
*
|
|
12
|
-
* Good for development and testing. For production,
|
|
13
|
-
*
|
|
12
|
+
* Good for development and testing. For production, implement SessionStore
|
|
13
|
+
* with Redis or a database.
|
|
14
|
+
*
|
|
15
|
+
* Expired sessions are reclaimed on any access — `create`, `get` and `touch`
|
|
16
|
+
* all run a rate-limited sweep — so a store that stops receiving logins
|
|
17
|
+
* still releases its memory. The sweep is rate-limited rather than run per
|
|
18
|
+
* call so that a large store does not turn an O(1) lookup into an O(n) walk.
|
|
19
|
+
*
|
|
20
|
+
* @param options.purgeIntervalMs - Minimum gap between full sweeps
|
|
21
|
+
* (default: 60000). Set to 0 to sweep on every access.
|
|
14
22
|
*/
|
|
15
|
-
export declare function createMemorySessionStore(
|
|
23
|
+
export declare function createMemorySessionStore(storeOptions?: {
|
|
24
|
+
readonly purgeIntervalMs?: number;
|
|
25
|
+
}): SessionStore;
|
|
16
26
|
//# sourceMappingURL=authSession.core.d.ts.map
|
|
@@ -7,19 +7,49 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { randomBytes } from "node:crypto";
|
|
9
9
|
const DEFAULT_TTL_SECONDS = 86400; // 24 hours
|
|
10
|
+
/** Minimum interval between full sweeps of the session map. */
|
|
11
|
+
const DEFAULT_PURGE_INTERVAL_MS = 60_000;
|
|
10
12
|
/**
|
|
11
13
|
* Create an in-memory session store.
|
|
12
14
|
*
|
|
13
|
-
* Good for development and testing. For production,
|
|
14
|
-
*
|
|
15
|
+
* Good for development and testing. For production, implement SessionStore
|
|
16
|
+
* with Redis or a database.
|
|
17
|
+
*
|
|
18
|
+
* Expired sessions are reclaimed on any access — `create`, `get` and `touch`
|
|
19
|
+
* all run a rate-limited sweep — so a store that stops receiving logins
|
|
20
|
+
* still releases its memory. The sweep is rate-limited rather than run per
|
|
21
|
+
* call so that a large store does not turn an O(1) lookup into an O(n) walk.
|
|
22
|
+
*
|
|
23
|
+
* @param options.purgeIntervalMs - Minimum gap between full sweeps
|
|
24
|
+
* (default: 60000). Set to 0 to sweep on every access.
|
|
15
25
|
*/
|
|
16
|
-
export function createMemorySessionStore() {
|
|
26
|
+
export function createMemorySessionStore(storeOptions) {
|
|
17
27
|
const sessions = new Map();
|
|
28
|
+
const ttls = new Map();
|
|
29
|
+
const purgeIntervalMs = storeOptions?.purgeIntervalMs ?? DEFAULT_PURGE_INTERVAL_MS;
|
|
30
|
+
let lastPurge = 0;
|
|
31
|
+
function maybePurgeExpired() {
|
|
32
|
+
const nowMs = Date.now();
|
|
33
|
+
if (nowMs - lastPurge < purgeIntervalMs)
|
|
34
|
+
return;
|
|
35
|
+
lastPurge = nowMs;
|
|
36
|
+
for (const [id, session] of sessions) {
|
|
37
|
+
if (nowMs > session.expiresAt.getTime()) {
|
|
38
|
+
sessions.delete(id);
|
|
39
|
+
ttls.delete(id);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
18
43
|
return {
|
|
19
44
|
async create(options) {
|
|
45
|
+
maybePurgeExpired();
|
|
20
46
|
const id = generateSessionId();
|
|
21
47
|
const now = new Date();
|
|
22
48
|
const ttlMs = (options.ttlSeconds ?? DEFAULT_TTL_SECONDS) * 1000;
|
|
49
|
+
const absoluteExpiresAt = options.absoluteTtlSeconds !== undefined
|
|
50
|
+
? new Date(now.getTime() + options.absoluteTtlSeconds * 1000)
|
|
51
|
+
: undefined;
|
|
52
|
+
const expiresAt = clampToAbsolute(new Date(now.getTime() + ttlMs), absoluteExpiresAt);
|
|
23
53
|
const session = {
|
|
24
54
|
id,
|
|
25
55
|
userId: options.userId,
|
|
@@ -27,46 +57,65 @@ export function createMemorySessionStore() {
|
|
|
27
57
|
ip: options.ip,
|
|
28
58
|
createdAt: now,
|
|
29
59
|
lastActivityAt: now,
|
|
30
|
-
expiresAt
|
|
31
|
-
|
|
60
|
+
expiresAt,
|
|
61
|
+
absoluteExpiresAt,
|
|
32
62
|
metadata: options.metadata,
|
|
33
63
|
};
|
|
34
64
|
sessions.set(id, session);
|
|
65
|
+
ttls.set(id, ttlMs);
|
|
35
66
|
return session;
|
|
36
67
|
},
|
|
37
68
|
async get(sessionId) {
|
|
69
|
+
maybePurgeExpired();
|
|
38
70
|
const session = sessions.get(sessionId);
|
|
39
71
|
if (!session)
|
|
40
72
|
return null;
|
|
41
|
-
if (
|
|
42
|
-
return null;
|
|
43
|
-
if (new Date() > session.expiresAt) {
|
|
73
|
+
if (Date.now() > session.expiresAt.getTime()) {
|
|
44
74
|
sessions.delete(sessionId);
|
|
75
|
+
ttls.delete(sessionId);
|
|
45
76
|
return null;
|
|
46
77
|
}
|
|
47
78
|
return session;
|
|
48
79
|
},
|
|
49
80
|
async touch(sessionId) {
|
|
81
|
+
maybePurgeExpired();
|
|
50
82
|
const session = sessions.get(sessionId);
|
|
51
|
-
if (session
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
83
|
+
if (!session)
|
|
84
|
+
return;
|
|
85
|
+
const now = new Date();
|
|
86
|
+
if (now.getTime() > session.expiresAt.getTime()) {
|
|
87
|
+
sessions.delete(sessionId);
|
|
88
|
+
ttls.delete(sessionId);
|
|
89
|
+
return;
|
|
56
90
|
}
|
|
91
|
+
const ttlMs = ttls.get(sessionId) ?? DEFAULT_TTL_SECONDS * 1000;
|
|
92
|
+
sessions.set(sessionId, {
|
|
93
|
+
...session,
|
|
94
|
+
lastActivityAt: now,
|
|
95
|
+
// Sliding expiration: activity extends the session by its TTL, but
|
|
96
|
+
// never past the absolute deadline.
|
|
97
|
+
expiresAt: clampToAbsolute(new Date(now.getTime() + ttlMs), session.absoluteExpiresAt),
|
|
98
|
+
});
|
|
57
99
|
},
|
|
58
100
|
async destroy(sessionId) {
|
|
59
101
|
sessions.delete(sessionId);
|
|
102
|
+
ttls.delete(sessionId);
|
|
60
103
|
},
|
|
61
104
|
async destroyAllForUser(userId) {
|
|
62
105
|
for (const [id, session] of sessions) {
|
|
63
106
|
if (session.userId === userId) {
|
|
64
107
|
sessions.delete(id);
|
|
108
|
+
ttls.delete(id);
|
|
65
109
|
}
|
|
66
110
|
}
|
|
67
111
|
},
|
|
68
112
|
};
|
|
69
113
|
}
|
|
114
|
+
function clampToAbsolute(expiresAt, absolute) {
|
|
115
|
+
if (!absolute)
|
|
116
|
+
return expiresAt;
|
|
117
|
+
return expiresAt.getTime() > absolute.getTime() ? absolute : expiresAt;
|
|
118
|
+
}
|
|
70
119
|
function generateSessionId() {
|
|
71
120
|
return randomBytes(32).toString("hex");
|
|
72
121
|
}
|
|
@@ -6,24 +6,50 @@
|
|
|
6
6
|
* Pure Node.js implementation (no jsonwebtoken dependency).
|
|
7
7
|
* Uses HMAC SHA-256 for signing.
|
|
8
8
|
*/
|
|
9
|
-
import type { UserId } from "@zudojs/constants";
|
|
9
|
+
import type { SessionId, UserId } from "@zudojs/constants";
|
|
10
10
|
import type { JwtToken, TokenPair, TokenConfig, TokenVerificationResult } from "../authTypes/authToken.type.js";
|
|
11
11
|
/**
|
|
12
12
|
* Create a new token pair (access + refresh).
|
|
13
|
+
*
|
|
14
|
+
* @param options.roles - Roles to embed in both tokens.
|
|
15
|
+
* @param options.sessionId - Session to bind the pair to (`sid` claim).
|
|
16
|
+
* `createAuthService()` sets this so that `logout()` invalidates the pair.
|
|
17
|
+
* @throws {AuthConfigurationError} when the signing secrets are missing,
|
|
18
|
+
* shorter than 32 bytes, or identical to each other.
|
|
13
19
|
*/
|
|
14
20
|
export declare function createTokenPair(userId: UserId, config: TokenConfig, options?: {
|
|
15
21
|
readonly roles?: readonly string[];
|
|
22
|
+
readonly sessionId?: SessionId;
|
|
16
23
|
}): TokenPair;
|
|
17
24
|
/**
|
|
18
25
|
* Verify and decode an access token.
|
|
26
|
+
*
|
|
27
|
+
* @throws {AuthConfigurationError} when the signing secrets are invalid.
|
|
28
|
+
* Malformed or untrusted *tokens* never throw — they come back as
|
|
29
|
+
* `{ valid: false, error }`.
|
|
19
30
|
*/
|
|
20
31
|
export declare function verifyAccessToken(token: JwtToken, config: TokenConfig): TokenVerificationResult;
|
|
21
32
|
/**
|
|
22
33
|
* Verify and decode a refresh token.
|
|
34
|
+
*
|
|
35
|
+
* @throws {AuthConfigurationError} when the signing secrets are invalid.
|
|
23
36
|
*/
|
|
24
37
|
export declare function verifyRefreshToken(token: JwtToken, config: TokenConfig): TokenVerificationResult;
|
|
25
38
|
/**
|
|
26
39
|
* Refresh an access token using a valid refresh token.
|
|
40
|
+
*
|
|
41
|
+
* **This is the non-rotating, non-revoking variant.** It checks the refresh
|
|
42
|
+
* token's signature, expiry and type and nothing else: it consults no
|
|
43
|
+
* {@link TokenRevocationStore}, does not revoke the token it consumes, does
|
|
44
|
+
* not re-load the user, and does not check the session. A stolen refresh
|
|
45
|
+
* token therefore stays replayable for its full lifetime (7 days by
|
|
46
|
+
* default) — and so does the token it was already exchanged for.
|
|
47
|
+
*
|
|
48
|
+
* Use `createAuthService().refresh()` instead for anything user-facing: it
|
|
49
|
+
* rotates the refresh token atomically, revokes the used `jti`, re-loads the
|
|
50
|
+
* user so deactivation and role changes take effect, and validates the
|
|
51
|
+
* session. This function exists for callers who manage all of that
|
|
52
|
+
* themselves.
|
|
27
53
|
*/
|
|
28
54
|
export declare function refreshAccessToken(refreshToken: JwtToken, config: TokenConfig, options?: {
|
|
29
55
|
readonly roles?: readonly string[];
|
|
@@ -6,14 +6,21 @@
|
|
|
6
6
|
* Pure Node.js implementation (no jsonwebtoken dependency).
|
|
7
7
|
* Uses HMAC SHA-256 for signing.
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
const DEFAULT_ACCESS_TTL =
|
|
12
|
-
const DEFAULT_REFRESH_TTL =
|
|
9
|
+
import { signToken, verifyToken, generateTokenId, assertTokenSecrets, } from "./authToken.signing.js";
|
|
10
|
+
// TTLs are in seconds — they are added to Unix-second `iat`/`exp` claims.
|
|
11
|
+
const DEFAULT_ACCESS_TTL = 900; // 15 minutes
|
|
12
|
+
const DEFAULT_REFRESH_TTL = 604_800; // 7 days
|
|
13
13
|
/**
|
|
14
14
|
* Create a new token pair (access + refresh).
|
|
15
|
+
*
|
|
16
|
+
* @param options.roles - Roles to embed in both tokens.
|
|
17
|
+
* @param options.sessionId - Session to bind the pair to (`sid` claim).
|
|
18
|
+
* `createAuthService()` sets this so that `logout()` invalidates the pair.
|
|
19
|
+
* @throws {AuthConfigurationError} when the signing secrets are missing,
|
|
20
|
+
* shorter than 32 bytes, or identical to each other.
|
|
15
21
|
*/
|
|
16
22
|
export function createTokenPair(userId, config, options) {
|
|
23
|
+
assertTokenSecrets(config);
|
|
17
24
|
const accessTtl = config.accessTtl ?? DEFAULT_ACCESS_TTL;
|
|
18
25
|
const refreshTtl = config.refreshTtl ?? DEFAULT_REFRESH_TTL;
|
|
19
26
|
const now = Math.floor(Date.now() / 1000);
|
|
@@ -24,6 +31,7 @@ export function createTokenPair(userId, config, options) {
|
|
|
24
31
|
typ: "access",
|
|
25
32
|
jti: generateTokenId(),
|
|
26
33
|
roles: options?.roles,
|
|
34
|
+
...(options?.sessionId ? { sid: options.sessionId } : {}),
|
|
27
35
|
...(config.issuer ? { iss: config.issuer } : {}),
|
|
28
36
|
...(config.audience ? { aud: config.audience } : {}),
|
|
29
37
|
}, config.accessSecret);
|
|
@@ -33,6 +41,8 @@ export function createTokenPair(userId, config, options) {
|
|
|
33
41
|
exp: now + refreshTtl,
|
|
34
42
|
typ: "refresh",
|
|
35
43
|
jti: generateTokenId(),
|
|
44
|
+
roles: options?.roles,
|
|
45
|
+
...(options?.sessionId ? { sid: options.sessionId } : {}),
|
|
36
46
|
...(config.issuer ? { iss: config.issuer } : {}),
|
|
37
47
|
...(config.audience ? { aud: config.audience } : {}),
|
|
38
48
|
}, config.refreshSecret);
|
|
@@ -45,18 +55,39 @@ export function createTokenPair(userId, config, options) {
|
|
|
45
55
|
}
|
|
46
56
|
/**
|
|
47
57
|
* Verify and decode an access token.
|
|
58
|
+
*
|
|
59
|
+
* @throws {AuthConfigurationError} when the signing secrets are invalid.
|
|
60
|
+
* Malformed or untrusted *tokens* never throw — they come back as
|
|
61
|
+
* `{ valid: false, error }`.
|
|
48
62
|
*/
|
|
49
63
|
export function verifyAccessToken(token, config) {
|
|
64
|
+
assertTokenSecrets(config);
|
|
50
65
|
return verifyToken(token, config.accessSecret, "access", config);
|
|
51
66
|
}
|
|
52
67
|
/**
|
|
53
68
|
* Verify and decode a refresh token.
|
|
69
|
+
*
|
|
70
|
+
* @throws {AuthConfigurationError} when the signing secrets are invalid.
|
|
54
71
|
*/
|
|
55
72
|
export function verifyRefreshToken(token, config) {
|
|
73
|
+
assertTokenSecrets(config);
|
|
56
74
|
return verifyToken(token, config.refreshSecret, "refresh", config);
|
|
57
75
|
}
|
|
58
76
|
/**
|
|
59
77
|
* Refresh an access token using a valid refresh token.
|
|
78
|
+
*
|
|
79
|
+
* **This is the non-rotating, non-revoking variant.** It checks the refresh
|
|
80
|
+
* token's signature, expiry and type and nothing else: it consults no
|
|
81
|
+
* {@link TokenRevocationStore}, does not revoke the token it consumes, does
|
|
82
|
+
* not re-load the user, and does not check the session. A stolen refresh
|
|
83
|
+
* token therefore stays replayable for its full lifetime (7 days by
|
|
84
|
+
* default) — and so does the token it was already exchanged for.
|
|
85
|
+
*
|
|
86
|
+
* Use `createAuthService().refresh()` instead for anything user-facing: it
|
|
87
|
+
* rotates the refresh token atomically, revokes the used `jti`, re-loads the
|
|
88
|
+
* user so deactivation and role changes take effect, and validates the
|
|
89
|
+
* session. This function exists for callers who manage all of that
|
|
90
|
+
* themselves.
|
|
60
91
|
*/
|
|
61
92
|
export function refreshAccessToken(refreshToken, config, options) {
|
|
62
93
|
const result = verifyRefreshToken(refreshToken, config);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal base64url + JWT segment helpers.
|
|
3
|
+
*
|
|
4
|
+
* @module authToken/authToken.encoding
|
|
5
|
+
*
|
|
6
|
+
* Not exported from the package barrel. Every JWT segment in this package —
|
|
7
|
+
* verified or not — is decoded through here so that the bounds and the
|
|
8
|
+
* decoding rules stay in exactly one place.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Maximum accepted length, in characters, of a complete JWT.
|
|
12
|
+
*
|
|
13
|
+
* An HS256 JWT with a generous set of claims is well under 2 KB; 8 KB is the
|
|
14
|
+
* conventional HTTP header limit. Anything larger is rejected before it is
|
|
15
|
+
* decoded so that an unauthenticated request cannot make the process
|
|
16
|
+
* allocate and parse an arbitrarily large buffer.
|
|
17
|
+
*/
|
|
18
|
+
export declare const MAX_TOKEN_LENGTH = 8192;
|
|
19
|
+
/**
|
|
20
|
+
* Maximum accepted length, in characters, of the JOSE header segment.
|
|
21
|
+
* A real header (`{"alg":"HS256","typ":"JWT"}`) is 36 characters encoded.
|
|
22
|
+
*/
|
|
23
|
+
export declare const MAX_HEADER_SEGMENT_LENGTH = 1024;
|
|
24
|
+
/** Encode a UTF-8 string as base64url. */
|
|
25
|
+
export declare function base64UrlEncode(data: string): string;
|
|
26
|
+
/** Decode a base64url segment to a UTF-8 string. */
|
|
27
|
+
export declare function base64UrlDecode(data: string): string;
|
|
28
|
+
/**
|
|
29
|
+
* Split a JWT into its three segments, rejecting anything that is not a
|
|
30
|
+
* string, is empty, or exceeds {@link MAX_TOKEN_LENGTH}.
|
|
31
|
+
*
|
|
32
|
+
* @returns The three segments, or `null` when the input is not a
|
|
33
|
+
* plausibly-shaped, in-bounds JWT.
|
|
34
|
+
*/
|
|
35
|
+
export declare function splitToken(token: unknown): readonly [string, string, string] | null;
|
|
36
|
+
/**
|
|
37
|
+
* Decode a JWT body segment into a plain object.
|
|
38
|
+
*
|
|
39
|
+
* @returns The parsed object, or `null` when the segment is not valid JSON
|
|
40
|
+
* or does not decode to a non-null, non-array object.
|
|
41
|
+
*/
|
|
42
|
+
export declare function decodeJsonSegment(segment: string): Record<string, unknown> | null;
|
|
43
|
+
//# sourceMappingURL=authToken.encoding.d.ts.map
|