@azlib/identity 0.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.
Files changed (69) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +387 -0
  3. package/dist/errors-BGMwaW5s.d.mts +481 -0
  4. package/dist/errors-BGMwaW5s.d.mts.map +1 -0
  5. package/dist/errors-Bcjx9o6g.cjs +111 -0
  6. package/dist/errors-C2xZAatu.d.cts +481 -0
  7. package/dist/errors-C2xZAatu.d.cts.map +1 -0
  8. package/dist/errors-CEmnZxIn.mjs +66 -0
  9. package/dist/errors-CEmnZxIn.mjs.map +1 -0
  10. package/dist/express.cjs +405 -0
  11. package/dist/express.d.cts +87 -0
  12. package/dist/express.d.cts.map +1 -0
  13. package/dist/express.d.mts +87 -0
  14. package/dist/express.d.mts.map +1 -0
  15. package/dist/express.mjs +401 -0
  16. package/dist/express.mjs.map +1 -0
  17. package/dist/identity-4eP45YIP.cjs +461 -0
  18. package/dist/identity-Bz9RDOvT.mjs +410 -0
  19. package/dist/identity-Bz9RDOvT.mjs.map +1 -0
  20. package/dist/identity-router-DBL20UWT.d.mts +115 -0
  21. package/dist/identity-router-DBL20UWT.d.mts.map +1 -0
  22. package/dist/identity-router-Dib30Waj.d.cts +115 -0
  23. package/dist/identity-router-Dib30Waj.d.cts.map +1 -0
  24. package/dist/identity-service-B9zrvE9z.d.mts +128 -0
  25. package/dist/identity-service-B9zrvE9z.d.mts.map +1 -0
  26. package/dist/identity-service-CLzKx8Z7.d.cts +128 -0
  27. package/dist/identity-service-CLzKx8Z7.d.cts.map +1 -0
  28. package/dist/identity-store-BRRahxcS.d.cts +272 -0
  29. package/dist/identity-store-BRRahxcS.d.cts.map +1 -0
  30. package/dist/identity-store-BRRahxcS.d.mts +272 -0
  31. package/dist/identity-store-BRRahxcS.d.mts.map +1 -0
  32. package/dist/index-CpufYgyn.d.cts +30 -0
  33. package/dist/index-CpufYgyn.d.cts.map +1 -0
  34. package/dist/index-CpufYgyn.d.mts +30 -0
  35. package/dist/index-CpufYgyn.d.mts.map +1 -0
  36. package/dist/index.cjs +18 -0
  37. package/dist/index.d.cts +4 -0
  38. package/dist/index.d.mts +4 -0
  39. package/dist/index.mjs +3 -0
  40. package/dist/logger-Be1wDzBC.cjs +48 -0
  41. package/dist/logger-CcCHJVVe.mjs +33 -0
  42. package/dist/logger-CcCHJVVe.mjs.map +1 -0
  43. package/dist/nestjs.cjs +516 -0
  44. package/dist/nestjs.d.cts +102 -0
  45. package/dist/nestjs.d.cts.map +1 -0
  46. package/dist/nestjs.d.mts +102 -0
  47. package/dist/nestjs.d.mts.map +1 -0
  48. package/dist/nestjs.mjs +500 -0
  49. package/dist/nestjs.mjs.map +1 -0
  50. package/dist/node.cjs +946 -0
  51. package/dist/node.d.cts +260 -0
  52. package/dist/node.d.cts.map +1 -0
  53. package/dist/node.d.mts +260 -0
  54. package/dist/node.d.mts.map +1 -0
  55. package/dist/node.mjs +890 -0
  56. package/dist/node.mjs.map +1 -0
  57. package/dist/test-utils.cjs +169 -0
  58. package/dist/test-utils.d.cts +12 -0
  59. package/dist/test-utils.d.cts.map +1 -0
  60. package/dist/test-utils.d.mts +12 -0
  61. package/dist/test-utils.d.mts.map +1 -0
  62. package/dist/test-utils.mjs +170 -0
  63. package/dist/test-utils.mjs.map +1 -0
  64. package/package.json +92 -0
  65. package/schema/model.ts +100 -0
  66. package/schema/mysql.sql +102 -0
  67. package/schema/postgres.sql +92 -0
  68. package/schema/prisma.schema +122 -0
  69. package/schema/sqlite.sql +92 -0
