@cairs-npm/express-auth-2fa 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -2
- package/dist/chunk-PQPLAWOZ.js +44 -0
- package/dist/chunk-PQPLAWOZ.js.map +1 -0
- package/dist/contract/index.cjs +25 -0
- package/dist/contract/index.cjs.map +1 -0
- package/dist/contract/index.d.cts +1 -0
- package/dist/contract/index.d.ts +1 -0
- package/dist/contract/index.js +3 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/hash-GKCJDZLH.js +21 -0
- package/dist/hash-GKCJDZLH.js.map +1 -0
- package/dist/index.cjs +1760 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +579 -0
- package/dist/index.d.ts +579 -0
- package/dist/index.js +1683 -0
- package/dist/index.js.map +1 -0
- package/package.json +75 -4
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1760 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __esm = (fn, res) => function __init() {
|
|
9
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
10
|
+
};
|
|
11
|
+
var __export = (target, all) => {
|
|
12
|
+
for (var name in all)
|
|
13
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
14
|
+
};
|
|
15
|
+
var __copyProps = (to, from, except, desc) => {
|
|
16
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
17
|
+
for (let key of __getOwnPropNames(from))
|
|
18
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
19
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
20
|
+
}
|
|
21
|
+
return to;
|
|
22
|
+
};
|
|
23
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
24
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
25
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
26
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
27
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
28
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
29
|
+
mod
|
|
30
|
+
));
|
|
31
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
32
|
+
|
|
33
|
+
// src/crypto/hash.ts
|
|
34
|
+
var hash_exports = {};
|
|
35
|
+
__export(hash_exports, {
|
|
36
|
+
DUMMY_HASH: () => DUMMY_HASH,
|
|
37
|
+
hashPassword: () => hashPassword,
|
|
38
|
+
randomId: () => randomId,
|
|
39
|
+
randomSmsCode: () => randomSmsCode,
|
|
40
|
+
randomToken: () => randomToken,
|
|
41
|
+
sha256Hex: () => sha256Hex,
|
|
42
|
+
timingSafeEqualStr: () => timingSafeEqualStr,
|
|
43
|
+
verifyPassword: () => verifyPassword
|
|
44
|
+
});
|
|
45
|
+
function hashPassword(plain, params) {
|
|
46
|
+
return import_argon2.default.hash(plain, {
|
|
47
|
+
type: import_argon2.default.argon2id,
|
|
48
|
+
memoryCost: params.memoryKiB,
|
|
49
|
+
timeCost: params.iterations,
|
|
50
|
+
parallelism: params.parallelism
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
function verifyPassword(hash, plain) {
|
|
54
|
+
return import_argon2.default.verify(hash, plain).catch(() => false);
|
|
55
|
+
}
|
|
56
|
+
function sha256Hex(value) {
|
|
57
|
+
return import_node_crypto.default.createHash("sha256").update(value, "utf8").digest("hex");
|
|
58
|
+
}
|
|
59
|
+
function timingSafeEqualStr(a, b) {
|
|
60
|
+
const ha = import_node_crypto.default.createHash("sha256").update(a).digest();
|
|
61
|
+
const hb = import_node_crypto.default.createHash("sha256").update(b).digest();
|
|
62
|
+
return import_node_crypto.default.timingSafeEqual(ha, hb);
|
|
63
|
+
}
|
|
64
|
+
function randomToken() {
|
|
65
|
+
return import_node_crypto.default.randomBytes(32).toString("base64url");
|
|
66
|
+
}
|
|
67
|
+
function randomSmsCode() {
|
|
68
|
+
return import_node_crypto.default.randomInt(0, 1e6).toString().padStart(6, "0");
|
|
69
|
+
}
|
|
70
|
+
function randomId(prefix) {
|
|
71
|
+
return `${prefix}_${import_node_crypto.default.randomBytes(9).toString("base64url")}`;
|
|
72
|
+
}
|
|
73
|
+
var import_node_crypto, import_argon2, DUMMY_HASH;
|
|
74
|
+
var init_hash = __esm({
|
|
75
|
+
"src/crypto/hash.ts"() {
|
|
76
|
+
"use strict";
|
|
77
|
+
import_node_crypto = __toESM(require("crypto"), 1);
|
|
78
|
+
import_argon2 = __toESM(require("argon2"), 1);
|
|
79
|
+
DUMMY_HASH = "$argon2id$v=19$m=65536,t=3,p=4$AAAAAAAAAAAAAAAAAAAAAA$1AkBK4sQubUqG5fSG5+0E4yP+4DpVXq3sfJv4boqK3k";
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// src/index.ts
|
|
84
|
+
var src_exports = {};
|
|
85
|
+
__export(src_exports, {
|
|
86
|
+
ApiErrorException: () => ApiErrorException,
|
|
87
|
+
AuthService: () => AuthService,
|
|
88
|
+
apiError: () => apiError,
|
|
89
|
+
consoleEmail: () => consoleEmail,
|
|
90
|
+
consoleSms: () => consoleSms,
|
|
91
|
+
createAuthRouter: () => createAuthRouter,
|
|
92
|
+
errorHandler: () => errorHandler,
|
|
93
|
+
getCollections: () => getCollections,
|
|
94
|
+
migrate: () => migrate2,
|
|
95
|
+
twilioSms: () => twilioSms
|
|
96
|
+
});
|
|
97
|
+
module.exports = __toCommonJS(src_exports);
|
|
98
|
+
var import_express3 = require("express");
|
|
99
|
+
var import_helmet = __toESM(require("helmet"), 1);
|
|
100
|
+
|
|
101
|
+
// src/config/schema.ts
|
|
102
|
+
var import_zod = require("zod");
|
|
103
|
+
var limitSchema = import_zod.z.object({
|
|
104
|
+
/** Failed passwords before lockout (BR-2). */
|
|
105
|
+
loginMaxAttempts: import_zod.z.number().int().positive().default(5),
|
|
106
|
+
/** Window for failed-login counting, minutes (BR-2). */
|
|
107
|
+
loginWindowMin: import_zod.z.number().positive().default(15),
|
|
108
|
+
/** Lockout duration, minutes (BR-2, BR-9). */
|
|
109
|
+
lockMinutes: import_zod.z.number().positive().default(15),
|
|
110
|
+
/** Min seconds between SMS sends on one challenge (BR-6). */
|
|
111
|
+
smsResendSec: import_zod.z.number().nonnegative().default(30),
|
|
112
|
+
/** Max SMS sends per challenge (BR-6). */
|
|
113
|
+
smsMaxPerChallenge: import_zod.z.number().int().positive().default(5),
|
|
114
|
+
/** Max SMS sends per user per hour (BR-6). */
|
|
115
|
+
smsMaxPerUserHour: import_zod.z.number().int().positive().default(10),
|
|
116
|
+
/** Wrong codes per challenge before lockout (BR-9). */
|
|
117
|
+
mfaMaxAttempts: import_zod.z.number().int().positive().default(5),
|
|
118
|
+
/** Max verification methods per user (BR-16). */
|
|
119
|
+
maxMethods: import_zod.z.number().int().positive().default(5),
|
|
120
|
+
/** Per-IP login requests per minute (NFR-B4). */
|
|
121
|
+
loginIpPerMin: import_zod.z.number().int().positive().default(20),
|
|
122
|
+
/** Per-IP MFA verify requests per minute (NFR-B4). */
|
|
123
|
+
verifyIpPerMin: import_zod.z.number().int().positive().default(30),
|
|
124
|
+
/** Per-IP step-up requests per minute (NFR-B4). */
|
|
125
|
+
stepUpIpPerMin: import_zod.z.number().int().positive().default(20)
|
|
126
|
+
});
|
|
127
|
+
var argon2Schema = import_zod.z.object({
|
|
128
|
+
memoryKiB: import_zod.z.number().int().positive().default(65536),
|
|
129
|
+
iterations: import_zod.z.number().int().positive().default(3),
|
|
130
|
+
parallelism: import_zod.z.number().int().positive().default(4)
|
|
131
|
+
});
|
|
132
|
+
var cookieSchema = import_zod.z.object({
|
|
133
|
+
secure: import_zod.z.boolean().default(true),
|
|
134
|
+
domain: import_zod.z.string().optional(),
|
|
135
|
+
sameSite: import_zod.z.enum(["lax", "strict", "none"]).default("lax"),
|
|
136
|
+
path: import_zod.z.string().default("/")
|
|
137
|
+
});
|
|
138
|
+
var ttlSchema = import_zod.z.object({
|
|
139
|
+
accessTokenSec: import_zod.z.number().int().positive().default(900),
|
|
140
|
+
refreshTokenDays: import_zod.z.number().positive().default(30),
|
|
141
|
+
trustedDeviceDays: import_zod.z.number().positive().default(30),
|
|
142
|
+
challengeSec: import_zod.z.number().int().positive().default(300),
|
|
143
|
+
stepUpSec: import_zod.z.number().int().positive().default(300),
|
|
144
|
+
pendingMethodMin: import_zod.z.number().positive().default(15),
|
|
145
|
+
auditDays: import_zod.z.number().positive().default(365)
|
|
146
|
+
});
|
|
147
|
+
var configSchema = import_zod.z.object({
|
|
148
|
+
db: import_zod.z.custom((v) => !!v && typeof v.collection === "function"),
|
|
149
|
+
issuer: import_zod.z.string().min(1),
|
|
150
|
+
/** PEM PKCS8 private key (Ed25519 or RSA). Required, min 32 bytes. */
|
|
151
|
+
jwtPrivateKey: import_zod.z.string().min(32),
|
|
152
|
+
/** 32-byte AES key, raw string or base64. Required. */
|
|
153
|
+
encryptionKey: import_zod.z.string().min(32),
|
|
154
|
+
keyId: import_zod.z.string().default("v1"),
|
|
155
|
+
userAdapter: import_zod.z.custom(
|
|
156
|
+
(v) => !!v && typeof v.findByLogin === "function" && typeof v.findById === "function"
|
|
157
|
+
),
|
|
158
|
+
smsProvider: import_zod.z.custom((v) => !!v && typeof v.send === "function"),
|
|
159
|
+
/** Enables the always-present EMAIL method (default for new users). */
|
|
160
|
+
emailProvider: import_zod.z.custom((v) => !v || typeof v.send === "function").optional(),
|
|
161
|
+
/** MongoClient for transactions; defaults to the client behind `db`. */
|
|
162
|
+
client: import_zod.z.custom((v) => !v || typeof v.startSession === "function").optional(),
|
|
163
|
+
logger: import_zod.z.custom().optional(),
|
|
164
|
+
hooks: import_zod.z.custom((v) => !v || typeof v === "object").optional(),
|
|
165
|
+
clock: import_zod.z.custom((v) => !v || typeof v === "function").optional(),
|
|
166
|
+
collectionPrefix: import_zod.z.string().min(1).default("auth_"),
|
|
167
|
+
/**
|
|
168
|
+
* Passkeys (WebAuthn) as a second step. `rpID` is the site's domain (e.g. "childmatch.com",
|
|
169
|
+
* "localhost"); `origins` are the exact browser origins allowed (e.g. "https://app.childmatch.com").
|
|
170
|
+
* Omit to turn passkeys off.
|
|
171
|
+
*/
|
|
172
|
+
passkeys: import_zod.z.object({
|
|
173
|
+
rpID: import_zod.z.string().min(1),
|
|
174
|
+
rpName: import_zod.z.string().min(1),
|
|
175
|
+
origins: import_zod.z.array(import_zod.z.string().url()).min(1)
|
|
176
|
+
}).optional(),
|
|
177
|
+
/** 2-Step Verification state for users who have not chosen one yet. */
|
|
178
|
+
twoStepDefault: import_zod.z.boolean().default(false),
|
|
179
|
+
limits: limitSchema.default({}),
|
|
180
|
+
/**
|
|
181
|
+
* Set false to turn off every RATE_LIMITED check: the per-IP limits on login / code
|
|
182
|
+
* verify / send / step-up and the code-send cooldown and caps. Account lockouts after
|
|
183
|
+
* failed passwords or codes (LOCKED) still apply.
|
|
184
|
+
*/
|
|
185
|
+
rateLimits: import_zod.z.boolean().default(true),
|
|
186
|
+
argon2: argon2Schema.default({}),
|
|
187
|
+
cookies: cookieSchema.default({}),
|
|
188
|
+
ttl: ttlSchema.default({}),
|
|
189
|
+
/** Serve GET /auth/openapi.json (A.7 docs). */
|
|
190
|
+
exposeDocs: import_zod.z.boolean().default(false)
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// src/config/defaults.ts
|
|
194
|
+
var noopLogger = {
|
|
195
|
+
info: () => {
|
|
196
|
+
},
|
|
197
|
+
warn: () => {
|
|
198
|
+
},
|
|
199
|
+
error: () => {
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
var SENSITIVE_KEYS = ["password", "code", "secret", "token", "phone", "authorization"];
|
|
203
|
+
function redact(value, depth = 0) {
|
|
204
|
+
if (value === null || value === void 0 || depth > 6) return value;
|
|
205
|
+
if (typeof value === "string") return value;
|
|
206
|
+
if (Array.isArray(value)) return value.map((v) => redact(v, depth + 1));
|
|
207
|
+
if (typeof value === "object") {
|
|
208
|
+
const out = {};
|
|
209
|
+
for (const [k, v] of Object.entries(value)) {
|
|
210
|
+
out[k] = SENSITIVE_KEYS.some((s) => k.toLowerCase().includes(s)) ? "[REDACTED]" : redact(v, depth + 1);
|
|
211
|
+
}
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
return value;
|
|
215
|
+
}
|
|
216
|
+
function wrapLogger(logger) {
|
|
217
|
+
if (!logger) return noopLogger;
|
|
218
|
+
return {
|
|
219
|
+
info: (o, m) => logger.info(redact(o), m),
|
|
220
|
+
warn: (o, m) => logger.warn(redact(o), m),
|
|
221
|
+
error: (o, m) => logger.error(redact(o), m)
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
function resolveConfig(input) {
|
|
225
|
+
const parsed = configSchema.parse(input);
|
|
226
|
+
const key = decodeEncryptionKey(parsed.encryptionKey);
|
|
227
|
+
if (key.length !== 32) {
|
|
228
|
+
throw new Error("encryptionKey must decode to exactly 32 bytes (raw 32-char string or base64)");
|
|
229
|
+
}
|
|
230
|
+
const clock = parsed.clock ?? (() => /* @__PURE__ */ new Date());
|
|
231
|
+
return { ...parsed, clock, logger: wrapLogger(parsed.logger) };
|
|
232
|
+
}
|
|
233
|
+
function decodeEncryptionKey(raw) {
|
|
234
|
+
const asBase64 = Buffer.from(raw, "base64");
|
|
235
|
+
if (asBase64.length === 32 && asBase64.toString("base64").replace(/=+$/, "") === raw.replace(/=+$/, "")) {
|
|
236
|
+
return asBase64;
|
|
237
|
+
}
|
|
238
|
+
return Buffer.from(raw, "utf8");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// src/data/migrate.ts
|
|
242
|
+
function getCollections(db, prefix = "auth_") {
|
|
243
|
+
return {
|
|
244
|
+
credentials: db.collection(`${prefix}credentials`),
|
|
245
|
+
methods: db.collection(`${prefix}mfa_methods`),
|
|
246
|
+
challenges: db.collection(`${prefix}challenges`),
|
|
247
|
+
sessions: db.collection(`${prefix}sessions`),
|
|
248
|
+
trustedDevices: db.collection(`${prefix}trusted_devices`),
|
|
249
|
+
events: db.collection(`${prefix}events`),
|
|
250
|
+
rateLimits: db.collection(`${prefix}rate_limits`)
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
var VALIDATORS = {
|
|
254
|
+
credentials: {
|
|
255
|
+
$jsonSchema: {
|
|
256
|
+
bsonType: "object",
|
|
257
|
+
required: ["userId", "passwordHash", "twoStepEnabled", "updatedAt"],
|
|
258
|
+
properties: {
|
|
259
|
+
userId: { bsonType: "string" },
|
|
260
|
+
passwordHash: { bsonType: "string" },
|
|
261
|
+
twoStepEnabled: { bsonType: "bool" },
|
|
262
|
+
lockedUntil: { bsonType: ["date", "null"] },
|
|
263
|
+
updatedAt: { bsonType: "date" }
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
mfa_methods: {
|
|
268
|
+
$jsonSchema: {
|
|
269
|
+
bsonType: "object",
|
|
270
|
+
required: ["userId", "type", "label", "isDefault", "status", "createdAt"],
|
|
271
|
+
properties: {
|
|
272
|
+
userId: { bsonType: "string" },
|
|
273
|
+
type: { enum: ["EMAIL", "TOTP", "SMS", "PASSKEY"] },
|
|
274
|
+
label: { bsonType: "string" },
|
|
275
|
+
isDefault: { bsonType: "bool" },
|
|
276
|
+
status: { enum: ["pending", "active"] },
|
|
277
|
+
createdAt: { bsonType: "date" },
|
|
278
|
+
lastUsedAt: { bsonType: ["date", "null"] }
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
challenges: {
|
|
283
|
+
$jsonSchema: {
|
|
284
|
+
bsonType: "object",
|
|
285
|
+
required: ["_id", "userId", "purpose", "attempts", "expiresAt"],
|
|
286
|
+
properties: {
|
|
287
|
+
userId: { bsonType: "string" },
|
|
288
|
+
purpose: { enum: ["login", "step_up", "enroll"] },
|
|
289
|
+
attempts: { bsonType: "int" },
|
|
290
|
+
expiresAt: { bsonType: "date" }
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
sessions: {
|
|
295
|
+
$jsonSchema: {
|
|
296
|
+
bsonType: "object",
|
|
297
|
+
required: ["_id", "userId", "refreshTokenHash", "familyId", "expiresAt"],
|
|
298
|
+
properties: {
|
|
299
|
+
userId: { bsonType: "string" },
|
|
300
|
+
refreshTokenHash: { bsonType: "string" },
|
|
301
|
+
familyId: { bsonType: "string" },
|
|
302
|
+
expiresAt: { bsonType: "date" },
|
|
303
|
+
revokedAt: { bsonType: ["date", "null"] }
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
trusted_devices: {
|
|
308
|
+
$jsonSchema: {
|
|
309
|
+
bsonType: "object",
|
|
310
|
+
required: ["userId", "tokenHash", "expiresAt"],
|
|
311
|
+
properties: {
|
|
312
|
+
userId: { bsonType: "string" },
|
|
313
|
+
tokenHash: { bsonType: "string" },
|
|
314
|
+
expiresAt: { bsonType: "date" }
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
events: {
|
|
319
|
+
$jsonSchema: {
|
|
320
|
+
bsonType: "object",
|
|
321
|
+
required: ["userId", "type", "at"],
|
|
322
|
+
properties: {
|
|
323
|
+
userId: { bsonType: "string" },
|
|
324
|
+
type: { bsonType: "string" },
|
|
325
|
+
at: { bsonType: "date" }
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
rate_limits: {
|
|
330
|
+
$jsonSchema: {
|
|
331
|
+
bsonType: "object",
|
|
332
|
+
required: ["_id", "count", "expiresAt"],
|
|
333
|
+
properties: {
|
|
334
|
+
count: { bsonType: "int" },
|
|
335
|
+
expiresAt: { bsonType: "date" }
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
async function migrate(db, prefix = "auth_", auditDays = 365) {
|
|
341
|
+
for (const [suffix, validator] of Object.entries(VALIDATORS)) {
|
|
342
|
+
const name = `${prefix}${suffix}`;
|
|
343
|
+
const exists = await db.listCollections({ name }).hasNext();
|
|
344
|
+
if (!exists) {
|
|
345
|
+
await db.createCollection(name, { validator });
|
|
346
|
+
} else {
|
|
347
|
+
await db.command({ collMod: name, validator });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const c = getCollections(db, prefix);
|
|
351
|
+
await c.credentials.createIndex({ userId: 1 }, { unique: true });
|
|
352
|
+
await c.methods.createIndex({ userId: 1 });
|
|
353
|
+
await c.methods.createIndex(
|
|
354
|
+
{ userId: 1, isDefault: 1 },
|
|
355
|
+
{ unique: true, partialFilterExpression: { isDefault: true } }
|
|
356
|
+
);
|
|
357
|
+
await c.methods.createIndex(
|
|
358
|
+
{ userId: 1, type: 1 },
|
|
359
|
+
{ unique: true, partialFilterExpression: { type: "EMAIL" } }
|
|
360
|
+
);
|
|
361
|
+
await c.methods.createIndex(
|
|
362
|
+
{ pendingExpiresAt: 1 },
|
|
363
|
+
{ expireAfterSeconds: 0, partialFilterExpression: { status: "pending" } }
|
|
364
|
+
);
|
|
365
|
+
await c.challenges.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
|
366
|
+
await c.sessions.createIndex({ refreshTokenHash: 1 }, { unique: true });
|
|
367
|
+
await c.sessions.createIndex({ userId: 1 });
|
|
368
|
+
await c.sessions.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
|
369
|
+
await c.trustedDevices.createIndex({ tokenHash: 1 }, { unique: true });
|
|
370
|
+
await c.trustedDevices.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
|
371
|
+
await c.events.createIndex({ userId: 1, at: -1 });
|
|
372
|
+
await c.events.createIndex({ at: 1 }, { expireAfterSeconds: Math.floor(auditDays * 86400) });
|
|
373
|
+
await c.rateLimits.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
|
374
|
+
return c;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// src/services/auth.service.ts
|
|
378
|
+
init_hash();
|
|
379
|
+
|
|
380
|
+
// src/middleware/errors.ts
|
|
381
|
+
var import_zod2 = require("zod");
|
|
382
|
+
var ApiErrorException = class extends Error {
|
|
383
|
+
constructor(status, code, message, extra) {
|
|
384
|
+
super(message);
|
|
385
|
+
this.status = status;
|
|
386
|
+
this.code = code;
|
|
387
|
+
this.extra = extra;
|
|
388
|
+
}
|
|
389
|
+
status;
|
|
390
|
+
code;
|
|
391
|
+
extra;
|
|
392
|
+
};
|
|
393
|
+
function apiError(status, code, message, extra) {
|
|
394
|
+
return new ApiErrorException(status, code, message, {
|
|
395
|
+
attemptsLeft: extra?.attemptsLeft,
|
|
396
|
+
retryAt: extra?.retryAt instanceof Date ? extra.retryAt.toISOString() : extra?.retryAt
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
function errorHandler(err, _req, res, _next) {
|
|
400
|
+
if (err instanceof ApiErrorException) {
|
|
401
|
+
const body = { error: err.code, message: err.message, ...err.extra };
|
|
402
|
+
res.status(err.status).json(body);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (err instanceof import_zod2.ZodError) {
|
|
406
|
+
res.status(400).json({ error: "VALIDATION_ERROR", message: err.issues[0]?.message ?? "The request is invalid." });
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
console.error("express-auth-2fa: unhandled error", err);
|
|
410
|
+
res.status(500).json({ error: "VALIDATION_ERROR", message: "Internal error." });
|
|
411
|
+
}
|
|
412
|
+
function parseBody(schema, body) {
|
|
413
|
+
return schema.parse(body);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// src/middleware/cookies.ts
|
|
417
|
+
var REFRESH_COOKIE = "auth_refresh";
|
|
418
|
+
var TRUSTED_DEVICE_COOKIE = "auth_trusted";
|
|
419
|
+
var CSRF_COOKIE = "auth_csrf";
|
|
420
|
+
function parseCookies(req) {
|
|
421
|
+
const header = req.headers.cookie;
|
|
422
|
+
if (!header) return {};
|
|
423
|
+
const out = {};
|
|
424
|
+
for (const part of header.split(";")) {
|
|
425
|
+
const eq = part.indexOf("=");
|
|
426
|
+
if (eq === -1) continue;
|
|
427
|
+
out[part.slice(0, eq).trim()] = decodeURIComponent(part.slice(eq + 1).trim());
|
|
428
|
+
}
|
|
429
|
+
return out;
|
|
430
|
+
}
|
|
431
|
+
function setCookie(res, config, name, value, maxAgeMs, httpOnly = true) {
|
|
432
|
+
res.cookie(name, value, {
|
|
433
|
+
httpOnly,
|
|
434
|
+
secure: config.cookies.secure,
|
|
435
|
+
sameSite: config.cookies.sameSite,
|
|
436
|
+
domain: config.cookies.domain,
|
|
437
|
+
path: config.cookies.path,
|
|
438
|
+
maxAge: maxAgeMs
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
function clearCookie(res, config, name) {
|
|
442
|
+
res.clearCookie(name, {
|
|
443
|
+
secure: config.cookies.secure,
|
|
444
|
+
sameSite: config.cookies.sameSite,
|
|
445
|
+
domain: config.cookies.domain,
|
|
446
|
+
path: config.cookies.path
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// src/services/method.service.ts
|
|
451
|
+
var import_mongodb = require("mongodb");
|
|
452
|
+
var import_libphonenumber_js = require("libphonenumber-js");
|
|
453
|
+
|
|
454
|
+
// src/crypto/encrypt.ts
|
|
455
|
+
var import_node_crypto2 = __toESM(require("crypto"), 1);
|
|
456
|
+
function encryptString(plain, key, keyId) {
|
|
457
|
+
const iv = import_node_crypto2.default.randomBytes(12);
|
|
458
|
+
const cipher = import_node_crypto2.default.createCipheriv("aes-256-gcm", key, iv);
|
|
459
|
+
const ct = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
|
|
460
|
+
return [keyId, iv, cipher.getAuthTag(), ct].map((b) => typeof b === "string" ? b : b.toString("base64url")).join(".");
|
|
461
|
+
}
|
|
462
|
+
function decryptString(payload, keys) {
|
|
463
|
+
const parts = payload.split(".");
|
|
464
|
+
if (parts.length !== 4) throw new Error("invalid ciphertext format");
|
|
465
|
+
const [keyId, ivB64, tagB64, ctB64] = parts;
|
|
466
|
+
const key = keys[keyId];
|
|
467
|
+
if (!key) throw new Error(`unknown keyId: ${keyId}`);
|
|
468
|
+
const decipher = import_node_crypto2.default.createDecipheriv("aes-256-gcm", key, Buffer.from(ivB64, "base64url"));
|
|
469
|
+
decipher.setAuthTag(Buffer.from(tagB64, "base64url"));
|
|
470
|
+
const plain = Buffer.concat([decipher.update(Buffer.from(ctB64, "base64url")), decipher.final()]);
|
|
471
|
+
return { plain: plain.toString("utf8"), keyId };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// src/crypto/totp.ts
|
|
475
|
+
var import_otplib = require("otplib");
|
|
476
|
+
function atEpoch(epochMs) {
|
|
477
|
+
const a = import_otplib.authenticator.clone();
|
|
478
|
+
a.options = { digits: 6, step: 30, window: 0, epoch: epochMs };
|
|
479
|
+
return a;
|
|
480
|
+
}
|
|
481
|
+
function generateTotpSecret() {
|
|
482
|
+
return import_otplib.authenticator.generateSecret(20);
|
|
483
|
+
}
|
|
484
|
+
function buildOtpauthUri(issuer, account, secret) {
|
|
485
|
+
return import_otplib.authenticator.keyuri(account, issuer, secret);
|
|
486
|
+
}
|
|
487
|
+
function totpStepAt(epochMs) {
|
|
488
|
+
return Math.floor(epochMs / 3e4);
|
|
489
|
+
}
|
|
490
|
+
function checkTotp(code, secret, nowMs) {
|
|
491
|
+
for (const offset of [-1, 0, 1]) {
|
|
492
|
+
const epoch = nowMs + offset * 3e4;
|
|
493
|
+
if (atEpoch(epoch).verify({ token: code, secret })) {
|
|
494
|
+
return { valid: true, step: totpStepAt(epoch) };
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return { valid: false, step: null };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// src/services/method.service.ts
|
|
501
|
+
init_hash();
|
|
502
|
+
|
|
503
|
+
// src/services/methodSecret.ts
|
|
504
|
+
function decryptMethodSecret(payload, keys) {
|
|
505
|
+
try {
|
|
506
|
+
return decryptString(payload, keys).plain;
|
|
507
|
+
} catch {
|
|
508
|
+
throw apiError(
|
|
509
|
+
409,
|
|
510
|
+
"VALIDATION_ERROR",
|
|
511
|
+
"This verification method can no longer be used. Choose another method, or remove it and add it again."
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
async function pruneUnreadableMethods(config, cols, userId, audit) {
|
|
516
|
+
const keys = { [config.keyId]: decodeEncryptionKey(config.encryptionKey) };
|
|
517
|
+
const methods = await cols.methods.find({ userId, type: { $in: ["SMS", "TOTP"] } }).toArray();
|
|
518
|
+
const unreadable = methods.filter((m) => {
|
|
519
|
+
const sealed = m.type === "SMS" ? m.phoneE164Enc : m.secretEnc;
|
|
520
|
+
if (!sealed) return true;
|
|
521
|
+
try {
|
|
522
|
+
decryptString(sealed, keys);
|
|
523
|
+
return false;
|
|
524
|
+
} catch {
|
|
525
|
+
return true;
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
if (unreadable.length === 0) return 0;
|
|
529
|
+
await cols.methods.deleteMany({ _id: { $in: unreadable.map((m) => m._id) } });
|
|
530
|
+
if (!await cols.methods.findOne({ userId, status: "active", isDefault: true })) {
|
|
531
|
+
const next = await cols.methods.findOne({ userId, status: "active", type: "EMAIL" }) ?? await cols.methods.findOne({ userId, status: "active" }, { sort: { lastUsedAt: -1, createdAt: -1 } });
|
|
532
|
+
if (next) await cols.methods.updateOne({ _id: next._id }, { $set: { isDefault: true } });
|
|
533
|
+
}
|
|
534
|
+
for (const m of unreadable) {
|
|
535
|
+
await audit?.record(userId, "method_removed", { meta: { methodId: m._id.toString(), type: m.type, reason: "unreadable" } });
|
|
536
|
+
}
|
|
537
|
+
return unreadable.length;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// src/services/delivery.ts
|
|
541
|
+
function maskEmail(email) {
|
|
542
|
+
const at = email.lastIndexOf("@");
|
|
543
|
+
if (at <= 0) return "\u2022\u2022\u2022";
|
|
544
|
+
return `${email[0]}\u2022\u2022\u2022${email.slice(at)}`;
|
|
545
|
+
}
|
|
546
|
+
function isDuplicateKey(e) {
|
|
547
|
+
return !!e && typeof e === "object" && e.code === 11e3;
|
|
548
|
+
}
|
|
549
|
+
async function ensureEmailMethod(config, cols, userId, email) {
|
|
550
|
+
if (!config.emailProvider) return;
|
|
551
|
+
const address = email ?? (await config.userAdapter.findById(userId))?.email;
|
|
552
|
+
if (!address || !address.includes("@")) return;
|
|
553
|
+
const label = maskEmail(address);
|
|
554
|
+
const existing = await cols.methods.findOne({ userId, type: "EMAIL" });
|
|
555
|
+
if (existing) {
|
|
556
|
+
if (existing.label !== label) await cols.methods.updateOne({ _id: existing._id }, { $set: { label } });
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
const hasDefault = await cols.methods.countDocuments({ userId, status: "active", isDefault: true }) > 0;
|
|
560
|
+
const doc = {
|
|
561
|
+
userId,
|
|
562
|
+
type: "EMAIL",
|
|
563
|
+
label,
|
|
564
|
+
isDefault: !hasDefault,
|
|
565
|
+
status: "active",
|
|
566
|
+
createdAt: config.clock(),
|
|
567
|
+
lastUsedAt: null
|
|
568
|
+
};
|
|
569
|
+
try {
|
|
570
|
+
await cols.methods.insertOne(doc);
|
|
571
|
+
} catch (e) {
|
|
572
|
+
if (!isDuplicateKey(e)) throw e;
|
|
573
|
+
if (await cols.methods.findOne({ userId, type: "EMAIL" })) return;
|
|
574
|
+
try {
|
|
575
|
+
await cols.methods.insertOne({ ...doc, _id: void 0, isDefault: false });
|
|
576
|
+
} catch (e2) {
|
|
577
|
+
if (!isDuplicateKey(e2)) throw e2;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
async function deliverCode(config, encKey, method, code) {
|
|
582
|
+
const message = `Your verification code is ${code}`;
|
|
583
|
+
if (method.type === "SMS") {
|
|
584
|
+
if (!method.phoneE164Enc) throw apiError(400, "VALIDATION_ERROR", "Unknown SMS method.");
|
|
585
|
+
const phone = decryptMethodSecret(method.phoneE164Enc, { [config.keyId]: encKey });
|
|
586
|
+
await config.smsProvider.send(phone, message);
|
|
587
|
+
return `\u2022\u2022\u2022\u2022 ${method.phoneLast4 ?? ""}`.trim();
|
|
588
|
+
}
|
|
589
|
+
if (method.type === "EMAIL") {
|
|
590
|
+
const email = (await config.userAdapter.findById(method.userId))?.email;
|
|
591
|
+
if (!config.emailProvider || !email) throw apiError(400, "VALIDATION_ERROR", "Email verification is not available.");
|
|
592
|
+
await config.emailProvider.send({ to: email, code, message });
|
|
593
|
+
return maskEmail(email);
|
|
594
|
+
}
|
|
595
|
+
throw apiError(400, "VALIDATION_ERROR", "This method does not send codes.");
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// src/services/method.service.ts
|
|
599
|
+
function toApiMethod(doc) {
|
|
600
|
+
return {
|
|
601
|
+
id: doc._id.toString(),
|
|
602
|
+
type: doc.type,
|
|
603
|
+
label: doc.type === "SMS" ? `\u2022\u2022\u2022\u2022 ${doc.phoneLast4 ?? ""}`.trim() : doc.label,
|
|
604
|
+
isDefault: doc.isDefault,
|
|
605
|
+
createdAt: doc.createdAt.toISOString(),
|
|
606
|
+
lastUsedAt: doc.lastUsedAt ? doc.lastUsedAt.toISOString() : null
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
function duplicateTypeMessage(type) {
|
|
610
|
+
return type === "SMS" ? "You already have a phone number. Remove it before adding another." : "You already have an authenticator app. Remove it before adding another.";
|
|
611
|
+
}
|
|
612
|
+
var MethodService = class {
|
|
613
|
+
constructor(config, cols, tokens, audit) {
|
|
614
|
+
this.config = config;
|
|
615
|
+
this.cols = cols;
|
|
616
|
+
this.tokens = tokens;
|
|
617
|
+
this.audit = audit;
|
|
618
|
+
this.encKey = decodeEncryptionKey(config.encryptionKey);
|
|
619
|
+
}
|
|
620
|
+
config;
|
|
621
|
+
cols;
|
|
622
|
+
tokens;
|
|
623
|
+
audit;
|
|
624
|
+
encKey;
|
|
625
|
+
objectId(id) {
|
|
626
|
+
try {
|
|
627
|
+
return new import_mongodb.ObjectId(id);
|
|
628
|
+
} catch {
|
|
629
|
+
throw apiError(400, "VALIDATION_ERROR", "Invalid id.");
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
/** BR-14 — active methods only, default first, then by createdAt. */
|
|
633
|
+
async securitySummary(userId) {
|
|
634
|
+
await ensureEmailMethod(this.config, this.cols, userId);
|
|
635
|
+
await pruneUnreadableMethods(this.config, this.cols, userId, this.audit);
|
|
636
|
+
const cred = await this.cols.credentials.findOne({ userId });
|
|
637
|
+
const docs = await this.cols.methods.find({ userId, status: "active" }).toArray();
|
|
638
|
+
const methods = docs.sort((a, b) => Number(b.isDefault) - Number(a.isDefault) || a.createdAt.getTime() - b.createdAt.getTime()).map(toApiMethod);
|
|
639
|
+
return { twoStepEnabled: cred?.twoStepEnabled ?? this.config.twoStepDefault, methods };
|
|
640
|
+
}
|
|
641
|
+
/** BR-15 — enable needs ≥1 active method; disable needs step-up + revokes trusted devices. */
|
|
642
|
+
async setTwoStep(userId, sid, enabled, stepUpToken) {
|
|
643
|
+
const now = this.config.clock();
|
|
644
|
+
if (enabled) {
|
|
645
|
+
await ensureEmailMethod(this.config, this.cols, userId);
|
|
646
|
+
const count = await this.cols.methods.countDocuments({ userId, status: "active" });
|
|
647
|
+
if (count === 0) throw apiError(409, "NO_METHODS", "Add a verification method before enabling 2-Step Verification.");
|
|
648
|
+
} else {
|
|
649
|
+
if (!stepUpToken) throw apiError(403, "STEP_UP_REQUIRED", "This action requires recent verification.");
|
|
650
|
+
await this.tokens.verifyStepUpToken(stepUpToken, userId, sid);
|
|
651
|
+
await this.cols.trustedDevices.deleteMany({ userId });
|
|
652
|
+
}
|
|
653
|
+
await this.cols.credentials.updateOne({ userId }, { $set: { twoStepEnabled: enabled, updatedAt: now } });
|
|
654
|
+
await this.audit.record(userId, enabled ? "2fa_enabled" : "2fa_disabled", {});
|
|
655
|
+
return { twoStepEnabled: enabled };
|
|
656
|
+
}
|
|
657
|
+
/** BR-16 — add a pending TOTP (otpauthUri+secret once) or pending SMS (sends code). Max 5 methods. */
|
|
658
|
+
async addMethod(userId, input) {
|
|
659
|
+
if (input.type === "EMAIL") {
|
|
660
|
+
throw apiError(400, "VALIDATION_ERROR", "The email method is added automatically from the account email.");
|
|
661
|
+
}
|
|
662
|
+
const now = this.config.clock();
|
|
663
|
+
if (await this.cols.methods.findOne({ userId, type: input.type, status: "active" })) {
|
|
664
|
+
throw apiError(409, "VALIDATION_ERROR", duplicateTypeMessage(input.type));
|
|
665
|
+
}
|
|
666
|
+
await this.cols.methods.deleteMany({ userId, type: input.type, status: "pending" });
|
|
667
|
+
const total = await this.cols.methods.countDocuments({ userId });
|
|
668
|
+
if (total >= this.config.limits.maxMethods) {
|
|
669
|
+
throw apiError(409, "VALIDATION_ERROR", `You can add at most ${this.config.limits.maxMethods} verification methods.`);
|
|
670
|
+
}
|
|
671
|
+
const pendingExpiresAt = new Date(now.getTime() + this.config.ttl.pendingMethodMin * 6e4);
|
|
672
|
+
if (input.type === "TOTP") {
|
|
673
|
+
const secret = generateTotpSecret();
|
|
674
|
+
const user = await this.config.userAdapter.findById(userId);
|
|
675
|
+
const account = user?.email ?? user?.username ?? userId;
|
|
676
|
+
const doc2 = {
|
|
677
|
+
userId,
|
|
678
|
+
type: "TOTP",
|
|
679
|
+
label: input.label ?? "Authenticator app",
|
|
680
|
+
isDefault: false,
|
|
681
|
+
status: "pending",
|
|
682
|
+
secretEnc: encryptString(secret, this.encKey, this.config.keyId),
|
|
683
|
+
pendingExpiresAt,
|
|
684
|
+
createdAt: now,
|
|
685
|
+
lastUsedAt: null
|
|
686
|
+
};
|
|
687
|
+
const { insertedId: insertedId2 } = await this.cols.methods.insertOne(doc2);
|
|
688
|
+
await this.audit.record(userId, "method_add_started", { meta: { type: "TOTP" } });
|
|
689
|
+
return {
|
|
690
|
+
pendingMethodId: insertedId2.toString(),
|
|
691
|
+
otpauthUri: buildOtpauthUri(this.config.issuer, account, secret),
|
|
692
|
+
secret
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
const parsed = input.phone ? (0, import_libphonenumber_js.parsePhoneNumberFromString)(input.phone) : void 0;
|
|
696
|
+
if (!parsed || !parsed.isValid()) throw apiError(400, "VALIDATION_ERROR", "Enter a valid phone number.");
|
|
697
|
+
const e164 = parsed.number;
|
|
698
|
+
const last4 = e164.slice(-4);
|
|
699
|
+
const code = randomSmsCode();
|
|
700
|
+
const doc = {
|
|
701
|
+
userId,
|
|
702
|
+
type: "SMS",
|
|
703
|
+
label: `\u2022\u2022\u2022\u2022 ${last4}`,
|
|
704
|
+
isDefault: false,
|
|
705
|
+
status: "pending",
|
|
706
|
+
phoneE164Enc: encryptString(e164, this.encKey, this.config.keyId),
|
|
707
|
+
phoneLast4: last4,
|
|
708
|
+
pendingCodeHash: sha256Hex(code),
|
|
709
|
+
pendingExpiresAt,
|
|
710
|
+
createdAt: now,
|
|
711
|
+
lastUsedAt: null
|
|
712
|
+
};
|
|
713
|
+
const { insertedId } = await this.cols.methods.insertOne(doc);
|
|
714
|
+
await this.config.smsProvider.send(e164, `Your verification code is ${code}`);
|
|
715
|
+
await this.audit.record(userId, "method_add_started", { meta: { type: "SMS" } });
|
|
716
|
+
return { pendingMethodId: insertedId.toString() };
|
|
717
|
+
}
|
|
718
|
+
/** BR-17 — confirm a pending method; first active method becomes default. */
|
|
719
|
+
async confirmMethod(userId, methodId, code) {
|
|
720
|
+
const method = await this.cols.methods.findOne({ _id: this.objectId(methodId), userId, status: "pending" });
|
|
721
|
+
if (!method) throw apiError(404, "VALIDATION_ERROR", "Unknown method.");
|
|
722
|
+
const now = this.config.clock();
|
|
723
|
+
let ok = false;
|
|
724
|
+
if (method.type === "TOTP") {
|
|
725
|
+
const secret = decryptMethodSecret(method.secretEnc, { [this.config.keyId]: this.encKey });
|
|
726
|
+
ok = checkTotp(code, secret, now.getTime()).valid;
|
|
727
|
+
} else {
|
|
728
|
+
ok = !!method.pendingCodeHash && timingSafeEqualStr(method.pendingCodeHash, sha256Hex(code));
|
|
729
|
+
}
|
|
730
|
+
if (!ok) throw apiError(401, "INVALID_CODE", "That code didn't work. Try again.");
|
|
731
|
+
if (await this.cols.methods.findOne({ userId, type: method.type, status: "active" })) {
|
|
732
|
+
await this.cols.methods.deleteOne({ _id: method._id });
|
|
733
|
+
throw apiError(409, "VALIDATION_ERROR", duplicateTypeMessage(method.type));
|
|
734
|
+
}
|
|
735
|
+
const activeCount = await this.cols.methods.countDocuments({ userId, status: "active" });
|
|
736
|
+
await this.cols.methods.updateOne(
|
|
737
|
+
{ _id: method._id },
|
|
738
|
+
{
|
|
739
|
+
$set: { status: "active", isDefault: activeCount === 0, lastUsedAt: now },
|
|
740
|
+
$unset: { pendingCodeHash: "", pendingExpiresAt: "" }
|
|
741
|
+
}
|
|
742
|
+
);
|
|
743
|
+
await this.audit.record(userId, "method_added", { meta: { type: method.type } });
|
|
744
|
+
return toApiMethod({ ...method, status: "active", isDefault: activeCount === 0, lastUsedAt: now });
|
|
745
|
+
}
|
|
746
|
+
/** BR-18 — rename (TOTP only, 1–40 chars) or set default (step-up, transaction). */
|
|
747
|
+
async patchMethod(userId, sid, methodId, input) {
|
|
748
|
+
const method = await this.cols.methods.findOne({ _id: this.objectId(methodId), userId, status: "active" });
|
|
749
|
+
if (!method) throw apiError(404, "VALIDATION_ERROR", "Unknown method.");
|
|
750
|
+
if (input.isDefault === true) {
|
|
751
|
+
if (!input.stepUpToken) throw apiError(403, "STEP_UP_REQUIRED", "This action requires recent verification.");
|
|
752
|
+
await this.tokens.verifyStepUpToken(input.stepUpToken, userId, sid);
|
|
753
|
+
await this.withTransaction(async (session) => {
|
|
754
|
+
await this.cols.methods.updateMany({ userId, isDefault: true }, { $set: { isDefault: false } }, { session });
|
|
755
|
+
await this.cols.methods.updateOne({ _id: method._id }, { $set: { isDefault: true } }, { session });
|
|
756
|
+
});
|
|
757
|
+
method.isDefault = true;
|
|
758
|
+
await this.audit.record(userId, "method_set_default", { meta: { methodId } });
|
|
759
|
+
}
|
|
760
|
+
if (input.isDefault === false && method.isDefault) {
|
|
761
|
+
throw apiError(400, "VALIDATION_ERROR", "Set another method as default instead.");
|
|
762
|
+
}
|
|
763
|
+
if (input.label !== void 0) {
|
|
764
|
+
if (method.type !== "TOTP" && method.type !== "PASSKEY") {
|
|
765
|
+
throw apiError(400, "VALIDATION_ERROR", "Only authenticator apps and passkeys can be renamed.");
|
|
766
|
+
}
|
|
767
|
+
if (input.label.length < 1 || input.label.length > 40) {
|
|
768
|
+
throw apiError(400, "VALIDATION_ERROR", "Label must be 1\u201340 characters.");
|
|
769
|
+
}
|
|
770
|
+
await this.cols.methods.updateOne({ _id: method._id }, { $set: { label: input.label } });
|
|
771
|
+
method.label = input.label;
|
|
772
|
+
await this.audit.record(userId, "method_renamed", { meta: { methodId } });
|
|
773
|
+
}
|
|
774
|
+
return toApiMethod(method);
|
|
775
|
+
}
|
|
776
|
+
/** BR-19 — remove (step-up); LAST_METHOD guard; promote most-recently-used default. */
|
|
777
|
+
async deleteMethod(userId, sid, methodId, stepUpToken) {
|
|
778
|
+
if (!stepUpToken) throw apiError(403, "STEP_UP_REQUIRED", "This action requires recent verification.");
|
|
779
|
+
await this.tokens.verifyStepUpToken(stepUpToken, userId, sid);
|
|
780
|
+
const method = await this.cols.methods.findOne({ _id: this.objectId(methodId), userId });
|
|
781
|
+
if (!method) throw apiError(404, "VALIDATION_ERROR", "Unknown method.");
|
|
782
|
+
if (method.type === "EMAIL") throw apiError(400, "VALIDATION_ERROR", "Your email verification method can't be removed.");
|
|
783
|
+
const cred = await this.cols.credentials.findOne({ userId });
|
|
784
|
+
const activeCount = await this.cols.methods.countDocuments({ userId, status: "active" });
|
|
785
|
+
if (cred?.twoStepEnabled && method.status === "active" && activeCount <= 1) {
|
|
786
|
+
throw apiError(409, "LAST_METHOD", "You can't remove your last verification method while 2-Step Verification is on.");
|
|
787
|
+
}
|
|
788
|
+
await this.withTransaction(async (session) => {
|
|
789
|
+
await this.cols.methods.deleteOne({ _id: method._id }, { session });
|
|
790
|
+
if (method.isDefault && method.status === "active") {
|
|
791
|
+
const next = await this.cols.methods.findOne(
|
|
792
|
+
{ userId, status: "active", _id: { $ne: method._id } },
|
|
793
|
+
{ sort: { lastUsedAt: -1, createdAt: -1 }, session }
|
|
794
|
+
);
|
|
795
|
+
if (next) await this.cols.methods.updateOne({ _id: next._id }, { $set: { isDefault: true } }, { session });
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
await this.audit.record(userId, "method_removed", { meta: { methodId, type: method.type } });
|
|
799
|
+
}
|
|
800
|
+
transactionsSupported = null;
|
|
801
|
+
/** Transactions need a replica set or mongos; a standalone server (common in dev/test) has neither. */
|
|
802
|
+
supportsTransactions() {
|
|
803
|
+
this.transactionsSupported ??= this.config.db.command({ hello: 1 }).then((h) => !!h.setName || h.msg === "isdbgrid").catch(() => false);
|
|
804
|
+
return this.transactionsSupported;
|
|
805
|
+
}
|
|
806
|
+
async withTransaction(fn) {
|
|
807
|
+
if (!await this.supportsTransactions()) {
|
|
808
|
+
await fn(void 0);
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
const db = this.config.db;
|
|
812
|
+
const client = this.config.client ?? db.client ?? db.s?.client;
|
|
813
|
+
if (!client) throw new Error("express-auth-2fa: no MongoClient available for transactions; pass `client`.");
|
|
814
|
+
const session = client.startSession();
|
|
815
|
+
try {
|
|
816
|
+
await session.withTransaction(async () => fn(session));
|
|
817
|
+
} finally {
|
|
818
|
+
await session.endSession();
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
};
|
|
822
|
+
|
|
823
|
+
// src/services/auth.service.ts
|
|
824
|
+
function ctxOf(req) {
|
|
825
|
+
return { ip: req.ip, userAgent: req.headers["user-agent"] };
|
|
826
|
+
}
|
|
827
|
+
var AuthService = class {
|
|
828
|
+
constructor(config, cols, tokens, audit) {
|
|
829
|
+
this.config = config;
|
|
830
|
+
this.cols = cols;
|
|
831
|
+
this.tokens = tokens;
|
|
832
|
+
this.audit = audit;
|
|
833
|
+
}
|
|
834
|
+
config;
|
|
835
|
+
cols;
|
|
836
|
+
tokens;
|
|
837
|
+
audit;
|
|
838
|
+
async getCredentials(userId) {
|
|
839
|
+
return this.cols.credentials.findOne({ userId });
|
|
840
|
+
}
|
|
841
|
+
async ensureCredentials(userId) {
|
|
842
|
+
const now = this.config.clock();
|
|
843
|
+
await this.cols.credentials.updateOne(
|
|
844
|
+
{ userId },
|
|
845
|
+
{
|
|
846
|
+
$setOnInsert: {
|
|
847
|
+
userId,
|
|
848
|
+
passwordHash: "",
|
|
849
|
+
twoStepEnabled: this.config.twoStepDefault,
|
|
850
|
+
failedLogins: { count: 0, windowStart: now },
|
|
851
|
+
lockedUntil: null,
|
|
852
|
+
updatedAt: now
|
|
853
|
+
}
|
|
854
|
+
},
|
|
855
|
+
{ upsert: true }
|
|
856
|
+
);
|
|
857
|
+
return await this.cols.credentials.findOne({ userId });
|
|
858
|
+
}
|
|
859
|
+
/** Set or update the password for a user (used by apps/tests; registration is app-owned). */
|
|
860
|
+
async setPassword(userId, password) {
|
|
861
|
+
const { hashPassword: hashPassword2 } = await Promise.resolve().then(() => (init_hash(), hash_exports));
|
|
862
|
+
const now = this.config.clock();
|
|
863
|
+
const cred = await this.ensureCredentials(userId);
|
|
864
|
+
await this.cols.credentials.updateOne(
|
|
865
|
+
{ _id: cred._id },
|
|
866
|
+
{ $set: { passwordHash: await hashPassword2(password, this.config.argon2), updatedAt: now } }
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
async login(req, res, input) {
|
|
870
|
+
const now = this.config.clock();
|
|
871
|
+
const ctx = ctxOf(req);
|
|
872
|
+
const user = await this.config.userAdapter.findByLogin(input.username);
|
|
873
|
+
const cred = user ? await this.getCredentials(user.id) : null;
|
|
874
|
+
const ok = await verifyPassword(cred?.passwordHash || DUMMY_HASH, input.password);
|
|
875
|
+
if (!user || !cred?.passwordHash || !ok) {
|
|
876
|
+
if (user && cred) await this.registerFailedLogin(cred, now);
|
|
877
|
+
throw apiError(401, "INVALID_CREDENTIALS", "Invalid username or password.");
|
|
878
|
+
}
|
|
879
|
+
if (cred.lockedUntil && cred.lockedUntil.getTime() > now.getTime()) {
|
|
880
|
+
throw apiError(423, "LOCKED", "Too many attempts. Try again later.", { retryAt: cred.lockedUntil });
|
|
881
|
+
}
|
|
882
|
+
await this.resetFailedLogins(cred, now);
|
|
883
|
+
const trustedOk = cred.twoStepEnabled && await this.checkTrustedDevice(req, user.id);
|
|
884
|
+
if (!cred.twoStepEnabled || trustedOk) {
|
|
885
|
+
await this.audit.record(user.id, "login_success", ctx);
|
|
886
|
+
return this.authenticate(user.id, req, res, ["pwd"], { rememberMe: input.rememberMe });
|
|
887
|
+
}
|
|
888
|
+
await ensureEmailMethod(this.config, this.cols, user.id, user.email);
|
|
889
|
+
await pruneUnreadableMethods(this.config, this.cols, user.id, this.audit);
|
|
890
|
+
const active = await this.cols.methods.find({ userId: user.id, status: "active" }).toArray();
|
|
891
|
+
if (active.length === 0) {
|
|
892
|
+
await this.audit.record(user.id, "login_success", ctx);
|
|
893
|
+
return this.authenticate(user.id, req, res, ["pwd"], { rememberMe: input.rememberMe });
|
|
894
|
+
}
|
|
895
|
+
const challengeId = randomId("chg");
|
|
896
|
+
await this.cols.challenges.insertOne({
|
|
897
|
+
_id: challengeId,
|
|
898
|
+
userId: user.id,
|
|
899
|
+
purpose: "login",
|
|
900
|
+
methodId: null,
|
|
901
|
+
smsCodeHash: null,
|
|
902
|
+
smsSentAt: null,
|
|
903
|
+
smsSendCount: 0,
|
|
904
|
+
attempts: 0,
|
|
905
|
+
expiresAt: new Date(now.getTime() + this.config.ttl.challengeSec * 1e3),
|
|
906
|
+
createdAt: now
|
|
907
|
+
});
|
|
908
|
+
const methods = active.map(toApiMethod);
|
|
909
|
+
const defaultMethod = active.find((m) => m.isDefault) ?? active.find((m) => m.type === "EMAIL") ?? active[0];
|
|
910
|
+
await this.audit.record(user.id, "login_mfa_required", ctx);
|
|
911
|
+
return {
|
|
912
|
+
status: "MFA_REQUIRED",
|
|
913
|
+
challengeId,
|
|
914
|
+
methods,
|
|
915
|
+
defaultMethodId: defaultMethod._id.toString()
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
async registerFailedLogin(cred, now) {
|
|
919
|
+
const { loginMaxAttempts, loginWindowMin, lockMinutes } = this.config.limits;
|
|
920
|
+
const windowMs = loginWindowMin * 6e4;
|
|
921
|
+
const count = cred.failedLogins && now.getTime() - cred.failedLogins.windowStart.getTime() < windowMs ? cred.failedLogins.count + 1 : 1;
|
|
922
|
+
const locked = count >= loginMaxAttempts;
|
|
923
|
+
await this.cols.credentials.updateOne(
|
|
924
|
+
{ _id: cred._id },
|
|
925
|
+
{
|
|
926
|
+
$set: {
|
|
927
|
+
failedLogins: { count, windowStart: locked ? cred.failedLogins?.windowStart ?? now : count === 1 ? now : cred.failedLogins.windowStart },
|
|
928
|
+
...locked ? { lockedUntil: new Date(now.getTime() + lockMinutes * 6e4) } : {},
|
|
929
|
+
updatedAt: now
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
);
|
|
933
|
+
if (locked) await this.audit.record(cred.userId, "account_locked", {});
|
|
934
|
+
}
|
|
935
|
+
async resetFailedLogins(cred, now) {
|
|
936
|
+
await this.cols.credentials.updateOne(
|
|
937
|
+
{ _id: cred._id },
|
|
938
|
+
{ $set: { failedLogins: { count: 0, windowStart: now }, updatedAt: now } }
|
|
939
|
+
);
|
|
940
|
+
}
|
|
941
|
+
async checkTrustedDevice(req, userId) {
|
|
942
|
+
const token = parseCookies(req)[TRUSTED_DEVICE_COOKIE];
|
|
943
|
+
if (!token) return false;
|
|
944
|
+
const doc = await this.cols.trustedDevices.findOne({ tokenHash: sha256Hex(token), userId });
|
|
945
|
+
if (!doc) return false;
|
|
946
|
+
if (doc.expiresAt.getTime() <= this.config.clock().getTime()) return false;
|
|
947
|
+
return true;
|
|
948
|
+
}
|
|
949
|
+
/** Issue tokens + set cookies (refresh cookie, CSRF cookie). */
|
|
950
|
+
async authenticate(userId, req, res, amr, opts = {}) {
|
|
951
|
+
const ctx = ctxOf(req);
|
|
952
|
+
const bundle = await this.tokens.issueTokens(userId, amr, ctx);
|
|
953
|
+
const refreshDays = opts.rememberMe ? this.config.ttl.refreshTokenDays : Math.min(this.config.ttl.refreshTokenDays, 1);
|
|
954
|
+
setCookie(res, this.config, REFRESH_COOKIE, bundle.refreshToken, refreshDays * 864e5);
|
|
955
|
+
const csrfToken = randomToken();
|
|
956
|
+
setCookie(res, this.config, CSRF_COOKIE, csrfToken, this.config.ttl.refreshTokenDays * 864e5, false);
|
|
957
|
+
if (opts.rememberDevice) {
|
|
958
|
+
await this.trustDevice(userId, res, opts.deviceLabel ?? "This device");
|
|
959
|
+
}
|
|
960
|
+
const user = await this.config.userAdapter.findById(userId);
|
|
961
|
+
return {
|
|
962
|
+
status: "AUTHENTICATED",
|
|
963
|
+
user: { id: userId, username: user?.username, email: user?.email },
|
|
964
|
+
tokens: { accessToken: bundle.accessToken, expiresInSec: bundle.expiresInSec, csrfToken }
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
async trustDevice(userId, res, label) {
|
|
968
|
+
const token = randomToken();
|
|
969
|
+
const now = this.config.clock();
|
|
970
|
+
await this.cols.trustedDevices.insertOne({
|
|
971
|
+
userId,
|
|
972
|
+
tokenHash: sha256Hex(token),
|
|
973
|
+
label,
|
|
974
|
+
createdAt: now,
|
|
975
|
+
expiresAt: new Date(now.getTime() + this.config.ttl.trustedDeviceDays * 864e5)
|
|
976
|
+
});
|
|
977
|
+
setCookie(res, this.config, TRUSTED_DEVICE_COOKIE, token, this.config.ttl.trustedDeviceDays * 864e5);
|
|
978
|
+
}
|
|
979
|
+
async refresh(req, res) {
|
|
980
|
+
const cookies = parseCookies(req);
|
|
981
|
+
const csrfCookie = cookies[CSRF_COOKIE];
|
|
982
|
+
const csrfHeader = req.headers["x-csrf-token"];
|
|
983
|
+
if (!csrfCookie || !csrfHeader || csrfCookie !== csrfHeader) {
|
|
984
|
+
throw apiError(403, "INVALID_CREDENTIALS", "CSRF token mismatch.");
|
|
985
|
+
}
|
|
986
|
+
const raw = cookies[REFRESH_COOKIE];
|
|
987
|
+
if (!raw) throw apiError(401, "INVALID_CREDENTIALS", "No refresh token.");
|
|
988
|
+
const bundle = await this.tokens.rotateRefreshToken(raw, ctxOf(req));
|
|
989
|
+
setCookie(res, this.config, REFRESH_COOKIE, bundle.refreshToken, this.config.ttl.refreshTokenDays * 864e5);
|
|
990
|
+
return { accessToken: bundle.accessToken, expiresInSec: bundle.expiresInSec, csrfToken: csrfCookie };
|
|
991
|
+
}
|
|
992
|
+
async logout(req, res, sid) {
|
|
993
|
+
await this.tokens.revokeSession(sid);
|
|
994
|
+
clearCookie(res, this.config, REFRESH_COOKIE);
|
|
995
|
+
clearCookie(res, this.config, CSRF_COOKIE);
|
|
996
|
+
clearCookie(res, this.config, TRUSTED_DEVICE_COOKIE);
|
|
997
|
+
const auth = req.auth;
|
|
998
|
+
if (auth) await this.audit.record(auth.sub, "logout", ctxOf(req));
|
|
999
|
+
}
|
|
1000
|
+
async listActiveMethods(userId) {
|
|
1001
|
+
const docs = await this.cols.methods.find({ userId, status: "active" }).toArray();
|
|
1002
|
+
return docs.sort((a, b) => Number(b.isDefault) - Number(a.isDefault) || a.createdAt.getTime() - b.createdAt.getTime()).map(toApiMethod);
|
|
1003
|
+
}
|
|
1004
|
+
};
|
|
1005
|
+
|
|
1006
|
+
// src/services/mfa.service.ts
|
|
1007
|
+
var import_mongodb2 = require("mongodb");
|
|
1008
|
+
init_hash();
|
|
1009
|
+
|
|
1010
|
+
// src/middleware/rateLimit.ts
|
|
1011
|
+
function rateLimit(cols, config, bucket, max, windowMs) {
|
|
1012
|
+
return async (req, _res, next) => {
|
|
1013
|
+
if (!config.rateLimits) {
|
|
1014
|
+
next();
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
const now = config.clock().getTime();
|
|
1018
|
+
const slot = Math.floor(now / windowMs);
|
|
1019
|
+
const key = `${bucket}:${req.ip ?? "unknown"}:${slot}`;
|
|
1020
|
+
const expiresAt = new Date((slot + 1) * windowMs + 6e4);
|
|
1021
|
+
try {
|
|
1022
|
+
const doc = await cols.rateLimits.findOneAndUpdate(
|
|
1023
|
+
{ _id: key },
|
|
1024
|
+
{ $inc: { count: 1 }, $setOnInsert: { expiresAt } },
|
|
1025
|
+
{ upsert: true, returnDocument: "after" }
|
|
1026
|
+
);
|
|
1027
|
+
const count = doc?.count ?? 1;
|
|
1028
|
+
if (count > max) {
|
|
1029
|
+
next(
|
|
1030
|
+
apiError(429, "RATE_LIMITED", "Too many requests. Try again later.", {
|
|
1031
|
+
retryAt: new Date((slot + 1) * windowMs)
|
|
1032
|
+
})
|
|
1033
|
+
);
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
next();
|
|
1037
|
+
} catch {
|
|
1038
|
+
next();
|
|
1039
|
+
}
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
async function consumeUserQuota(cols, config, bucket, userId, max, windowMs) {
|
|
1043
|
+
const now = config.clock().getTime();
|
|
1044
|
+
const slot = Math.floor(now / windowMs);
|
|
1045
|
+
const key = `${bucket}:u:${userId}:${slot}`;
|
|
1046
|
+
const retryAt = new Date((slot + 1) * windowMs);
|
|
1047
|
+
const doc = await cols.rateLimits.findOneAndUpdate(
|
|
1048
|
+
{ _id: key },
|
|
1049
|
+
{ $inc: { count: 1 }, $setOnInsert: { expiresAt: new Date((slot + 1) * windowMs + 6e4) } },
|
|
1050
|
+
{ upsert: true, returnDocument: "after" }
|
|
1051
|
+
);
|
|
1052
|
+
return { allowed: (doc?.count ?? 1) <= max, retryAt };
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// src/services/mfa.service.ts
|
|
1056
|
+
var MfaService = class {
|
|
1057
|
+
constructor(config, cols, tokens, audit, auth) {
|
|
1058
|
+
this.config = config;
|
|
1059
|
+
this.cols = cols;
|
|
1060
|
+
this.tokens = tokens;
|
|
1061
|
+
this.audit = audit;
|
|
1062
|
+
this.auth = auth;
|
|
1063
|
+
this.encKey = decodeEncryptionKey(config.encryptionKey);
|
|
1064
|
+
}
|
|
1065
|
+
config;
|
|
1066
|
+
cols;
|
|
1067
|
+
tokens;
|
|
1068
|
+
audit;
|
|
1069
|
+
auth;
|
|
1070
|
+
encKey;
|
|
1071
|
+
async getChallenge(challengeId, purpose) {
|
|
1072
|
+
const ch = await this.cols.challenges.findOne({ _id: challengeId, purpose });
|
|
1073
|
+
if (!ch || ch.expiresAt.getTime() <= this.config.clock().getTime()) {
|
|
1074
|
+
throw apiError(410, "CHALLENGE_EXPIRED", "This verification challenge has expired. Sign in again.");
|
|
1075
|
+
}
|
|
1076
|
+
return ch;
|
|
1077
|
+
}
|
|
1078
|
+
/** BR-5/BR-6 — send (or resend) an SMS or email code for a challenge. */
|
|
1079
|
+
async sendCode(challengeId, methodId) {
|
|
1080
|
+
const ch = await this.getChallenge(challengeId, "login");
|
|
1081
|
+
const now = this.config.clock();
|
|
1082
|
+
const limits = this.config.limits;
|
|
1083
|
+
const method = await this.cols.methods.findOne({
|
|
1084
|
+
_id: this.objectId(methodId),
|
|
1085
|
+
userId: ch.userId,
|
|
1086
|
+
type: { $in: ["SMS", "EMAIL"] },
|
|
1087
|
+
status: "active"
|
|
1088
|
+
});
|
|
1089
|
+
if (!method) throw apiError(400, "VALIDATION_ERROR", "Unknown method.");
|
|
1090
|
+
const limited = this.config.rateLimits;
|
|
1091
|
+
if (limited && ch.smsSentAt && now.getTime() - ch.smsSentAt.getTime() < limits.smsResendSec * 1e3) {
|
|
1092
|
+
throw apiError(429, "RATE_LIMITED", "Please wait before requesting another code.", {
|
|
1093
|
+
retryAt: new Date(ch.smsSentAt.getTime() + limits.smsResendSec * 1e3)
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
if (limited && ch.smsSendCount >= limits.smsMaxPerChallenge) {
|
|
1097
|
+
throw apiError(429, "RATE_LIMITED", "Too many codes sent for this challenge.", { retryAt: ch.expiresAt });
|
|
1098
|
+
}
|
|
1099
|
+
const quota = limited ? await consumeUserQuota(this.cols, this.config, `${method.type.toLowerCase()}_send`, ch.userId, limits.smsMaxPerUserHour, 36e5) : { allowed: true, retryAt: void 0 };
|
|
1100
|
+
if (!quota.allowed) {
|
|
1101
|
+
throw apiError(429, "RATE_LIMITED", "Too many codes sent. Try again later.", { retryAt: quota.retryAt });
|
|
1102
|
+
}
|
|
1103
|
+
const code = randomSmsCode();
|
|
1104
|
+
await this.cols.challenges.updateOne(
|
|
1105
|
+
{ _id: challengeId },
|
|
1106
|
+
{ $set: { methodId, smsCodeHash: sha256Hex(code), smsSentAt: now }, $inc: { smsSendCount: 1 } }
|
|
1107
|
+
);
|
|
1108
|
+
const sentTo = await deliverCode(this.config, this.encKey, method, code);
|
|
1109
|
+
return { sentTo, resendAfterSec: limited ? limits.smsResendSec : 0 };
|
|
1110
|
+
}
|
|
1111
|
+
/** BR-7..BR-10 — verify a 6-digit code against a login challenge. */
|
|
1112
|
+
async verify(req, res, input) {
|
|
1113
|
+
const ch = await this.getChallenge(input.challengeId, "login");
|
|
1114
|
+
const now = this.config.clock();
|
|
1115
|
+
const method = await this.cols.methods.findOne({ _id: this.objectId(input.methodId), userId: ch.userId, status: "active" });
|
|
1116
|
+
if (!method) throw apiError(400, "VALIDATION_ERROR", "Unknown method.");
|
|
1117
|
+
if (method.type === "PASSKEY") throw apiError(400, "VALIDATION_ERROR", "Use your passkey to verify.");
|
|
1118
|
+
const ok = await this.checkCode(ch, method, input.code, now);
|
|
1119
|
+
if (!ok) {
|
|
1120
|
+
const attempts = ch.attempts + 1;
|
|
1121
|
+
const attemptsLeft = this.config.limits.mfaMaxAttempts - attempts;
|
|
1122
|
+
if (attemptsLeft <= 0) {
|
|
1123
|
+
await this.cols.challenges.deleteOne({ _id: ch._id });
|
|
1124
|
+
const lockedUntil = new Date(now.getTime() + this.config.limits.lockMinutes * 6e4);
|
|
1125
|
+
await this.cols.credentials.updateOne({ userId: ch.userId }, { $set: { lockedUntil, updatedAt: now } });
|
|
1126
|
+
await this.audit.record(ch.userId, "mfa_locked", ctxOf(req));
|
|
1127
|
+
throw apiError(423, "LOCKED", "Too many attempts. Try again later.", { retryAt: lockedUntil });
|
|
1128
|
+
}
|
|
1129
|
+
await this.cols.challenges.updateOne({ _id: ch._id }, { $set: { attempts } });
|
|
1130
|
+
await this.audit.record(ch.userId, "mfa_failed", ctxOf(req));
|
|
1131
|
+
throw apiError(401, "INVALID_CODE", "That code didn't work. Try again.", { attemptsLeft });
|
|
1132
|
+
}
|
|
1133
|
+
await this.cols.challenges.deleteOne({ _id: ch._id });
|
|
1134
|
+
await this.cols.methods.updateOne({ _id: method._id }, { $set: { lastUsedAt: now } });
|
|
1135
|
+
await this.audit.record(ch.userId, "login_success", ctxOf(req));
|
|
1136
|
+
const user = await this.config.userAdapter.findById(ch.userId);
|
|
1137
|
+
const ua = req.headers["user-agent"];
|
|
1138
|
+
const result = await this.auth.authenticate(ch.userId, req, res, ["pwd", "otp"], {
|
|
1139
|
+
rememberDevice: input.rememberDevice,
|
|
1140
|
+
deviceLabel: typeof ua === "string" ? ua.slice(0, 80) : void 0
|
|
1141
|
+
});
|
|
1142
|
+
res.json({ ...result, user: result.user ?? { id: ch.userId, username: user?.username, email: user?.email } });
|
|
1143
|
+
}
|
|
1144
|
+
async checkCode(ch, method, code, now) {
|
|
1145
|
+
if (method.type === "TOTP") {
|
|
1146
|
+
if (!method.secretEnc) return false;
|
|
1147
|
+
const secret = decryptMethodSecret(method.secretEnc, { [this.config.keyId]: this.encKey });
|
|
1148
|
+
const check = checkTotp(code, secret, now.getTime());
|
|
1149
|
+
if (check.valid && check.step !== null && check.step > (method.lastUsedStep ?? -1)) {
|
|
1150
|
+
await this.cols.methods.updateOne({ _id: method._id }, { $set: { lastUsedStep: check.step } });
|
|
1151
|
+
return true;
|
|
1152
|
+
}
|
|
1153
|
+
return false;
|
|
1154
|
+
}
|
|
1155
|
+
const valid = !!ch.smsCodeHash && timingSafeEqualStr(ch.smsCodeHash, sha256Hex(code));
|
|
1156
|
+
if (valid) await this.cols.challenges.updateOne({ _id: ch._id }, { $set: { smsCodeHash: null } });
|
|
1157
|
+
return valid;
|
|
1158
|
+
}
|
|
1159
|
+
/** BR-13 — step-up: verify a fresh TOTP/SMS/email code, return a 5-min stepUpToken. */
|
|
1160
|
+
async stepUp(req, input, auth) {
|
|
1161
|
+
const method = await this.cols.methods.findOne({ _id: this.objectId(input.methodId), userId: auth.sub, status: "active" });
|
|
1162
|
+
if (!method) throw apiError(400, "VALIDATION_ERROR", "Unknown method.");
|
|
1163
|
+
if (method.type === "PASSKEY") throw apiError(400, "VALIDATION_ERROR", "Choose a method that sends or shows a code.");
|
|
1164
|
+
const now = this.config.clock();
|
|
1165
|
+
if (method.type !== "TOTP" && !input.code) {
|
|
1166
|
+
const challengeId = randomId("chg");
|
|
1167
|
+
const code = randomSmsCode();
|
|
1168
|
+
await this.cols.challenges.insertOne({
|
|
1169
|
+
_id: challengeId,
|
|
1170
|
+
userId: auth.sub,
|
|
1171
|
+
purpose: "step_up",
|
|
1172
|
+
methodId: input.methodId,
|
|
1173
|
+
smsCodeHash: sha256Hex(code),
|
|
1174
|
+
smsSentAt: now,
|
|
1175
|
+
smsSendCount: 1,
|
|
1176
|
+
attempts: 0,
|
|
1177
|
+
expiresAt: new Date(now.getTime() + this.config.ttl.challengeSec * 1e3),
|
|
1178
|
+
createdAt: now
|
|
1179
|
+
});
|
|
1180
|
+
const sentTo = await deliverCode(this.config, this.encKey, method, code);
|
|
1181
|
+
return { challengeId, sentTo, resendAfterSec: this.config.rateLimits ? this.config.limits.smsResendSec : 0 };
|
|
1182
|
+
}
|
|
1183
|
+
if (!input.code) throw apiError(400, "VALIDATION_ERROR", "code is required.");
|
|
1184
|
+
let ok = false;
|
|
1185
|
+
if (method.type === "TOTP") {
|
|
1186
|
+
const secret = decryptMethodSecret(method.secretEnc, { [this.config.keyId]: this.encKey });
|
|
1187
|
+
const check = checkTotp(input.code, secret, now.getTime());
|
|
1188
|
+
if (check.valid && check.step !== null && check.step > (method.lastUsedStep ?? -1)) {
|
|
1189
|
+
ok = true;
|
|
1190
|
+
await this.cols.methods.updateOne({ _id: method._id }, { $set: { lastUsedStep: check.step } });
|
|
1191
|
+
}
|
|
1192
|
+
} else {
|
|
1193
|
+
const ch = await this.cols.challenges.findOne(
|
|
1194
|
+
{ userId: auth.sub, purpose: "step_up", methodId: input.methodId },
|
|
1195
|
+
{ sort: { createdAt: -1 } }
|
|
1196
|
+
);
|
|
1197
|
+
if (ch && ch.expiresAt.getTime() > now.getTime() && ch.smsCodeHash && timingSafeEqualStr(ch.smsCodeHash, sha256Hex(input.code))) {
|
|
1198
|
+
ok = true;
|
|
1199
|
+
await this.cols.challenges.deleteOne({ _id: ch._id });
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
if (!ok) {
|
|
1203
|
+
await this.audit.record(auth.sub, "step_up_failed", ctxOf(req));
|
|
1204
|
+
throw apiError(401, "INVALID_CODE", "That code didn't work. Try again.");
|
|
1205
|
+
}
|
|
1206
|
+
await this.cols.methods.updateOne({ _id: method._id }, { $set: { lastUsedAt: now } });
|
|
1207
|
+
await this.audit.record(auth.sub, "step_up_success", ctxOf(req));
|
|
1208
|
+
const { token, expiresInSec } = await this.tokens.signStepUpToken(auth.sub, auth.sid);
|
|
1209
|
+
return { stepUpToken: token, expiresInSec };
|
|
1210
|
+
}
|
|
1211
|
+
objectId(id) {
|
|
1212
|
+
try {
|
|
1213
|
+
return new import_mongodb2.ObjectId(id);
|
|
1214
|
+
} catch {
|
|
1215
|
+
throw apiError(400, "VALIDATION_ERROR", "Invalid id.");
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
};
|
|
1219
|
+
|
|
1220
|
+
// src/services/passkey.service.ts
|
|
1221
|
+
var import_server = require("@simplewebauthn/server");
|
|
1222
|
+
init_hash();
|
|
1223
|
+
var PasskeyService = class {
|
|
1224
|
+
constructor(config, cols, audit, auth) {
|
|
1225
|
+
this.config = config;
|
|
1226
|
+
this.cols = cols;
|
|
1227
|
+
this.audit = audit;
|
|
1228
|
+
this.auth = auth;
|
|
1229
|
+
}
|
|
1230
|
+
config;
|
|
1231
|
+
cols;
|
|
1232
|
+
audit;
|
|
1233
|
+
auth;
|
|
1234
|
+
rp() {
|
|
1235
|
+
if (!this.config.passkeys) throw apiError(400, "VALIDATION_ERROR", "Passkeys are not enabled.");
|
|
1236
|
+
return this.config.passkeys;
|
|
1237
|
+
}
|
|
1238
|
+
activePasskeys(userId) {
|
|
1239
|
+
return this.cols.methods.find({ userId, type: "PASSKEY", status: "active" }).toArray();
|
|
1240
|
+
}
|
|
1241
|
+
descriptor(m) {
|
|
1242
|
+
return { id: m.credentialId, transports: m.transports };
|
|
1243
|
+
}
|
|
1244
|
+
async liveChallenge(filter) {
|
|
1245
|
+
const ch = await this.cols.challenges.findOne(filter);
|
|
1246
|
+
if (!ch || ch.expiresAt.getTime() <= this.config.clock().getTime()) {
|
|
1247
|
+
throw apiError(410, "CHALLENGE_EXPIRED", "This verification challenge has expired. Sign in again.");
|
|
1248
|
+
}
|
|
1249
|
+
return ch;
|
|
1250
|
+
}
|
|
1251
|
+
/** Step 1 of adding a passkey: WebAuthn creation options for the signed-in user. */
|
|
1252
|
+
async registrationOptions(userId) {
|
|
1253
|
+
const rp = this.rp();
|
|
1254
|
+
const now = this.config.clock();
|
|
1255
|
+
if (await this.cols.methods.countDocuments({ userId }) >= this.config.limits.maxMethods) {
|
|
1256
|
+
throw apiError(409, "VALIDATION_ERROR", `You can add at most ${this.config.limits.maxMethods} verification methods.`);
|
|
1257
|
+
}
|
|
1258
|
+
const user = await this.config.userAdapter.findById(userId);
|
|
1259
|
+
const name = user?.email ?? user?.username ?? userId;
|
|
1260
|
+
const options = await (0, import_server.generateRegistrationOptions)({
|
|
1261
|
+
rpName: rp.rpName,
|
|
1262
|
+
rpID: rp.rpID,
|
|
1263
|
+
userName: name,
|
|
1264
|
+
userDisplayName: name,
|
|
1265
|
+
userID: new TextEncoder().encode(userId),
|
|
1266
|
+
attestationType: "none",
|
|
1267
|
+
excludeCredentials: (await this.activePasskeys(userId)).map((m) => this.descriptor(m)),
|
|
1268
|
+
authenticatorSelection: { residentKey: "preferred", userVerification: "preferred" }
|
|
1269
|
+
});
|
|
1270
|
+
await this.cols.challenges.deleteMany({ userId, purpose: "enroll" });
|
|
1271
|
+
await this.cols.challenges.insertOne({
|
|
1272
|
+
_id: randomId("chg"),
|
|
1273
|
+
userId,
|
|
1274
|
+
purpose: "enroll",
|
|
1275
|
+
methodId: null,
|
|
1276
|
+
smsCodeHash: null,
|
|
1277
|
+
smsSentAt: null,
|
|
1278
|
+
smsSendCount: 0,
|
|
1279
|
+
attempts: 0,
|
|
1280
|
+
webauthnChallenge: options.challenge,
|
|
1281
|
+
expiresAt: new Date(now.getTime() + this.config.ttl.challengeSec * 1e3),
|
|
1282
|
+
createdAt: now
|
|
1283
|
+
});
|
|
1284
|
+
return options;
|
|
1285
|
+
}
|
|
1286
|
+
/** Step 2 of adding a passkey: verify the browser's attestation and store the credential. */
|
|
1287
|
+
async register(userId, input) {
|
|
1288
|
+
const rp = this.rp();
|
|
1289
|
+
const ch = await this.liveChallenge({ userId, purpose: "enroll" });
|
|
1290
|
+
let verification;
|
|
1291
|
+
try {
|
|
1292
|
+
verification = await (0, import_server.verifyRegistrationResponse)({
|
|
1293
|
+
response: input.response,
|
|
1294
|
+
expectedChallenge: ch.webauthnChallenge ?? "",
|
|
1295
|
+
expectedOrigin: rp.origins,
|
|
1296
|
+
expectedRPID: rp.rpID,
|
|
1297
|
+
requireUserVerification: false
|
|
1298
|
+
});
|
|
1299
|
+
} catch {
|
|
1300
|
+
verification = null;
|
|
1301
|
+
}
|
|
1302
|
+
if (!verification?.verified || !verification.registrationInfo) {
|
|
1303
|
+
throw apiError(400, "VALIDATION_ERROR", "We couldn't add that passkey. Try again.");
|
|
1304
|
+
}
|
|
1305
|
+
await this.cols.challenges.deleteOne({ _id: ch._id });
|
|
1306
|
+
const { credential } = verification.registrationInfo;
|
|
1307
|
+
if (await this.cols.methods.findOne({ type: "PASSKEY", credentialId: credential.id })) {
|
|
1308
|
+
throw apiError(409, "VALIDATION_ERROR", "That passkey is already added.");
|
|
1309
|
+
}
|
|
1310
|
+
const now = this.config.clock();
|
|
1311
|
+
const hasDefault = !!await this.cols.methods.findOne({ userId, status: "active", isDefault: true });
|
|
1312
|
+
const doc = {
|
|
1313
|
+
userId,
|
|
1314
|
+
type: "PASSKEY",
|
|
1315
|
+
label: input.label?.trim() || "Passkey",
|
|
1316
|
+
isDefault: !hasDefault,
|
|
1317
|
+
status: "active",
|
|
1318
|
+
credentialId: credential.id,
|
|
1319
|
+
publicKey: Buffer.from(credential.publicKey).toString("base64url"),
|
|
1320
|
+
counter: credential.counter,
|
|
1321
|
+
transports: credential.transports,
|
|
1322
|
+
createdAt: now,
|
|
1323
|
+
lastUsedAt: null
|
|
1324
|
+
};
|
|
1325
|
+
const { insertedId } = await this.cols.methods.insertOne(doc);
|
|
1326
|
+
await this.audit.record(userId, "method_added", { meta: { type: "PASSKEY" } });
|
|
1327
|
+
return toApiMethod({ ...doc, _id: insertedId });
|
|
1328
|
+
}
|
|
1329
|
+
/** Sign-in code step: WebAuthn request options for the user behind a login challenge. */
|
|
1330
|
+
async loginOptions(challengeId) {
|
|
1331
|
+
const rp = this.rp();
|
|
1332
|
+
const ch = await this.liveChallenge({ _id: challengeId, purpose: "login" });
|
|
1333
|
+
const passkeys = await this.activePasskeys(ch.userId);
|
|
1334
|
+
if (passkeys.length === 0) throw apiError(400, "VALIDATION_ERROR", "There's no passkey on this account.");
|
|
1335
|
+
const options = await (0, import_server.generateAuthenticationOptions)({
|
|
1336
|
+
rpID: rp.rpID,
|
|
1337
|
+
allowCredentials: passkeys.map((m) => this.descriptor(m)),
|
|
1338
|
+
userVerification: "preferred"
|
|
1339
|
+
});
|
|
1340
|
+
await this.cols.challenges.updateOne({ _id: ch._id }, { $set: { webauthnChallenge: options.challenge } });
|
|
1341
|
+
return options;
|
|
1342
|
+
}
|
|
1343
|
+
/** Sign-in code step: verify the passkey assertion and finish signing in. */
|
|
1344
|
+
async loginVerify(req, res, input) {
|
|
1345
|
+
const rp = this.rp();
|
|
1346
|
+
const ch = await this.liveChallenge({ _id: input.challengeId, purpose: "login" });
|
|
1347
|
+
if (!ch.webauthnChallenge) throw apiError(400, "VALIDATION_ERROR", "Start the passkey check again.");
|
|
1348
|
+
const response = input.response;
|
|
1349
|
+
const method = await this.cols.methods.findOne({
|
|
1350
|
+
userId: ch.userId,
|
|
1351
|
+
type: "PASSKEY",
|
|
1352
|
+
status: "active",
|
|
1353
|
+
credentialId: typeof response.id === "string" ? response.id : ""
|
|
1354
|
+
});
|
|
1355
|
+
if (!method) throw apiError(401, "INVALID_CODE", "That passkey isn't on this account.");
|
|
1356
|
+
let verification;
|
|
1357
|
+
try {
|
|
1358
|
+
verification = await (0, import_server.verifyAuthenticationResponse)({
|
|
1359
|
+
response,
|
|
1360
|
+
expectedChallenge: ch.webauthnChallenge,
|
|
1361
|
+
expectedOrigin: rp.origins,
|
|
1362
|
+
expectedRPID: rp.rpID,
|
|
1363
|
+
credential: {
|
|
1364
|
+
id: method.credentialId,
|
|
1365
|
+
publicKey: Buffer.from(method.publicKey, "base64url"),
|
|
1366
|
+
counter: method.counter ?? 0,
|
|
1367
|
+
transports: method.transports
|
|
1368
|
+
},
|
|
1369
|
+
requireUserVerification: false
|
|
1370
|
+
});
|
|
1371
|
+
} catch {
|
|
1372
|
+
verification = null;
|
|
1373
|
+
}
|
|
1374
|
+
if (!verification?.verified) {
|
|
1375
|
+
await this.audit.record(ch.userId, "mfa_failed", ctxOf(req));
|
|
1376
|
+
throw apiError(401, "INVALID_CODE", "We couldn't verify your passkey. Try again.");
|
|
1377
|
+
}
|
|
1378
|
+
const now = this.config.clock();
|
|
1379
|
+
await this.cols.challenges.deleteOne({ _id: ch._id });
|
|
1380
|
+
await this.cols.methods.updateOne(
|
|
1381
|
+
{ _id: method._id },
|
|
1382
|
+
{ $set: { counter: verification.authenticationInfo.newCounter, lastUsedAt: now } }
|
|
1383
|
+
);
|
|
1384
|
+
await this.audit.record(ch.userId, "login_success", ctxOf(req));
|
|
1385
|
+
const ua = req.headers["user-agent"];
|
|
1386
|
+
const result = await this.auth.authenticate(ch.userId, req, res, ["pwd", "hwk"], {
|
|
1387
|
+
rememberDevice: input.rememberDevice,
|
|
1388
|
+
deviceLabel: typeof ua === "string" ? ua.slice(0, 80) : void 0
|
|
1389
|
+
});
|
|
1390
|
+
res.json(result);
|
|
1391
|
+
}
|
|
1392
|
+
};
|
|
1393
|
+
|
|
1394
|
+
// src/services/token.service.ts
|
|
1395
|
+
var import_node_crypto3 = __toESM(require("crypto"), 1);
|
|
1396
|
+
var import_jose = require("jose");
|
|
1397
|
+
init_hash();
|
|
1398
|
+
var TokenService = class {
|
|
1399
|
+
constructor(config, cols) {
|
|
1400
|
+
this.config = config;
|
|
1401
|
+
this.cols = cols;
|
|
1402
|
+
const keyObj = import_node_crypto3.default.createPrivateKey(config.jwtPrivateKey);
|
|
1403
|
+
this.alg = keyObj.asymmetricKeyType === "ed25519" ? "EdDSA" : "RS256";
|
|
1404
|
+
this.privateKeyPromise = (0, import_jose.importPKCS8)(config.jwtPrivateKey, this.alg);
|
|
1405
|
+
this.publicKeyPromise = (0, import_jose.importSPKI)(
|
|
1406
|
+
import_node_crypto3.default.createPublicKey(keyObj).export({ type: "spki", format: "pem" }).toString(),
|
|
1407
|
+
this.alg
|
|
1408
|
+
);
|
|
1409
|
+
}
|
|
1410
|
+
config;
|
|
1411
|
+
cols;
|
|
1412
|
+
alg;
|
|
1413
|
+
privateKeyPromise;
|
|
1414
|
+
publicKeyPromise;
|
|
1415
|
+
async signAccessToken(userId, sid, amr) {
|
|
1416
|
+
const key = await this.privateKeyPromise;
|
|
1417
|
+
return new import_jose.SignJWT({ sid, amr }).setProtectedHeader({ alg: this.alg }).setIssuer(this.config.issuer).setSubject(userId).setIssuedAt().setExpirationTime(`${this.config.ttl.accessTokenSec}s`).sign(key);
|
|
1418
|
+
}
|
|
1419
|
+
async verifyAccessToken(token) {
|
|
1420
|
+
try {
|
|
1421
|
+
const { payload } = await (0, import_jose.jwtVerify)(token, await this.publicKeyPromise, {
|
|
1422
|
+
issuer: this.config.issuer
|
|
1423
|
+
});
|
|
1424
|
+
return { sub: payload.sub, sid: payload.sid, amr: payload.amr ?? [] };
|
|
1425
|
+
} catch {
|
|
1426
|
+
throw apiError(401, "INVALID_CREDENTIALS", "Invalid or expired access token.");
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
/** Create a new session + refresh token; returns tokens for the client. */
|
|
1430
|
+
async issueTokens(userId, amr, meta, familyId) {
|
|
1431
|
+
const now = this.config.clock();
|
|
1432
|
+
const sid = randomId("ses");
|
|
1433
|
+
const refreshToken = randomToken();
|
|
1434
|
+
const expiresAt = new Date(now.getTime() + this.config.ttl.refreshTokenDays * 864e5);
|
|
1435
|
+
const doc = {
|
|
1436
|
+
_id: sid,
|
|
1437
|
+
userId,
|
|
1438
|
+
refreshTokenHash: sha256Hex(refreshToken),
|
|
1439
|
+
familyId: familyId ?? randomId("fam"),
|
|
1440
|
+
rotatedFrom: null,
|
|
1441
|
+
userAgent: meta.userAgent,
|
|
1442
|
+
ip: meta.ip,
|
|
1443
|
+
createdAt: now,
|
|
1444
|
+
expiresAt,
|
|
1445
|
+
revokedAt: null
|
|
1446
|
+
};
|
|
1447
|
+
await this.cols.sessions.insertOne(doc);
|
|
1448
|
+
return {
|
|
1449
|
+
accessToken: await this.signAccessToken(userId, sid, amr),
|
|
1450
|
+
expiresInSec: this.config.ttl.accessTokenSec,
|
|
1451
|
+
refreshToken,
|
|
1452
|
+
sessionId: sid
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1455
|
+
/**
|
|
1456
|
+
* Rotate a refresh token (BR-11). Reuse of a rotated token revokes the whole
|
|
1457
|
+
* family. Old hashes are retained in `usedHashes` so reuse is detectable.
|
|
1458
|
+
*/
|
|
1459
|
+
async rotateRefreshToken(rawToken, meta) {
|
|
1460
|
+
const hash = sha256Hex(rawToken);
|
|
1461
|
+
const now = this.config.clock();
|
|
1462
|
+
const session = await this.cols.sessions.findOne({
|
|
1463
|
+
$or: [{ refreshTokenHash: hash }, { usedHashes: hash }]
|
|
1464
|
+
});
|
|
1465
|
+
if (!session) {
|
|
1466
|
+
throw apiError(401, "INVALID_CREDENTIALS", "Invalid refresh token.");
|
|
1467
|
+
}
|
|
1468
|
+
if (session.refreshTokenHash !== hash) {
|
|
1469
|
+
await this.cols.sessions.updateMany(
|
|
1470
|
+
{ familyId: session.familyId, revokedAt: null },
|
|
1471
|
+
{ $set: { revokedAt: now } }
|
|
1472
|
+
);
|
|
1473
|
+
throw apiError(401, "INVALID_CREDENTIALS", "Refresh token reuse detected; session revoked.");
|
|
1474
|
+
}
|
|
1475
|
+
if (session.revokedAt || session.expiresAt.getTime() <= now.getTime()) {
|
|
1476
|
+
throw apiError(401, "INVALID_CREDENTIALS", "Session expired.");
|
|
1477
|
+
}
|
|
1478
|
+
const newToken = randomToken();
|
|
1479
|
+
await this.cols.sessions.updateOne(
|
|
1480
|
+
{ _id: session._id, refreshTokenHash: hash },
|
|
1481
|
+
{
|
|
1482
|
+
$set: {
|
|
1483
|
+
refreshTokenHash: sha256Hex(newToken),
|
|
1484
|
+
userAgent: meta.userAgent,
|
|
1485
|
+
ip: meta.ip
|
|
1486
|
+
},
|
|
1487
|
+
$push: { usedHashes: { $each: [hash], $slice: -200 } }
|
|
1488
|
+
}
|
|
1489
|
+
);
|
|
1490
|
+
return {
|
|
1491
|
+
accessToken: await this.signAccessToken(session.userId, session._id, ["pwd", "otp"]),
|
|
1492
|
+
expiresInSec: this.config.ttl.accessTokenSec,
|
|
1493
|
+
refreshToken: newToken,
|
|
1494
|
+
sessionId: session._id
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
async revokeSession(sid) {
|
|
1498
|
+
await this.cols.sessions.updateOne({ _id: sid }, { $set: { revokedAt: this.config.clock() } });
|
|
1499
|
+
}
|
|
1500
|
+
/** 5-minute step-up JWT bound to sub+sid (BR-13). */
|
|
1501
|
+
async signStepUpToken(userId, sid) {
|
|
1502
|
+
const key = await this.privateKeyPromise;
|
|
1503
|
+
const token = await new import_jose.SignJWT({ sid, purpose: "step_up" }).setProtectedHeader({ alg: this.alg }).setIssuer(this.config.issuer).setSubject(userId).setIssuedAt().setExpirationTime(`${this.config.ttl.stepUpSec}s`).sign(key);
|
|
1504
|
+
return { token, expiresInSec: this.config.ttl.stepUpSec };
|
|
1505
|
+
}
|
|
1506
|
+
async verifyStepUpToken(token, userId, sid) {
|
|
1507
|
+
try {
|
|
1508
|
+
const { payload } = await (0, import_jose.jwtVerify)(token, await this.publicKeyPromise, {
|
|
1509
|
+
issuer: this.config.issuer
|
|
1510
|
+
});
|
|
1511
|
+
if (payload.purpose !== "step_up" || payload.sub !== userId || payload.sid !== sid) {
|
|
1512
|
+
throw new Error("mismatch");
|
|
1513
|
+
}
|
|
1514
|
+
} catch {
|
|
1515
|
+
throw apiError(403, "STEP_UP_REQUIRED", "This action requires recent verification.");
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
};
|
|
1519
|
+
|
|
1520
|
+
// src/services/audit.service.ts
|
|
1521
|
+
var AuditService = class {
|
|
1522
|
+
constructor(config, cols) {
|
|
1523
|
+
this.config = config;
|
|
1524
|
+
this.cols = cols;
|
|
1525
|
+
}
|
|
1526
|
+
config;
|
|
1527
|
+
cols;
|
|
1528
|
+
async record(userId, type, meta = {}) {
|
|
1529
|
+
const event = {
|
|
1530
|
+
userId,
|
|
1531
|
+
type,
|
|
1532
|
+
ip: meta.ip,
|
|
1533
|
+
userAgent: meta.userAgent,
|
|
1534
|
+
at: this.config.clock(),
|
|
1535
|
+
meta: meta.meta
|
|
1536
|
+
};
|
|
1537
|
+
await this.cols.events.insertOne(event);
|
|
1538
|
+
try {
|
|
1539
|
+
await this.config.hooks?.onSecurityEvent?.(event);
|
|
1540
|
+
} catch {
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
};
|
|
1544
|
+
|
|
1545
|
+
// src/routes/auth.routes.ts
|
|
1546
|
+
var import_express = require("express");
|
|
1547
|
+
var import_auth_2fa_contract = require("@cairs-npm/auth-2fa-contract");
|
|
1548
|
+
|
|
1549
|
+
// src/middleware/requireAuth.ts
|
|
1550
|
+
function requireAuth(tokens) {
|
|
1551
|
+
return async (req, _res, next) => {
|
|
1552
|
+
const header = req.headers.authorization ?? "";
|
|
1553
|
+
const token = header.startsWith("Bearer ") ? header.slice(7) : null;
|
|
1554
|
+
if (!token) {
|
|
1555
|
+
next(apiError(401, "INVALID_CREDENTIALS", "Missing access token."));
|
|
1556
|
+
return;
|
|
1557
|
+
}
|
|
1558
|
+
try {
|
|
1559
|
+
req.auth = await tokens.verifyAccessToken(token);
|
|
1560
|
+
next();
|
|
1561
|
+
} catch (err) {
|
|
1562
|
+
next(err);
|
|
1563
|
+
}
|
|
1564
|
+
};
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
// src/routes/auth.routes.ts
|
|
1568
|
+
var wrap = (fn) => (req, res, next) => fn(req, res).then((r) => r === void 0 ? void 0 : res.json(r)).catch(next);
|
|
1569
|
+
function buildAuthRouter(config, cols, services) {
|
|
1570
|
+
const router = (0, import_express.Router)();
|
|
1571
|
+
const { loginIpPerMin, verifyIpPerMin, stepUpIpPerMin } = config.limits;
|
|
1572
|
+
router.post("/auth/login", rateLimit(cols, config, "login_ip", loginIpPerMin, 6e4), wrap(async (req, res) => {
|
|
1573
|
+
const input = parseBody(import_auth_2fa_contract.loginRequestSchema, req.body);
|
|
1574
|
+
return services.auth.login(req, res, input);
|
|
1575
|
+
}));
|
|
1576
|
+
router.post("/auth/mfa/send", rateLimit(cols, config, "mfa_send_ip", verifyIpPerMin, 6e4), wrap(async (req) => {
|
|
1577
|
+
const input = parseBody(import_auth_2fa_contract.mfaSendRequestSchema, req.body);
|
|
1578
|
+
return services.mfa.sendCode(input.challengeId, input.methodId);
|
|
1579
|
+
}));
|
|
1580
|
+
router.post("/auth/mfa/verify", rateLimit(cols, config, "mfa_verify_ip", verifyIpPerMin, 6e4), async (req, res, next) => {
|
|
1581
|
+
try {
|
|
1582
|
+
const input = parseBody(import_auth_2fa_contract.mfaVerifyRequestSchema, req.body);
|
|
1583
|
+
await services.mfa.verify(req, res, input);
|
|
1584
|
+
} catch (err) {
|
|
1585
|
+
next(err);
|
|
1586
|
+
}
|
|
1587
|
+
});
|
|
1588
|
+
router.post("/auth/passkey/options", rateLimit(cols, config, "mfa_verify_ip", verifyIpPerMin, 6e4), wrap(async (req) => {
|
|
1589
|
+
const input = parseBody(import_auth_2fa_contract.passkeyLoginOptionsRequestSchema, req.body);
|
|
1590
|
+
return services.passkeys.loginOptions(input.challengeId);
|
|
1591
|
+
}));
|
|
1592
|
+
router.post("/auth/passkey/verify", rateLimit(cols, config, "mfa_verify_ip", verifyIpPerMin, 6e4), async (req, res, next) => {
|
|
1593
|
+
try {
|
|
1594
|
+
const input = parseBody(import_auth_2fa_contract.passkeyLoginVerifyRequestSchema, req.body);
|
|
1595
|
+
await services.passkeys.loginVerify(req, res, input);
|
|
1596
|
+
} catch (err) {
|
|
1597
|
+
next(err);
|
|
1598
|
+
}
|
|
1599
|
+
});
|
|
1600
|
+
router.post("/auth/refresh", wrap(async (req, res) => services.auth.refresh(req, res)));
|
|
1601
|
+
router.post("/auth/logout", requireAuth(services.tokens), wrap(async (req, res) => {
|
|
1602
|
+
await services.auth.logout(req, res, req.auth.sid);
|
|
1603
|
+
res.status(204).end();
|
|
1604
|
+
return void 0;
|
|
1605
|
+
}));
|
|
1606
|
+
router.post("/auth/step-up", requireAuth(services.tokens), rateLimit(cols, config, "step_up_ip", stepUpIpPerMin, 6e4), wrap(async (req) => {
|
|
1607
|
+
const input = parseBody(import_auth_2fa_contract.stepUpRequestSchema.extend({ code: import_auth_2fa_contract.stepUpRequestSchema.shape.code.optional() }), req.body);
|
|
1608
|
+
return services.mfa.stepUp(req, input, req.auth);
|
|
1609
|
+
}));
|
|
1610
|
+
if (config.exposeDocs) {
|
|
1611
|
+
router.get("/auth/openapi.json", (_req, res) => {
|
|
1612
|
+
res.json(buildOpenApiDoc());
|
|
1613
|
+
});
|
|
1614
|
+
}
|
|
1615
|
+
return router;
|
|
1616
|
+
}
|
|
1617
|
+
function buildOpenApiDoc() {
|
|
1618
|
+
return {
|
|
1619
|
+
openapi: "3.1.0",
|
|
1620
|
+
info: { title: "express-auth-2fa", version: "0.1.0" },
|
|
1621
|
+
paths: {
|
|
1622
|
+
"/auth/login": { post: { summary: "Password sign-in; returns AUTHENTICATED or MFA_REQUIRED" } },
|
|
1623
|
+
"/auth/mfa/send": { post: { summary: "Send/resend an SMS code for a challenge" } },
|
|
1624
|
+
"/auth/mfa/verify": { post: { summary: "Verify a 6-digit TOTP or SMS code" } },
|
|
1625
|
+
"/auth/refresh": { post: { summary: "Rotate refresh cookie; returns a new access token" } },
|
|
1626
|
+
"/auth/logout": { post: { summary: "Revoke the session and clear cookies" } },
|
|
1627
|
+
"/auth/step-up": { post: { summary: "Verify a fresh code; returns a 5-min stepUpToken" } },
|
|
1628
|
+
"/account/security": { get: { summary: "2FA status + active methods" } },
|
|
1629
|
+
"/account/security/two-step": { put: { summary: "Enable/disable 2-Step Verification" } },
|
|
1630
|
+
"/account/security/methods": { post: { summary: "Add a pending TOTP or SMS method" } },
|
|
1631
|
+
"/account/security/methods/{id}/confirm": { post: { summary: "Confirm a pending method with a code" } },
|
|
1632
|
+
"/account/security/methods/{id}": {
|
|
1633
|
+
patch: { summary: "Rename or set-default a method" },
|
|
1634
|
+
delete: { summary: "Remove a method" }
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
};
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
// src/routes/account.routes.ts
|
|
1641
|
+
var import_express2 = require("express");
|
|
1642
|
+
var import_auth_2fa_contract2 = require("@cairs-npm/auth-2fa-contract");
|
|
1643
|
+
var wrap2 = (fn) => (req, res, next) => fn(req, res).then((r) => r === void 0 ? void 0 : res.json(r)).catch(next);
|
|
1644
|
+
function buildAccountRouter(_config, _cols, services) {
|
|
1645
|
+
const router = (0, import_express2.Router)();
|
|
1646
|
+
const auth = requireAuth(services.tokens);
|
|
1647
|
+
router.get("/account/security", auth, wrap2(async (req) => {
|
|
1648
|
+
return services.methods.securitySummary(req.auth.sub);
|
|
1649
|
+
}));
|
|
1650
|
+
router.put("/account/security/two-step", auth, wrap2(async (req) => {
|
|
1651
|
+
const input = parseBody(import_auth_2fa_contract2.twoStepRequestSchema, req.body);
|
|
1652
|
+
return services.methods.setTwoStep(req.auth.sub, req.auth.sid, input.enabled, input.stepUpToken);
|
|
1653
|
+
}));
|
|
1654
|
+
router.post("/account/security/methods", auth, wrap2(async (req) => {
|
|
1655
|
+
const input = parseBody(import_auth_2fa_contract2.addMethodRequestSchema, req.body);
|
|
1656
|
+
return services.methods.addMethod(req.auth.sub, input);
|
|
1657
|
+
}));
|
|
1658
|
+
router.post("/account/security/passkeys/options", auth, wrap2(async (req) => {
|
|
1659
|
+
return services.passkeys.registrationOptions(req.auth.sub);
|
|
1660
|
+
}));
|
|
1661
|
+
router.post("/account/security/passkeys", auth, wrap2(async (req) => {
|
|
1662
|
+
const input = parseBody(import_auth_2fa_contract2.passkeyRegisterRequestSchema, req.body);
|
|
1663
|
+
return services.passkeys.register(req.auth.sub, input);
|
|
1664
|
+
}));
|
|
1665
|
+
router.post("/account/security/methods/:id/confirm", auth, wrap2(async (req) => {
|
|
1666
|
+
const input = parseBody(import_auth_2fa_contract2.confirmMethodRequestSchema, req.body);
|
|
1667
|
+
return services.methods.confirmMethod(req.auth.sub, req.params.id, input.code);
|
|
1668
|
+
}));
|
|
1669
|
+
router.patch("/account/security/methods/:id", auth, wrap2(async (req) => {
|
|
1670
|
+
const input = parseBody(import_auth_2fa_contract2.patchMethodRequestSchema, req.body);
|
|
1671
|
+
return services.methods.patchMethod(req.auth.sub, req.auth.sid, req.params.id, input);
|
|
1672
|
+
}));
|
|
1673
|
+
router.delete("/account/security/methods/:id", auth, wrap2(async (req, res) => {
|
|
1674
|
+
const input = parseBody(import_auth_2fa_contract2.deleteMethodRequestSchema, req.body);
|
|
1675
|
+
await services.methods.deleteMethod(req.auth.sub, req.auth.sid, req.params.id, input.stepUpToken);
|
|
1676
|
+
res.status(204).end();
|
|
1677
|
+
return void 0;
|
|
1678
|
+
}));
|
|
1679
|
+
return router;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
// src/adapters/sms/console.ts
|
|
1683
|
+
function consoleSms(logger) {
|
|
1684
|
+
return {
|
|
1685
|
+
send: async (phoneE164, message) => {
|
|
1686
|
+
const masked = `\u2022\u2022\u2022\u2022 ${phoneE164.slice(-4)}`;
|
|
1687
|
+
(logger ?? console).info({ to: masked }, message);
|
|
1688
|
+
}
|
|
1689
|
+
};
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
// src/adapters/sms/twilio.ts
|
|
1693
|
+
function twilioSms(opts) {
|
|
1694
|
+
const baseUrl = (opts.baseUrl ?? "https://api.twilio.com/2010-04-01").replace(/\/+$/, "");
|
|
1695
|
+
const auth = `Basic ${Buffer.from(`${opts.sid}:${opts.token}`).toString("base64")}`;
|
|
1696
|
+
return {
|
|
1697
|
+
send: async (phoneE164, message) => {
|
|
1698
|
+
if (opts.client) {
|
|
1699
|
+
await opts.client.messages.create({ to: phoneE164, from: opts.from, body: message });
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
const form = new URLSearchParams({ To: phoneE164, Body: message });
|
|
1703
|
+
form.set(opts.from.startsWith("MG") ? "MessagingServiceSid" : "From", opts.from);
|
|
1704
|
+
const res = await fetch(`${baseUrl}/Accounts/${encodeURIComponent(opts.sid)}/Messages.json`, {
|
|
1705
|
+
method: "POST",
|
|
1706
|
+
headers: { Authorization: auth, "Content-Type": "application/x-www-form-urlencoded" },
|
|
1707
|
+
body: form.toString()
|
|
1708
|
+
});
|
|
1709
|
+
if (!res.ok) {
|
|
1710
|
+
const detail = await res.json().catch(() => null);
|
|
1711
|
+
throw new Error(`twilio: SMS not sent (${res.status}${detail?.code ? ` ${detail.code}` : ""}): ${detail?.message ?? res.statusText}`);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
};
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
// src/adapters/email/console.ts
|
|
1718
|
+
function consoleEmail(logger) {
|
|
1719
|
+
return {
|
|
1720
|
+
send: async ({ to, message }) => {
|
|
1721
|
+
(logger ?? console).info({ to: maskEmail(to) }, message);
|
|
1722
|
+
}
|
|
1723
|
+
};
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
// src/index.ts
|
|
1727
|
+
async function migrate2(db, prefix = "auth_", auditDays = 365) {
|
|
1728
|
+
return migrate(db, prefix, auditDays);
|
|
1729
|
+
}
|
|
1730
|
+
function createAuthRouter(input) {
|
|
1731
|
+
const config = resolveConfig(input);
|
|
1732
|
+
const cols = getCollections(config.db, config.collectionPrefix);
|
|
1733
|
+
const tokens = new TokenService(config, cols);
|
|
1734
|
+
const audit = new AuditService(config, cols);
|
|
1735
|
+
const auth = new AuthService(config, cols, tokens, audit);
|
|
1736
|
+
const mfa = new MfaService(config, cols, tokens, audit, auth);
|
|
1737
|
+
const methods = new MethodService(config, cols, tokens, audit);
|
|
1738
|
+
const passkeys = new PasskeyService(config, cols, audit, auth);
|
|
1739
|
+
const router = (0, import_express3.Router)();
|
|
1740
|
+
router.use((0, import_helmet.default)());
|
|
1741
|
+
router.use(buildAuthRouter(config, cols, { auth, mfa, passkeys, tokens }));
|
|
1742
|
+
router.use(buildAccountRouter(config, cols, { methods, passkeys, tokens }));
|
|
1743
|
+
router.use(errorHandler);
|
|
1744
|
+
router.services = { auth, mfa, methods, passkeys, tokens, audit };
|
|
1745
|
+
return router;
|
|
1746
|
+
}
|
|
1747
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1748
|
+
0 && (module.exports = {
|
|
1749
|
+
ApiErrorException,
|
|
1750
|
+
AuthService,
|
|
1751
|
+
apiError,
|
|
1752
|
+
consoleEmail,
|
|
1753
|
+
consoleSms,
|
|
1754
|
+
createAuthRouter,
|
|
1755
|
+
errorHandler,
|
|
1756
|
+
getCollections,
|
|
1757
|
+
migrate,
|
|
1758
|
+
twilioSms
|
|
1759
|
+
});
|
|
1760
|
+
//# sourceMappingURL=index.cjs.map
|