@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.
- package/LICENSE +201 -0
- package/README.md +387 -0
- package/dist/errors-BGMwaW5s.d.mts +481 -0
- package/dist/errors-BGMwaW5s.d.mts.map +1 -0
- package/dist/errors-Bcjx9o6g.cjs +111 -0
- package/dist/errors-C2xZAatu.d.cts +481 -0
- package/dist/errors-C2xZAatu.d.cts.map +1 -0
- package/dist/errors-CEmnZxIn.mjs +66 -0
- package/dist/errors-CEmnZxIn.mjs.map +1 -0
- package/dist/express.cjs +405 -0
- package/dist/express.d.cts +87 -0
- package/dist/express.d.cts.map +1 -0
- package/dist/express.d.mts +87 -0
- package/dist/express.d.mts.map +1 -0
- package/dist/express.mjs +401 -0
- package/dist/express.mjs.map +1 -0
- package/dist/identity-4eP45YIP.cjs +461 -0
- package/dist/identity-Bz9RDOvT.mjs +410 -0
- package/dist/identity-Bz9RDOvT.mjs.map +1 -0
- package/dist/identity-router-DBL20UWT.d.mts +115 -0
- package/dist/identity-router-DBL20UWT.d.mts.map +1 -0
- package/dist/identity-router-Dib30Waj.d.cts +115 -0
- package/dist/identity-router-Dib30Waj.d.cts.map +1 -0
- package/dist/identity-service-B9zrvE9z.d.mts +128 -0
- package/dist/identity-service-B9zrvE9z.d.mts.map +1 -0
- package/dist/identity-service-CLzKx8Z7.d.cts +128 -0
- package/dist/identity-service-CLzKx8Z7.d.cts.map +1 -0
- package/dist/identity-store-BRRahxcS.d.cts +272 -0
- package/dist/identity-store-BRRahxcS.d.cts.map +1 -0
- package/dist/identity-store-BRRahxcS.d.mts +272 -0
- package/dist/identity-store-BRRahxcS.d.mts.map +1 -0
- package/dist/index-CpufYgyn.d.cts +30 -0
- package/dist/index-CpufYgyn.d.cts.map +1 -0
- package/dist/index-CpufYgyn.d.mts +30 -0
- package/dist/index-CpufYgyn.d.mts.map +1 -0
- package/dist/index.cjs +18 -0
- package/dist/index.d.cts +4 -0
- package/dist/index.d.mts +4 -0
- package/dist/index.mjs +3 -0
- package/dist/logger-Be1wDzBC.cjs +48 -0
- package/dist/logger-CcCHJVVe.mjs +33 -0
- package/dist/logger-CcCHJVVe.mjs.map +1 -0
- package/dist/nestjs.cjs +516 -0
- package/dist/nestjs.d.cts +102 -0
- package/dist/nestjs.d.cts.map +1 -0
- package/dist/nestjs.d.mts +102 -0
- package/dist/nestjs.d.mts.map +1 -0
- package/dist/nestjs.mjs +500 -0
- package/dist/nestjs.mjs.map +1 -0
- package/dist/node.cjs +946 -0
- package/dist/node.d.cts +260 -0
- package/dist/node.d.cts.map +1 -0
- package/dist/node.d.mts +260 -0
- package/dist/node.d.mts.map +1 -0
- package/dist/node.mjs +890 -0
- package/dist/node.mjs.map +1 -0
- package/dist/test-utils.cjs +169 -0
- package/dist/test-utils.d.cts +12 -0
- package/dist/test-utils.d.cts.map +1 -0
- package/dist/test-utils.d.mts +12 -0
- package/dist/test-utils.d.mts.map +1 -0
- package/dist/test-utils.mjs +170 -0
- package/dist/test-utils.mjs.map +1 -0
- package/package.json +92 -0
- package/schema/model.ts +100 -0
- package/schema/mysql.sql +102 -0
- package/schema/postgres.sql +92 -0
- package/schema/prisma.schema +122 -0
- 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
|