package/dist/node.mjs ADDED
@@ -0,0 +1,890 @@
1
+ import { a as IdentityError, c as UnauthenticatedError, i as IdentityConfigError, n as EmailAlreadyRegisteredError, o as InvalidCredentialsError, r as ForbiddenError, s as InvalidTokenError, t as AccountUnavailableError } from "./errors-CEmnZxIn.mjs";
2
+ import { n as noopLogger, r as resolveLogger, t as consoleLogger } from "./logger-CcCHJVVe.mjs";
3
+ import { a as hydratePrincipal, c as isAuthorized, i as createOAuthService, l as resolveIdentityConfig, n as OAuthProviderNotFoundError, o as issueSession, r as OAuthStateMismatchError, s as evaluateAuthorization, t as identitySchemaModel } from "./identity-Bz9RDOvT.mjs";
4
+ import { createHash, createHmac, randomBytes, scrypt, timingSafeEqual } from "node:crypto";
5
+ import { SignJWT, jwtVerify } from "jose";
6
+ //#region core/password-hasher.ts
7
+ /**
8
+ * Password hashing using Node's scrypt. Hashes are self-describing:
9
+ * `scrypt$<N>$<saltHex>$<derivedKeyHex>` so parameters can evolve without breaking
10
+ * existing records.
11
+ */
12
+ const KEY_LENGTH = 64;
13
+ const scryptAsync = (password, salt, cost) => new Promise((resolve, reject) => {
14
+ scrypt(password, salt, KEY_LENGTH, {
15
+ N: cost,
16
+ maxmem: 256 * cost * 8
17
+ }, (err, derived) => {
18
+ if (err) reject(err);
19
+ else resolve(derived);
20
+ });
21
+ });
22
+ /** Creates a scrypt-based {@link PasswordHasher}. */
23
+ function createPasswordHasher(cost) {
24
+ return {
25
+ async hash(password) {
26
+ const salt = randomBytes(16);
27
+ const derived = await scryptAsync(password, salt, cost);
28
+ return `scrypt$${cost}$${salt.toString("hex")}$${derived.toString("hex")}`;
29
+ },
30
+ async verify(password, storedHash) {
31
+ const parts = storedHash.split("$");
32
+ if (parts.length !== 4 || parts[0] !== "scrypt") return false;
33
+ const cost = Number.parseInt(parts[1], 10);
34
+ const salt = Buffer.from(parts[2], "hex");
35
+ const expected = Buffer.from(parts[3], "hex");
36
+ if (!Number.isInteger(cost) || salt.length === 0 || expected.length === 0) return false;
37
+ const actual = await scryptAsync(password, salt, cost);
38
+ if (actual.length !== expected.length) return false;
39
+ return timingSafeEqual(actual, expected);
40
+ }
41
+ };
42
+ }
43
+ //#endregion
44
+ //#region core/token-service.ts
45
+ const hashToken = (token) => createHash("sha256").update(token).digest("hex");
46
+ function createTokenService(config) {
47
+ const secret = new TextEncoder().encode(config.accessTokenSecret);
48
+ return {
49
+ async issueAccessToken(userId, authVersion) {
50
+ const issuedAt = Math.floor(config.now().getTime() / 1e3);
51
+ const expiresAtSeconds = issuedAt + config.accessTokenTtlSeconds;
52
+ const builder = new SignJWT({ authVersion }).setProtectedHeader({ alg: "HS256" }).setSubject(userId).setIssuedAt(issuedAt).setIssuer(config.issuer).setExpirationTime(expiresAtSeconds);
53
+ if (config.audience) builder.setAudience(config.audience);
54
+ return {
55
+ token: await builder.sign(secret),
56
+ expiresAt: /* @__PURE__ */ new Date(expiresAtSeconds * 1e3)
57
+ };
58
+ },
59
+ async verifyAccessToken(token) {
60
+ try {
61
+ const { payload } = await jwtVerify(token, secret, {
62
+ issuer: config.issuer,
63
+ audience: config.audience
64
+ });
65
+ if (typeof payload.sub !== "string" || typeof payload.authVersion !== "number") throw new InvalidTokenError();
66
+ return {
67
+ sub: payload.sub,
68
+ authVersion: payload.authVersion,
69
+ iat: payload.iat ?? 0,
70
+ exp: payload.exp ?? 0
71
+ };
72
+ } catch (error) {
73
+ if (error instanceof InvalidTokenError) throw error;
74
+ throw new InvalidTokenError();
75
+ }
76
+ },
77
+ createRefreshToken(sessionId) {
78
+ const token = `${sessionId}.${randomBytes(32).toString("base64url")}`;
79
+ return {
80
+ token,
81
+ hash: hashToken(token)
82
+ };
83
+ },
84
+ parseSessionId(token) {
85
+ const separator = token.indexOf(".");
86
+ if (separator <= 0) return null;
87
+ return token.slice(0, separator);
88
+ },
89
+ hashRefreshToken(token) {
90
+ return hashToken(token);
91
+ }
92
+ };
93
+ }
94
+ //#endregion
95
+ //#region core/audit.ts
96
+ function createAuditLogger(store, now, onEvent) {
97
+ return { async record(type, userId, metadata = {}) {
98
+ const event = {
99
+ type,
100
+ userId,
101
+ metadata,
102
+ occurredAt: now()
103
+ };
104
+ try {
105
+ await store.recordEvent?.(event);
106
+ } catch {}
107
+ try {
108
+ await onEvent?.(event);
109
+ } catch {}
110
+ } };
111
+ }
112
+ //#endregion
113
+ //#region core/register.ts
114
+ /** Raised when registration input fails validation. */
115
+ var RegistrationValidationError = class extends IdentityError {
116
+ constructor(message) {
117
+ super("identity/registration-invalid", message, 400);
118
+ }
119
+ };
120
+ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
121
+ const MIN_PASSWORD_LENGTH$1 = 8;
122
+ /** Normalizes an email for storage and lookup (trim + lowercase). */
123
+ function normalizeEmail(email) {
124
+ return email.trim().toLowerCase();
125
+ }
126
+ /**
127
+ * Registers a new user: validates input, enforces unique email, hashes the password,
128
+ * persists the user and credential, and returns an authenticated session.
129
+ */
130
+ async function registerUser(deps, input) {
131
+ const { config, store, hasher, audit } = deps;
132
+ const email = normalizeEmail(input.email);
133
+ if (!EMAIL_PATTERN.test(email)) throw new RegistrationValidationError("A valid email address is required.");
134
+ if (input.password.length < MIN_PASSWORD_LENGTH$1) throw new RegistrationValidationError(`Password must be at least ${MIN_PASSWORD_LENGTH$1} characters.`);
135
+ if (await store.findUserByEmail(email)) throw new EmailAlreadyRegisteredError();
136
+ const now = config.now();
137
+ const user = await store.createUser({
138
+ userId: config.generateId(),
139
+ email,
140
+ displayName: input.displayName ?? null,
141
+ status: "active",
142
+ authVersion: 0,
143
+ createdAt: now,
144
+ updatedAt: now
145
+ });
146
+ const passwordHash = await hasher.hash(input.password);
147
+ await store.upsertCredential({
148
+ userId: user.userId,
149
+ passwordHash,
150
+ updatedAt: now
151
+ });
152
+ await audit.record("user.registered", user.userId, { email });
153
+ return issueSession(deps, user);
154
+ }
155
+ //#endregion
156
+ //#region core/lockout.ts
157
+ /**
158
+ * Checks whether the user account is currently locked. Throws {@link AccountUnavailableError}
159
+ * when the lockout is active (i.e. `lockedUntil` is in the future).
160
+ *
161
+ * Note: if `lockedUntil` is in the past the lock has expired — the user may proceed and
162
+ * the stale lock will be cleared on successful login.
163
+ */
164
+ function checkLockout(user, config, now) {
165
+ if (config.maxFailedAttempts === 0) return;
166
+ if (user.lockedUntil !== null && user.lockedUntil > now()) throw new AccountUnavailableError();
167
+ }
168
+ /**
169
+ * Records a failed login attempt. When the threshold is reached the account is locked
170
+ * for `config.durationSeconds` seconds (or permanently when `durationSeconds` is 0).
171
+ */
172
+ async function recordFailedAttempt(store, user, config, now) {
173
+ if (config.maxFailedAttempts === 0) return;
174
+ const attempts = user.failedLoginAttempts + 1;
175
+ const exceeded = attempts >= config.maxFailedAttempts;
176
+ const lockedUntil = exceeded ? config.durationSeconds > 0 ? new Date(now().getTime() + config.durationSeconds * 1e3) : /* @__PURE__ */ new Date(864e13) : null;
177
+ const patch = {
178
+ failedLoginAttempts: attempts,
179
+ ...exceeded ? {
180
+ status: "locked",
181
+ lockedUntil
182
+ } : {}
183
+ };
184
+ await store.updateUser(user.userId, patch);
185
+ }
186
+ /**
187
+ * Clears lockout state after a successful login. Resets the failure counter and any
188
+ * active lockout so the account returns to `active` status.
189
+ */
190
+ async function clearLockout(store, user) {
191
+ if (user.failedLoginAttempts === 0 && user.lockedUntil === null) return;
192
+ await store.updateUser(user.userId, {
193
+ failedLoginAttempts: 0,
194
+ lockedUntil: null,
195
+ ...user.status === "locked" ? { status: "active" } : {}
196
+ });
197
+ }
198
+ //#endregion
199
+ //#region core/totp.ts
200
+ const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
201
+ /** Encodes a Buffer to a base32 string (no padding). */
202
+ function base32Encode(buffer) {
203
+ let result = "";
204
+ let bits = 0;
205
+ let accumulator = 0;
206
+ for (const byte of buffer) {
207
+ accumulator = accumulator << 8 | byte;
208
+ bits += 8;
209
+ while (bits >= 5) {
210
+ bits -= 5;
211
+ result += BASE32_ALPHABET[accumulator >> bits & 31] ?? "";
212
+ }
213
+ }
214
+ if (bits > 0) result += BASE32_ALPHABET[accumulator << 5 - bits & 31] ?? "";
215
+ return result;
216
+ }
217
+ /** Decodes a base32 string (case-insensitive, padding stripped) to a Buffer. */
218
+ function base32Decode(encoded) {
219
+ const str = encoded.toUpperCase().replace(/=+$/, "");
220
+ const bytes = [];
221
+ let bits = 0;
222
+ let accumulator = 0;
223
+ for (const char of str) {
224
+ const value = BASE32_ALPHABET.indexOf(char);
225
+ if (value === -1) continue;
226
+ accumulator = accumulator << 5 | value;
227
+ bits += 5;
228
+ if (bits >= 8) {
229
+ bits -= 8;
230
+ bytes.push(accumulator >> bits & 255);
231
+ }
232
+ }
233
+ return Buffer.from(bytes);
234
+ }
235
+ const TOTP_STEP_SECONDS = 30;
236
+ const TOTP_DIGITS = 6;
237
+ /**
238
+ * Computes an HOTP value for the given base32 key and integer counter (RFC 4226).
239
+ * Returns a zero-padded 6-digit string.
240
+ */
241
+ function hotp(secretBase32, counter) {
242
+ const key = base32Decode(secretBase32);
243
+ const counterBuf = Buffer.alloc(8);
244
+ counterBuf.writeBigUInt64BE(counter);
245
+ const hmac = createHmac("sha1", key).update(counterBuf).digest();
246
+ const offset = hmac[19] & 15;
247
+ return (((hmac[offset] & 127) << 24 | (hmac[offset + 1] & 255) << 16 | (hmac[offset + 2] & 255) << 8 | hmac[offset + 3] & 255) % Math.pow(10, TOTP_DIGITS)).toString().padStart(TOTP_DIGITS, "0");
248
+ }
249
+ /**
250
+ * Generates a 160-bit random TOTP secret and returns it base32-encoded.
251
+ * 160 bits (20 bytes) aligns with the SHA-1 block size and is standard for TOTP.
252
+ */
253
+ function generateTotpSecret() {
254
+ return base32Encode(randomBytes(20));
255
+ }
256
+ /**
257
+ * Generates the current TOTP code for the given base32 secret (RFC 6238).
258
+ */
259
+ function generateTotp(secretBase32, now = /* @__PURE__ */ new Date()) {
260
+ return hotp(secretBase32, BigInt(Math.floor(now.getTime() / 1e3 / TOTP_STEP_SECONDS)));
261
+ }
262
+ /**
263
+ * Verifies a 6-digit TOTP code.
264
+ * Accepts codes from the previous, current, or next time step (±1 window) to
265
+ * tolerate clock skew between server and authenticator app.
266
+ */
267
+ function verifyTotp(code, secretBase32, now = /* @__PURE__ */ new Date()) {
268
+ if (code.length !== TOTP_DIGITS) return false;
269
+ const counter = BigInt(Math.floor(now.getTime() / 1e3 / TOTP_STEP_SECONDS));
270
+ return [
271
+ -1n,
272
+ 0n,
273
+ 1n
274
+ ].some((delta) => hotp(secretBase32, counter + delta) === code);
275
+ }
276
+ /**
277
+ * Builds an `otpauth://totp/` URI suitable for QR code scanning in authenticator apps
278
+ * (Google Authenticator, Authy, 1Password, etc.).
279
+ */
280
+ function buildOtpAuthUri(params) {
281
+ const enc = encodeURIComponent;
282
+ return `otpauth://totp/${enc(params.issuer)}:${enc(params.accountName)}?secret=${params.secret}&issuer=${enc(params.issuer)}&algorithm=SHA1&digits=${TOTP_DIGITS}&period=${TOTP_STEP_SECONDS}`;
283
+ }
284
+ //#endregion
285
+ //#region core/two-factor.ts
286
+ /** Raised when a TOTP code is absent or incorrect. */
287
+ var TwoFactorError = class extends IdentityError {
288
+ constructor(message = "Invalid two-factor authentication code.") {
289
+ super("identity/2fa-invalid", message, 401);
290
+ }
291
+ };
292
+ /**
293
+ * Raised during login when the user has 2FA enabled and the TOTP challenge has not yet
294
+ * been completed. HTTP 403 signals "authenticated but additional factor required".
295
+ */
296
+ var TwoFactorRequiredError = class extends IdentityError {
297
+ constructor() {
298
+ super("identity/2fa-required", "Two-factor authentication is required to complete sign-in.", 403);
299
+ }
300
+ };
301
+ /**
302
+ * Begins the 2FA setup flow. Generates a new TOTP secret (stored as pending, not yet
303
+ * active) and returns the secret + QR URI for display in an authenticator app.
304
+ *
305
+ * The caller must then call {@link enable2FA} with a valid TOTP code to activate 2FA.
306
+ */
307
+ async function setup2FA(deps, userId) {
308
+ const { config, store, audit } = deps;
309
+ const user = await store.findUserById(userId);
310
+ if (!user) throw new TwoFactorError("User not found.");
311
+ const secretBase32 = generateTotpSecret();
312
+ const now = config.now();
313
+ await store.upsertTotpSecret?.({
314
+ userId,
315
+ secretBase32,
316
+ enabledAt: null,
317
+ createdAt: now
318
+ });
319
+ await audit.record("user.2fa.setup.started", userId, {});
320
+ return {
321
+ secret: secretBase32,
322
+ otpAuthUri: buildOtpAuthUri({
323
+ secret: secretBase32,
324
+ accountName: user.email,
325
+ issuer: config.issuer
326
+ })
327
+ };
328
+ }
329
+ /**
330
+ * Confirms and enables 2FA. A valid TOTP code from the authenticator app is required.
331
+ * Idempotent when 2FA is already enabled for the user.
332
+ */
333
+ async function enable2FA(deps, userId, totpCode) {
334
+ const { config, store, audit } = deps;
335
+ const record = await store.findTotpSecret?.(userId);
336
+ if (!record) throw new TwoFactorError("2FA setup has not been started. Call setup2FA first.");
337
+ if (record.enabledAt) return;
338
+ if (!verifyTotp(totpCode, record.secretBase32, config.now())) throw new TwoFactorError();
339
+ await store.upsertTotpSecret?.({
340
+ ...record,
341
+ enabledAt: config.now()
342
+ });
343
+ await store.updateUser(userId, { twoFactorEnabled: true });
344
+ await audit.record("user.2fa.enabled", userId, {});
345
+ }
346
+ /**
347
+ * Disables 2FA for the user. Requires a valid TOTP code as confirmation.
348
+ * After disabling, login flows no longer issue MFA challenges for the account.
349
+ */
350
+ async function disable2FA(deps, userId, totpCode) {
351
+ const { config, store, audit } = deps;
352
+ const record = await store.findTotpSecret?.(userId);
353
+ if (!record || !record.enabledAt) throw new TwoFactorError("2FA is not enabled for this account.");
354
+ if (!verifyTotp(totpCode, record.secretBase32, config.now())) throw new TwoFactorError();
355
+ await store.deleteTotpSecret?.(userId);
356
+ await store.updateUser(userId, { twoFactorEnabled: false });
357
+ await audit.record("user.2fa.disabled", userId, {});
358
+ }
359
+ const MFA_CHALLENGE_TTL_SECONDS = 300;
360
+ const MFA_CHALLENGE_TYPE = "mfa_challenge";
361
+ /**
362
+ * Issues a short-lived signed JWT that encodes a pending MFA session.
363
+ * The client must present this token together with a TOTP code to complete login.
364
+ */
365
+ async function issueMfaChallengeToken(config, userId) {
366
+ const secret = new TextEncoder().encode(config.accessTokenSecret);
367
+ const now = Math.floor(config.now().getTime() / 1e3);
368
+ return new SignJWT({ type: MFA_CHALLENGE_TYPE }).setProtectedHeader({ alg: "HS256" }).setSubject(userId).setIssuedAt(now).setExpirationTime(now + MFA_CHALLENGE_TTL_SECONDS).sign(secret);
369
+ }
370
+ /**
371
+ * Verifies an MFA challenge token and TOTP code, then issues a full auth session.
372
+ *
373
+ * Throws {@link InvalidTokenError} for an expired/invalid challenge token and
374
+ * {@link TwoFactorError} for a wrong TOTP code.
375
+ */
376
+ async function verifyMfaChallenge(deps, mfaToken, totpCode) {
377
+ const { config, store, audit } = deps;
378
+ const secret = new TextEncoder().encode(config.accessTokenSecret);
379
+ let userId;
380
+ try {
381
+ const { payload } = await jwtVerify(mfaToken, secret);
382
+ if (payload["type"] !== MFA_CHALLENGE_TYPE || typeof payload.sub !== "string") throw new InvalidTokenError("Invalid MFA challenge token.");
383
+ userId = payload.sub;
384
+ } catch {
385
+ throw new InvalidTokenError("MFA challenge token is invalid or expired.");
386
+ }
387
+ const record = await store.findTotpSecret?.(userId);
388
+ if (!record || !record.enabledAt) throw new TwoFactorError("2FA is not configured for this account.");
389
+ if (!verifyTotp(totpCode, record.secretBase32, config.now())) {
390
+ await audit.record("user.login.failed", userId, { reason: "invalid_totp" });
391
+ throw new TwoFactorError();
392
+ }
393
+ const user = await store.findUserById(userId);
394
+ if (!user) throw new InvalidTokenError();
395
+ await audit.record("user.login.succeeded", userId, { via: "mfa" });
396
+ return issueSession(deps, user);
397
+ }
398
+ //#endregion
399
+ //#region core/login.ts
400
+ /**
401
+ * Authenticates a user with email + password. Uses a single generic failure for both
402
+ * "unknown user" and "wrong password" to avoid account enumeration, and performs a hash
403
+ * comparison even when the user is unknown to reduce timing side-channels.
404
+ *
405
+ * When account lockout is enabled (config.lockout.maxFailedAttempts > 0), consecutive
406
+ * failures increment a counter and temporarily lock the account.
407
+ */
408
+ async function loginUser(deps, input) {
409
+ const { config, store, hasher, audit } = deps;
410
+ const email = normalizeEmail(input.email);
411
+ const user = await store.findUserByEmail(email);
412
+ const credential = user ? await store.findCredential(user.userId) : null;
413
+ const storedHash = credential?.passwordHash ?? "scrypt$16384$00$00";
414
+ const passwordMatches = await hasher.verify(input.password, storedHash);
415
+ if (!user || !credential || !passwordMatches) {
416
+ if (user) {
417
+ await recordFailedAttempt(store, user, config.lockout, config.now);
418
+ if (user.failedLoginAttempts + 1 >= config.lockout.maxFailedAttempts && config.lockout.maxFailedAttempts > 0) await audit.record("user.account.locked", user.userId, {
419
+ email,
420
+ reason: "too_many_failures"
421
+ });
422
+ }
423
+ await audit.record("user.login.failed", user?.userId ?? null, { email });
424
+ throw new InvalidCredentialsError();
425
+ }
426
+ checkLockout(user, config.lockout, config.now);
427
+ if (user.status === "disabled" || user.status === "locked" && user.lockedUntil === null) {
428
+ await audit.record("user.login.failed", user.userId, {
429
+ email,
430
+ status: user.status
431
+ });
432
+ throw new AccountUnavailableError();
433
+ }
434
+ await clearLockout(store, user);
435
+ if (user.twoFactorEnabled) {
436
+ await audit.record("user.login.mfa.challenged", user.userId, { email });
437
+ return {
438
+ kind: "mfa_required",
439
+ mfaToken: await issueMfaChallengeToken(config, user.userId)
440
+ };
441
+ }
442
+ await audit.record("user.login.succeeded", user.userId, { email });
443
+ return issueSession(deps, user);
444
+ }
445
+ //#endregion
446
+ //#region core/refresh-service.ts
447
+ /**
448
+ * Rotates a refresh session: validates the presented refresh token against the stored
449
+ * hash, issues a new access token, and replaces the stored refresh-token hash.
450
+ *
451
+ * Detects refresh-token reuse (a token whose session exists but whose hash no longer
452
+ * matches) and revokes the session as a precaution.
453
+ */
454
+ async function refreshSession(deps, presentedToken) {
455
+ const { config, store, tokenService, audit } = deps;
456
+ const sessionId = tokenService.parseSessionId(presentedToken);
457
+ if (!sessionId) throw new InvalidTokenError();
458
+ const session = await store.findSessionById(sessionId);
459
+ const now = config.now();
460
+ if (!session || session.revokedAt || session.expiresAt.getTime() <= now.getTime()) throw new InvalidTokenError();
461
+ if (tokenService.hashRefreshToken(presentedToken) !== session.refreshTokenHash) {
462
+ await store.revokeSession(sessionId, now);
463
+ await audit.record("session.revoked", session.userId, { reason: "refresh-reuse" });
464
+ throw new InvalidTokenError();
465
+ }
466
+ const user = await store.findUserById(session.userId);
467
+ if (!user) throw new InvalidTokenError();
468
+ if (user.status !== "active") {
469
+ await store.revokeSession(sessionId, now);
470
+ throw new AccountUnavailableError();
471
+ }
472
+ const principal = await hydratePrincipal(store, user);
473
+ const access = await tokenService.issueAccessToken(user.userId, user.authVersion);
474
+ const refresh = tokenService.createRefreshToken(sessionId);
475
+ const refreshExpiresAt = new Date(now.getTime() + config.refreshTokenTtlSeconds * 1e3);
476
+ await store.rotateSession(sessionId, refresh.hash, refreshExpiresAt);
477
+ await audit.record("session.refreshed", user.userId, {});
478
+ return {
479
+ user: principal,
480
+ tokens: {
481
+ accessToken: access.token,
482
+ refreshToken: refresh.token,
483
+ accessTokenExpiresAt: access.expiresAt,
484
+ refreshTokenExpiresAt: refreshExpiresAt
485
+ }
486
+ };
487
+ }
488
+ /** Revokes a single session (logout). */
489
+ async function revokeSession(deps, presentedToken) {
490
+ const { config, store, tokenService, audit } = deps;
491
+ const sessionId = tokenService.parseSessionId(presentedToken);
492
+ if (!sessionId) return;
493
+ const session = await store.findSessionById(sessionId);
494
+ if (!session || session.revokedAt) return;
495
+ await store.revokeSession(sessionId, config.now());
496
+ await audit.record("session.revoked", session.userId, { reason: "logout" });
497
+ }
498
+ //#endregion
499
+ //#region core/verification-token.ts
500
+ /**
501
+ * Generates a cryptographically random opaque token and its SHA-256 hash.
502
+ * Returns both: the raw token is sent to the user; only the hash is persisted.
503
+ */
504
+ function generateVerificationToken() {
505
+ const token = randomBytes(32).toString("hex");
506
+ return {
507
+ token,
508
+ tokenHash: createHash("sha256").update(token).digest("hex")
509
+ };
510
+ }
511
+ /** Hashes a token presented by the user for secure lookup against stored hashes. */
512
+ function hashVerificationToken(token) {
513
+ return createHash("sha256").update(token).digest("hex");
514
+ }
515
+ //#endregion
516
+ //#region core/email-verification.ts
517
+ /** Default token lifetime for email verification links. */
518
+ const DEFAULT_TTL_SECONDS$1 = 1440 * 60;
519
+ /** Raised when an email verification token is missing, expired, or already used. */
520
+ var EmailVerificationError = class extends IdentityError {
521
+ constructor(message = "Email verification token is invalid or expired.") {
522
+ super("identity/email-verification-invalid", message, 400);
523
+ }
524
+ };
525
+ /**
526
+ * Generates and persists a verification token, then calls the notification service.
527
+ *
528
+ * Safe to call after registration or when the user explicitly requests a resend.
529
+ * Returns silently when the email is already verified.
530
+ */
531
+ async function requestEmailVerification(deps, userId) {
532
+ const { config, store, audit } = deps;
533
+ const notifications = config.notifications;
534
+ const user = await store.findUserById(userId);
535
+ if (!user) throw new EmailVerificationError("User not found.");
536
+ if (user.emailVerifiedAt) return;
537
+ const { token, tokenHash } = generateVerificationToken();
538
+ const now = config.now();
539
+ const expiresAt = new Date(now.getTime() + DEFAULT_TTL_SECONDS$1 * 1e3);
540
+ await store.createVerificationToken?.({
541
+ tokenHash,
542
+ userId,
543
+ type: "email_verification",
544
+ expiresAt
545
+ });
546
+ await notifications?.sendEmailVerification?.({
547
+ email: user.email,
548
+ displayName: user.displayName,
549
+ token
550
+ });
551
+ await audit.record("user.email.verification.requested", userId, { email: user.email });
552
+ }
553
+ /**
554
+ * Verifies the user's email address using the token from the verification link.
555
+ *
556
+ * Throws {@link EmailVerificationError} for unknown, expired, or already-used tokens.
557
+ */
558
+ async function verifyEmail(deps, rawToken) {
559
+ const { config, store, audit } = deps;
560
+ const tokenHash = hashVerificationToken(rawToken);
561
+ const record = await store.findVerificationToken?.(tokenHash);
562
+ if (!record || record.type !== "email_verification") throw new EmailVerificationError();
563
+ if (record.usedAt) throw new EmailVerificationError("This verification link has already been used.");
564
+ const now = config.now();
565
+ if (record.expiresAt <= now) throw new EmailVerificationError("This verification link has expired.");
566
+ await store.markVerificationTokenUsed?.(tokenHash, now);
567
+ await store.updateUser(record.userId, { emailVerifiedAt: now });
568
+ await audit.record("user.email.verified", record.userId, {});
569
+ }
570
+ //#endregion
571
+ //#region core/password-reset.ts
572
+ /** Default token lifetime for password reset links. */
573
+ const DEFAULT_TTL_SECONDS = 3600;
574
+ const MIN_PASSWORD_LENGTH = 8;
575
+ /** Raised when a password reset token is missing, expired, or already used. */
576
+ var PasswordResetError = class extends IdentityError {
577
+ constructor(message = "Password reset token is invalid or expired.") {
578
+ super("identity/password-reset-invalid", message, 400);
579
+ }
580
+ };
581
+ /**
582
+ * Generates a password reset token and dispatches the notification.
583
+ *
584
+ * Never reveals whether the email address exists in the system (returns silently for
585
+ * unknown addresses) to prevent account enumeration.
586
+ */
587
+ async function requestPasswordReset(deps, email) {
588
+ const { config, store, notifications, audit } = deps;
589
+ const normalized = normalizeEmail(email);
590
+ const user = await store.findUserByEmail(normalized);
591
+ if (!user) return;
592
+ const { token, tokenHash } = generateVerificationToken();
593
+ const now = config.now();
594
+ const expiresAt = new Date(now.getTime() + DEFAULT_TTL_SECONDS * 1e3);
595
+ await store.createVerificationToken?.({
596
+ tokenHash,
597
+ userId: user.userId,
598
+ type: "password_reset",
599
+ expiresAt
600
+ });
601
+ await (notifications ?? config.notifications)?.sendPasswordReset?.({
602
+ email: user.email,
603
+ displayName: user.displayName,
604
+ token
605
+ });
606
+ await audit.record("user.password.reset.requested", user.userId, { email: user.email });
607
+ }
608
+ /**
609
+ * Validates the reset token and sets a new password.
610
+ *
611
+ * On success, bumps `authVersion` and revokes all existing sessions so outstanding
612
+ * refresh tokens are immediately invalidated.
613
+ */
614
+ async function resetPassword(deps, rawToken, newPassword) {
615
+ const { config, store, hasher, audit } = deps;
616
+ if (newPassword.length < MIN_PASSWORD_LENGTH) throw new PasswordResetError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters.`);
617
+ const tokenHash = hashVerificationToken(rawToken);
618
+ const record = await store.findVerificationToken?.(tokenHash);
619
+ if (!record || record.type !== "password_reset") throw new PasswordResetError();
620
+ if (record.usedAt) throw new PasswordResetError("This reset link has already been used.");
621
+ const now = config.now();
622
+ if (record.expiresAt <= now) throw new PasswordResetError("This reset link has expired.");
623
+ await store.markVerificationTokenUsed?.(tokenHash, now);
624
+ const passwordHash = await hasher.hash(newPassword);
625
+ await store.upsertCredential({
626
+ userId: record.userId,
627
+ passwordHash,
628
+ updatedAt: now
629
+ });
630
+ const user = await store.findUserById(record.userId);
631
+ if (user) {
632
+ await store.updateUser(record.userId, { authVersion: user.authVersion + 1 });
633
+ await store.revokeAllSessions(record.userId, now);
634
+ }
635
+ await audit.record("user.password.reset.completed", record.userId, {});
636
+ }
637
+ //#endregion
638
+ //#region core/account-admin.ts
639
+ /** Raised when the target user does not exist. */
640
+ var AccountAdminError = class extends IdentityError {
641
+ constructor(message) {
642
+ super("identity/account-not-found", message, 404);
643
+ }
644
+ };
645
+ /**
646
+ * Locks a user account. Sets `status` to `"locked"` and immediately revokes all active
647
+ * sessions so existing refresh tokens stop working.
648
+ *
649
+ * Typically called by an admin. The `AccountUnavailableError` is thrown during any
650
+ * subsequent login or token verification attempt for the account.
651
+ */
652
+ async function lockAccount(deps, userId) {
653
+ const { config, store, audit } = deps;
654
+ if (!await store.findUserById(userId)) throw new AccountAdminError(`User ${userId} not found.`);
655
+ const now = config.now();
656
+ await store.updateUser(userId, { status: "locked" });
657
+ await store.revokeAllSessions(userId, now);
658
+ await audit.record("user.account.locked", userId, { reason: "admin" });
659
+ }
660
+ /**
661
+ * Unlocks a user account. Resets `status` to `"active"` and clears any stale lockout
662
+ * counters so the user can log in immediately.
663
+ */
664
+ async function unlockAccount(deps, userId) {
665
+ const { config, store, audit } = deps;
666
+ if (!await store.findUserById(userId)) throw new AccountAdminError(`User ${userId} not found.`);
667
+ await store.updateUser(userId, {
668
+ status: "active",
669
+ failedLoginAttempts: 0,
670
+ lockedUntil: null
671
+ });
672
+ await audit.record("user.account.unlocked", userId, {});
673
+ }
674
+ /**
675
+ * Disables a user account. Sets `status` to `"disabled"` and revokes all sessions.
676
+ * Unlike `lockAccount`, disabled accounts are not automatically unlockable by the
677
+ * lockout timer — an explicit call to `unlockAccount` is required.
678
+ */
679
+ async function disableAccount(deps, userId) {
680
+ const { config, store, audit } = deps;
681
+ if (!await store.findUserById(userId)) throw new AccountAdminError(`User ${userId} not found.`);
682
+ const now = config.now();
683
+ await store.updateUser(userId, { status: "disabled" });
684
+ await store.revokeAllSessions(userId, now);
685
+ await audit.record("user.account.locked", userId, { reason: "admin_disabled" });
686
+ }
687
+ //#endregion
688
+ //#region core/identity-service.ts
689
+ /**
690
+ * Wires the framework-agnostic identity runtime with Node defaults (scrypt hashing,
691
+ * `jose` HS256 tokens). Express and NestJS adapters build on top of this service.
692
+ */
693
+ function createIdentityService(configInput, store) {
694
+ const config = resolveIdentityConfig(configInput);
695
+ const tokenService = createTokenService(config);
696
+ const hasher = createPasswordHasher(config.passwordScryptCost);
697
+ const audit = createAuditLogger(store, config.now, config.onEvent);
698
+ const deps = {
699
+ config,
700
+ store,
701
+ tokenService,
702
+ hasher,
703
+ audit
704
+ };
705
+ const mfaDeps = deps;
706
+ const emailDeps = {
707
+ config,
708
+ store,
709
+ audit
710
+ };
711
+ const oauthProviders = configInput.oauth?.providers;
712
+ return {
713
+ register: (input) => registerUser(deps, input),
714
+ login: (input) => loginUser(deps, input),
715
+ refresh: (refreshToken) => refreshSession(deps, refreshToken),
716
+ logout: (refreshToken) => revokeSession(deps, refreshToken),
717
+ async authenticate(accessToken) {
718
+ const claims = await tokenService.verifyAccessToken(accessToken);
719
+ const user = await store.findUserById(claims.sub);
720
+ if (!user || user.authVersion !== claims.authVersion) throw new InvalidTokenError();
721
+ if (user.status !== "active") throw new AccountUnavailableError();
722
+ return hydratePrincipal(store, user);
723
+ },
724
+ authorize: (requirement, context) => evaluateAuthorization(requirement, context),
725
+ requestEmailVerification: (userId) => requestEmailVerification(emailDeps, userId),
726
+ verifyEmail: (token) => verifyEmail(emailDeps, token),
727
+ requestPasswordReset: (email) => requestPasswordReset(deps, email),
728
+ resetPassword: (token, newPassword) => resetPassword(deps, token, newPassword),
729
+ setup2FA: (userId) => setup2FA(mfaDeps, userId),
730
+ enable2FA: (userId, code) => enable2FA(mfaDeps, userId, code),
731
+ disable2FA: (userId, code) => disable2FA(mfaDeps, userId, code),
732
+ verifyMfaChallenge: (mfaToken, code) => verifyMfaChallenge(mfaDeps, mfaToken, code),
733
+ lockAccount: (userId) => lockAccount(deps, userId),
734
+ unlockAccount: (userId) => unlockAccount(deps, userId),
735
+ disableAccount: (userId) => disableAccount(deps, userId),
736
+ oauth: oauthProviders && oauthProviders.length > 0 ? createOAuthService({
737
+ providers: oauthProviders,
738
+ config,
739
+ store,
740
+ sessionDeps: deps,
741
+ audit
742
+ }) : void 0
743
+ };
744
+ }
745
+ //#endregion
746
+ //#region core/oauth/providers/google.ts
747
+ const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
748
+ const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
749
+ const GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo";
750
+ const DEFAULT_SCOPES$1 = [
751
+ "openid",
752
+ "email",
753
+ "profile"
754
+ ];
755
+ /**
756
+ * Creates an {@link OAuthProvider} configured for Google OAuth 2.0 / OpenID Connect.
757
+ *
758
+ * Security notes:
759
+ * - The client secret is used only during server-side code exchange.
760
+ * - The userinfo endpoint is called with the access token to fetch user data, providing
761
+ * a verified source of truth over the network (HTTPS).
762
+ * - The `state` parameter is required and must be verified by the caller to prevent CSRF.
763
+ */
764
+ function createGoogleOAuthProvider(config) {
765
+ const defaultScopes = config.scopes ?? DEFAULT_SCOPES$1;
766
+ return {
767
+ name: "google",
768
+ buildAuthorizationUrl({ redirectUri, state, scopes }) {
769
+ const url = new URL(GOOGLE_AUTH_URL);
770
+ url.searchParams.set("client_id", config.clientId);
771
+ url.searchParams.set("redirect_uri", redirectUri);
772
+ url.searchParams.set("response_type", "code");
773
+ url.searchParams.set("scope", (scopes ?? defaultScopes).join(" "));
774
+ url.searchParams.set("state", state);
775
+ url.searchParams.set("access_type", "offline");
776
+ return url.toString();
777
+ },
778
+ async exchangeCode({ code, redirectUri }) {
779
+ const body = new URLSearchParams({
780
+ code,
781
+ client_id: config.clientId,
782
+ client_secret: config.clientSecret,
783
+ redirect_uri: redirectUri,
784
+ grant_type: "authorization_code"
785
+ });
786
+ const response = await fetch(GOOGLE_TOKEN_URL, {
787
+ method: "POST",
788
+ headers: { "content-type": "application/x-www-form-urlencoded" },
789
+ body: body.toString()
790
+ });
791
+ const data = await response.json();
792
+ if (!response.ok || data.error) throw new Error(`Google token exchange failed: ${data.error_description ?? data.error ?? response.status}`);
793
+ return {
794
+ accessToken: data.access_token,
795
+ idToken: data.id_token,
796
+ tokenType: data.token_type,
797
+ expiresIn: data.expires_in,
798
+ refreshToken: data.refresh_token,
799
+ scope: data.scope
800
+ };
801
+ },
802
+ async fetchUserInfo(tokens) {
803
+ const response = await fetch(GOOGLE_USERINFO_URL, { headers: { authorization: `Bearer ${tokens.accessToken}` } });
804
+ const data = await response.json();
805
+ if (!response.ok) throw new Error(`Google userinfo request failed: ${data.error?.message ?? response.status}`);
806
+ return {
807
+ providerUserId: data.sub,
808
+ email: data.email ?? null,
809
+ emailVerified: data.email_verified === true,
810
+ displayName: data.name ?? null
811
+ };
812
+ }
813
+ };
814
+ }
815
+ //#endregion
816
+ //#region core/oauth/providers/microsoft.ts
817
+ const DEFAULT_SCOPES = [
818
+ "openid",
819
+ "email",
820
+ "profile",
821
+ "User.Read"
822
+ ];
823
+ /**
824
+ * Creates an {@link OAuthProvider} configured for Microsoft Entra ID OAuth 2.0 / OIDC.
825
+ *
826
+ * Security notes:
827
+ * - The client secret is used only during server-side code exchange.
828
+ * - User data is fetched from the Microsoft Graph API using the access token, providing
829
+ * a verified source of truth over HTTPS.
830
+ * - The `state` parameter is required and must be verified by the caller to prevent CSRF.
831
+ */
832
+ function createMicrosoftOAuthProvider(config) {
833
+ const tenantId = config.tenantId ?? "common";
834
+ const defaultScopes = config.scopes ?? DEFAULT_SCOPES;
835
+ const authBase = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0`;
836
+ return {
837
+ name: "microsoft",
838
+ buildAuthorizationUrl({ redirectUri, state, scopes }) {
839
+ const url = new URL(`${authBase}/authorize`);
840
+ url.searchParams.set("client_id", config.clientId);
841
+ url.searchParams.set("redirect_uri", redirectUri);
842
+ url.searchParams.set("response_type", "code");
843
+ url.searchParams.set("scope", (scopes ?? defaultScopes).join(" "));
844
+ url.searchParams.set("state", state);
845
+ url.searchParams.set("response_mode", "query");
846
+ return url.toString();
847
+ },
848
+ async exchangeCode({ code, redirectUri }) {
849
+ const body = new URLSearchParams({
850
+ code,
851
+ client_id: config.clientId,
852
+ client_secret: config.clientSecret,
853
+ redirect_uri: redirectUri,
854
+ grant_type: "authorization_code",
855
+ scope: defaultScopes.join(" ")
856
+ });
857
+ const response = await fetch(`${authBase}/token`, {
858
+ method: "POST",
859
+ headers: { "content-type": "application/x-www-form-urlencoded" },
860
+ body: body.toString()
861
+ });
862
+ const data = await response.json();
863
+ if (!response.ok || data.error) throw new Error(`Microsoft token exchange failed: ${data.error_description ?? data.error ?? response.status}`);
864
+ return {
865
+ accessToken: data.access_token,
866
+ idToken: data.id_token,
867
+ tokenType: data.token_type,
868
+ expiresIn: data.expires_in,
869
+ refreshToken: data.refresh_token,
870
+ scope: data.scope
871
+ };
872
+ },
873
+ async fetchUserInfo(tokens) {
874
+ const response = await fetch("https://graph.microsoft.com/v1.0/me", { headers: { authorization: `Bearer ${tokens.accessToken}` } });
875
+ const data = await response.json();
876
+ if (!response.ok) throw new Error(`Microsoft Graph /me request failed: ${data.error?.message ?? response.status}`);
877
+ const email = data.mail ?? data.userPrincipalName ?? null;
878
+ return {
879
+ providerUserId: data.id,
880
+ email,
881
+ emailVerified: true,
882
+ displayName: data.displayName ?? null
883
+ };
884
+ }
885
+ };
886
+ }
887
+ //#endregion
888
+ export { AccountAdminError, AccountUnavailableError, EmailAlreadyRegisteredError, EmailVerificationError, ForbiddenError, IdentityConfigError, IdentityError, InvalidCredentialsError, InvalidTokenError, OAuthProviderNotFoundError, OAuthStateMismatchError, PasswordResetError, RegistrationValidationError, TwoFactorError, TwoFactorRequiredError, UnauthenticatedError, base32Decode, base32Encode, buildOtpAuthUri, checkLockout, clearLockout, consoleLogger, createAuditLogger, createGoogleOAuthProvider, createIdentityService, createMicrosoftOAuthProvider, createOAuthService, createPasswordHasher, createTokenService, disable2FA, disableAccount, enable2FA, evaluateAuthorization, generateTotp, generateTotpSecret, generateVerificationToken, hashVerificationToken, identitySchemaModel, isAuthorized, issueMfaChallengeToken, lockAccount, loginUser, noopLogger, normalizeEmail, recordFailedAttempt, refreshSession, registerUser, requestEmailVerification, requestPasswordReset, resetPassword, resolveIdentityConfig, resolveLogger, revokeSession, setup2FA, unlockAccount, verifyEmail, verifyMfaChallenge, verifyTotp };
889
+
890
+ //# sourceMappingURL=node.mjs.map