@kb-labs/gateway-auth 2.94.0 → 2.96.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/dist/index.d.ts +974 -1
- package/dist/index.js +1503 -13
- package/dist/index.js.map +1 -1
- package/package.json +12 -7
package/dist/index.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import { randomBytes, randomUUID, createHash } from 'crypto';
|
|
2
|
-
import { SignJWT, jwtVerify } from 'jose';
|
|
1
|
+
import { randomBytes, randomUUID, timingSafeEqual, createHash } from 'crypto';
|
|
2
|
+
import { SignJWT, jwtVerify, createLocalJWKSet } from 'jose';
|
|
3
|
+
import bcrypt from 'bcryptjs';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { PERMISSIONS } from '@kb-labs/core-contracts';
|
|
3
6
|
|
|
4
7
|
// src/store.ts
|
|
5
8
|
var REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
@@ -24,6 +27,19 @@ function generateNamespaceId() {
|
|
|
24
27
|
async function saveClient(cache, record) {
|
|
25
28
|
await cache.set(`auth:client:${record.clientId}`, record);
|
|
26
29
|
await cache.set(`auth:hostindex:${record.hostId}`, record.clientId);
|
|
30
|
+
if (record.handle) {
|
|
31
|
+
await cache.set(`auth:handle:${record.handle}`, record.clientId);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async function isHandleTaken(cache, handle) {
|
|
35
|
+
return await cache.get(`auth:handle:${handle}`) !== null;
|
|
36
|
+
}
|
|
37
|
+
async function getClientByHandle(cache, handle) {
|
|
38
|
+
const clientId = await cache.get(`auth:handle:${handle}`);
|
|
39
|
+
if (!clientId) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
return getClient(cache, clientId);
|
|
27
43
|
}
|
|
28
44
|
async function getClientByHostId(cache, hostId) {
|
|
29
45
|
const clientId = await cache.get(`auth:hostindex:${hostId}`);
|
|
@@ -54,8 +70,11 @@ function buildClientRecord(opts) {
|
|
|
54
70
|
tier: "free",
|
|
55
71
|
name: opts.name,
|
|
56
72
|
capabilities: opts.capabilities,
|
|
73
|
+
permissions: opts.permissions,
|
|
57
74
|
publicKey: opts.publicKey,
|
|
58
|
-
createdAt: Date.now()
|
|
75
|
+
createdAt: Date.now(),
|
|
76
|
+
handle: opts.handle,
|
|
77
|
+
email: opts.email
|
|
59
78
|
};
|
|
60
79
|
}
|
|
61
80
|
async function saveRefreshToken(cache, token, hostId, namespaceId) {
|
|
@@ -87,7 +106,8 @@ async function signAccessToken(opts, config) {
|
|
|
87
106
|
const token = await new SignJWT({
|
|
88
107
|
namespaceId: opts.namespaceId,
|
|
89
108
|
tier: opts.tier,
|
|
90
|
-
type: opts.type
|
|
109
|
+
type: opts.type,
|
|
110
|
+
permissions: opts.permissions ?? []
|
|
91
111
|
}).setProtectedHeader({ alg: "HS256" }).setSubject(opts.hostId).setIssuedAt(now).setExpirationTime(now + ACCESS_TOKEN_TTL).sign(key);
|
|
92
112
|
return { token, expiresIn: ACCESS_TOKEN_TTL };
|
|
93
113
|
}
|
|
@@ -100,7 +120,7 @@ async function signRefreshToken(hostId, config) {
|
|
|
100
120
|
async function verifyAccessToken(token, config) {
|
|
101
121
|
try {
|
|
102
122
|
const key = getSecretKey(config.secret);
|
|
103
|
-
const { payload } = await jwtVerify(token, key);
|
|
123
|
+
const { payload } = await jwtVerify(token, key, { algorithms: ["HS256"] });
|
|
104
124
|
return payload;
|
|
105
125
|
} catch {
|
|
106
126
|
return null;
|
|
@@ -109,7 +129,7 @@ async function verifyAccessToken(token, config) {
|
|
|
109
129
|
async function verifyRefreshToken(token, config) {
|
|
110
130
|
try {
|
|
111
131
|
const key = getSecretKey(config.secret);
|
|
112
|
-
const { payload } = await jwtVerify(token, key);
|
|
132
|
+
const { payload } = await jwtVerify(token, key, { algorithms: ["HS256"] });
|
|
113
133
|
if (payload.type !== "refresh") {
|
|
114
134
|
return null;
|
|
115
135
|
}
|
|
@@ -121,6 +141,87 @@ async function verifyRefreshToken(token, config) {
|
|
|
121
141
|
return null;
|
|
122
142
|
}
|
|
123
143
|
}
|
|
144
|
+
async function signUserAccessToken(opts, config) {
|
|
145
|
+
const key = getSecretKey(config.secret);
|
|
146
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
147
|
+
const jti = randomUUID();
|
|
148
|
+
const token = await new SignJWT({
|
|
149
|
+
tenantId: opts.tenantId,
|
|
150
|
+
fam: opts.familyId,
|
|
151
|
+
type: "user",
|
|
152
|
+
jti
|
|
153
|
+
}).setProtectedHeader({ alg: "HS256" }).setSubject(opts.userId).setIssuedAt(now).setExpirationTime(now + opts.ttlSec).sign(key);
|
|
154
|
+
return { token, expiresInSec: opts.ttlSec, jti };
|
|
155
|
+
}
|
|
156
|
+
async function signUserRefreshToken(opts, config) {
|
|
157
|
+
const key = getSecretKey(config.secret);
|
|
158
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
159
|
+
const jti = opts.jti ?? randomUUID();
|
|
160
|
+
return new SignJWT({
|
|
161
|
+
tenantId: opts.tenantId,
|
|
162
|
+
fam: opts.familyId,
|
|
163
|
+
type: "refresh",
|
|
164
|
+
jti
|
|
165
|
+
}).setProtectedHeader({ alg: "HS256" }).setSubject(opts.userId).setIssuedAt(now).setExpirationTime(now + opts.ttlSec).sign(key);
|
|
166
|
+
}
|
|
167
|
+
var normaliseAccess = (raw) => {
|
|
168
|
+
if (raw.type !== "user") {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
if (!raw.sub || !raw.tenantId || !raw.fam || !raw.jti) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
if (typeof raw.iat !== "number" || typeof raw.exp !== "number") {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
userId: raw.sub,
|
|
179
|
+
tenantId: raw.tenantId,
|
|
180
|
+
familyId: raw.fam,
|
|
181
|
+
jti: raw.jti,
|
|
182
|
+
type: "user",
|
|
183
|
+
iat: raw.iat,
|
|
184
|
+
exp: raw.exp
|
|
185
|
+
};
|
|
186
|
+
};
|
|
187
|
+
var normaliseRefresh = (raw) => {
|
|
188
|
+
if (raw.type !== "refresh") {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
if (!raw.sub || !raw.tenantId || !raw.fam || !raw.jti) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
if (typeof raw.iat !== "number" || typeof raw.exp !== "number") {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
userId: raw.sub,
|
|
199
|
+
tenantId: raw.tenantId,
|
|
200
|
+
familyId: raw.fam,
|
|
201
|
+
jti: raw.jti,
|
|
202
|
+
type: "refresh",
|
|
203
|
+
iat: raw.iat,
|
|
204
|
+
exp: raw.exp
|
|
205
|
+
};
|
|
206
|
+
};
|
|
207
|
+
async function verifyUserAccessToken(token, config) {
|
|
208
|
+
try {
|
|
209
|
+
const key = getSecretKey(config.secret);
|
|
210
|
+
const { payload } = await jwtVerify(token, key, { algorithms: ["HS256"] });
|
|
211
|
+
return normaliseAccess(payload);
|
|
212
|
+
} catch {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
async function verifyUserRefreshToken(token, config) {
|
|
217
|
+
try {
|
|
218
|
+
const key = getSecretKey(config.secret);
|
|
219
|
+
const { payload } = await jwtVerify(token, key, { algorithms: ["HS256"] });
|
|
220
|
+
return normaliseRefresh(payload);
|
|
221
|
+
} catch {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
124
225
|
|
|
125
226
|
// src/service.ts
|
|
126
227
|
var AuthService = class {
|
|
@@ -132,12 +233,17 @@ var AuthService = class {
|
|
|
132
233
|
jwtConfig;
|
|
133
234
|
// ── Register ──────────────────────────────────────────────────────────────
|
|
134
235
|
async register(req) {
|
|
236
|
+
if (req.handle && await isHandleTaken(this.cache, req.handle)) {
|
|
237
|
+
throw Object.assign(new Error(`Handle '${req.handle}' is already taken`), { code: "HANDLE_TAKEN" });
|
|
238
|
+
}
|
|
135
239
|
const secret = generateClientSecret();
|
|
136
240
|
const record = buildClientRecord({
|
|
137
241
|
name: req.name,
|
|
138
242
|
capabilities: req.capabilities ?? [],
|
|
139
243
|
publicKey: req.publicKey,
|
|
140
|
-
secret
|
|
244
|
+
secret,
|
|
245
|
+
handle: req.handle,
|
|
246
|
+
email: req.email
|
|
141
247
|
});
|
|
142
248
|
await saveClient(this.cache, record);
|
|
143
249
|
if (req.publicKey) {
|
|
@@ -146,9 +252,9 @@ var AuthService = class {
|
|
|
146
252
|
return {
|
|
147
253
|
clientId: record.clientId,
|
|
148
254
|
clientSecret: secret,
|
|
149
|
-
// returned ONCE, never stored in plaintext
|
|
150
255
|
hostId: record.hostId,
|
|
151
|
-
namespaceId: record.namespaceId
|
|
256
|
+
namespaceId: record.namespaceId,
|
|
257
|
+
handle: record.handle
|
|
152
258
|
};
|
|
153
259
|
}
|
|
154
260
|
// ── Issue token pair ──────────────────────────────────────────────────────
|
|
@@ -163,7 +269,8 @@ var AuthService = class {
|
|
|
163
269
|
hostId: record.hostId,
|
|
164
270
|
namespaceId: record.namespaceId,
|
|
165
271
|
tier: record.tier,
|
|
166
|
-
type: "machine"
|
|
272
|
+
type: "machine",
|
|
273
|
+
permissions: record.permissions ?? ["host:connect"]
|
|
167
274
|
},
|
|
168
275
|
this.jwtConfig
|
|
169
276
|
),
|
|
@@ -183,9 +290,16 @@ var AuthService = class {
|
|
|
183
290
|
return null;
|
|
184
291
|
}
|
|
185
292
|
const hostId = stored.hostId;
|
|
293
|
+
const record = await getClientByHostId(this.cache, hostId);
|
|
186
294
|
const [{ token: accessToken, expiresIn }, newRefreshToken] = await Promise.all([
|
|
187
295
|
signAccessToken(
|
|
188
|
-
{
|
|
296
|
+
{
|
|
297
|
+
hostId,
|
|
298
|
+
namespaceId: stored.namespaceId,
|
|
299
|
+
tier: record?.tier ?? "free",
|
|
300
|
+
type: "machine",
|
|
301
|
+
permissions: record?.permissions ?? ["host:connect"]
|
|
302
|
+
},
|
|
189
303
|
this.jwtConfig
|
|
190
304
|
),
|
|
191
305
|
signRefreshToken(hostId, this.jwtConfig)
|
|
@@ -193,6 +307,14 @@ var AuthService = class {
|
|
|
193
307
|
await saveRefreshToken(this.cache, newRefreshToken, hostId, stored.namespaceId);
|
|
194
308
|
return { accessToken, refreshToken: newRefreshToken, expiresIn, tokenType: "Bearer" };
|
|
195
309
|
}
|
|
310
|
+
// ── Profile lookup ────────────────────────────────────────────────────────
|
|
311
|
+
async me(hostId) {
|
|
312
|
+
const record = await getClientByHostId(this.cache, hostId);
|
|
313
|
+
if (!record) {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
return { hostId: record.hostId, handle: record.handle, namespaceId: record.namespaceId };
|
|
317
|
+
}
|
|
196
318
|
// ── Verify access token → AuthContext ────────────────────────────────────
|
|
197
319
|
async verify(token) {
|
|
198
320
|
const payload = await verifyAccessToken(token, this.jwtConfig);
|
|
@@ -204,11 +326,1379 @@ var AuthService = class {
|
|
|
204
326
|
userId: payload.sub,
|
|
205
327
|
namespaceId: payload.namespaceId,
|
|
206
328
|
tier: payload.tier,
|
|
207
|
-
permissions: ["host:connect"]
|
|
329
|
+
permissions: payload.permissions ?? ["host:connect"]
|
|
208
330
|
};
|
|
209
331
|
}
|
|
210
332
|
};
|
|
211
333
|
|
|
212
|
-
|
|
334
|
+
// src/users-store.ts
|
|
335
|
+
var COLLECTION = "users";
|
|
336
|
+
var canonicalizeEmail = (raw) => raw.trim().toLowerCase();
|
|
337
|
+
var docToUser = (doc) => ({
|
|
338
|
+
userId: doc.userId,
|
|
339
|
+
tenantId: doc.tenantId,
|
|
340
|
+
email: doc.email,
|
|
341
|
+
displayName: doc.displayName,
|
|
342
|
+
status: doc.status,
|
|
343
|
+
createdAt: doc.createdAt,
|
|
344
|
+
updatedAt: doc.updatedAt
|
|
345
|
+
});
|
|
346
|
+
var UsersStore = class {
|
|
347
|
+
constructor(docs) {
|
|
348
|
+
this.docs = docs;
|
|
349
|
+
}
|
|
350
|
+
docs;
|
|
351
|
+
initialised = null;
|
|
352
|
+
async ensureSchema() {
|
|
353
|
+
if (!this.initialised) {
|
|
354
|
+
this.initialised = (async () => {
|
|
355
|
+
await this.docs.ensureCollection(COLLECTION, {
|
|
356
|
+
indexes: [
|
|
357
|
+
// Logical primary key for "user inside tenant" lookups.
|
|
358
|
+
{ path: ["tenantId", "email"], unique: true },
|
|
359
|
+
// userId is the surrogate key callers reference everywhere.
|
|
360
|
+
{ path: "userId", unique: true },
|
|
361
|
+
{ path: "tenantId" }
|
|
362
|
+
]
|
|
363
|
+
});
|
|
364
|
+
})();
|
|
365
|
+
}
|
|
366
|
+
await this.initialised;
|
|
367
|
+
}
|
|
368
|
+
async create(input) {
|
|
369
|
+
await this.ensureSchema();
|
|
370
|
+
const email = canonicalizeEmail(input.email);
|
|
371
|
+
await this.docs.insertOne(COLLECTION, {
|
|
372
|
+
userId: input.userId,
|
|
373
|
+
tenantId: input.tenantId,
|
|
374
|
+
email,
|
|
375
|
+
displayName: input.displayName,
|
|
376
|
+
status: input.status
|
|
377
|
+
});
|
|
378
|
+
const created = await this.getById(input.userId);
|
|
379
|
+
if (!created) {
|
|
380
|
+
throw new Error(`users-store: inserted user ${input.userId} but read-back returned null`);
|
|
381
|
+
}
|
|
382
|
+
return created;
|
|
383
|
+
}
|
|
384
|
+
async getById(userId) {
|
|
385
|
+
await this.ensureSchema();
|
|
386
|
+
const doc = await this.docs.findById(COLLECTION, userId);
|
|
387
|
+
if (doc && doc.userId === userId) {
|
|
388
|
+
return docToUser(doc);
|
|
389
|
+
}
|
|
390
|
+
const [byField] = await this.docs.find(
|
|
391
|
+
COLLECTION,
|
|
392
|
+
{ userId: { $eq: userId } },
|
|
393
|
+
{ limit: 1 }
|
|
394
|
+
);
|
|
395
|
+
return byField ? docToUser(byField) : null;
|
|
396
|
+
}
|
|
397
|
+
async findByEmailTenant(email, tenantId) {
|
|
398
|
+
await this.ensureSchema();
|
|
399
|
+
const normalised = canonicalizeEmail(email);
|
|
400
|
+
const [doc] = await this.docs.find(
|
|
401
|
+
COLLECTION,
|
|
402
|
+
{ $and: [{ tenantId: { $eq: tenantId } }, { email: { $eq: normalised } }] },
|
|
403
|
+
{ limit: 1 }
|
|
404
|
+
);
|
|
405
|
+
return doc ? docToUser(doc) : null;
|
|
406
|
+
}
|
|
407
|
+
async setStatus(userId, status) {
|
|
408
|
+
await this.ensureSchema();
|
|
409
|
+
const existing = await this.getById(userId);
|
|
410
|
+
if (!existing) {
|
|
411
|
+
throw new Error(`users-store: setStatus on unknown userId=${userId}`);
|
|
412
|
+
}
|
|
413
|
+
await this.docs.updateMany(
|
|
414
|
+
COLLECTION,
|
|
415
|
+
{ userId: { $eq: userId } },
|
|
416
|
+
{ $set: { status } }
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
/** List all users for a given tenant. */
|
|
420
|
+
async listByTenant(tenantId) {
|
|
421
|
+
await this.ensureSchema();
|
|
422
|
+
const docs = await this.docs.find(
|
|
423
|
+
COLLECTION,
|
|
424
|
+
{ tenantId: { $eq: tenantId } }
|
|
425
|
+
);
|
|
426
|
+
return docs.map(docToUser);
|
|
427
|
+
}
|
|
428
|
+
async delete(userId) {
|
|
429
|
+
await this.ensureSchema();
|
|
430
|
+
await this.docs.deleteMany(COLLECTION, { userId: { $eq: userId } });
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
// src/credentials-store.ts
|
|
435
|
+
var COLLECTION2 = "credentials";
|
|
436
|
+
var docToCredential = (doc) => ({
|
|
437
|
+
userId: doc.userId,
|
|
438
|
+
providerId: doc.providerId,
|
|
439
|
+
hash: doc.hash,
|
|
440
|
+
createdAt: doc.createdAt,
|
|
441
|
+
updatedAt: doc.updatedAt
|
|
442
|
+
});
|
|
443
|
+
var CredentialsStore = class {
|
|
444
|
+
constructor(docs) {
|
|
445
|
+
this.docs = docs;
|
|
446
|
+
}
|
|
447
|
+
docs;
|
|
448
|
+
initialised = null;
|
|
449
|
+
async ensureSchema() {
|
|
450
|
+
if (!this.initialised) {
|
|
451
|
+
this.initialised = (async () => {
|
|
452
|
+
await this.docs.ensureCollection(COLLECTION2, {
|
|
453
|
+
indexes: [
|
|
454
|
+
// Logical primary key.
|
|
455
|
+
{ path: ["userId", "providerId"], unique: true },
|
|
456
|
+
// Cascade delete needs userId alone.
|
|
457
|
+
{ path: "userId" }
|
|
458
|
+
]
|
|
459
|
+
});
|
|
460
|
+
})();
|
|
461
|
+
}
|
|
462
|
+
await this.initialised;
|
|
463
|
+
}
|
|
464
|
+
async setCredential(input) {
|
|
465
|
+
await this.ensureSchema();
|
|
466
|
+
await this.docs.updateOne(
|
|
467
|
+
COLLECTION2,
|
|
468
|
+
{
|
|
469
|
+
$and: [
|
|
470
|
+
{ userId: { $eq: input.userId } },
|
|
471
|
+
{ providerId: { $eq: input.providerId } }
|
|
472
|
+
]
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
$set: {
|
|
476
|
+
userId: input.userId,
|
|
477
|
+
providerId: input.providerId,
|
|
478
|
+
hash: input.hash
|
|
479
|
+
}
|
|
480
|
+
},
|
|
481
|
+
{ upsert: true }
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
async getCredential(userId, providerId) {
|
|
485
|
+
await this.ensureSchema();
|
|
486
|
+
const [doc] = await this.docs.find(
|
|
487
|
+
COLLECTION2,
|
|
488
|
+
{
|
|
489
|
+
$and: [
|
|
490
|
+
{ userId: { $eq: userId } },
|
|
491
|
+
{ providerId: { $eq: providerId } }
|
|
492
|
+
]
|
|
493
|
+
},
|
|
494
|
+
{ limit: 1 }
|
|
495
|
+
);
|
|
496
|
+
return doc ? docToCredential(doc) : null;
|
|
497
|
+
}
|
|
498
|
+
async deleteCredential(userId, providerId) {
|
|
499
|
+
await this.ensureSchema();
|
|
500
|
+
await this.docs.deleteMany(COLLECTION2, {
|
|
501
|
+
$and: [
|
|
502
|
+
{ userId: { $eq: userId } },
|
|
503
|
+
{ providerId: { $eq: providerId } }
|
|
504
|
+
]
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Cascade removal helper — call from the parent flow when a `User` is
|
|
509
|
+
* deleted so we never leave dangling credentials in the store.
|
|
510
|
+
*/
|
|
511
|
+
async deleteAllForUser(userId) {
|
|
512
|
+
await this.ensureSchema();
|
|
513
|
+
await this.docs.deleteMany(COLLECTION2, {
|
|
514
|
+
userId: { $eq: userId }
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
// src/memberships-store.ts
|
|
520
|
+
var COLLECTION3 = "memberships";
|
|
521
|
+
var docToMembership = (doc) => ({
|
|
522
|
+
userId: doc.userId,
|
|
523
|
+
tenantId: doc.tenantId,
|
|
524
|
+
groupId: doc.groupId,
|
|
525
|
+
createdAt: doc.createdAt,
|
|
526
|
+
updatedAt: doc.updatedAt
|
|
527
|
+
});
|
|
528
|
+
var MembershipsStore = class {
|
|
529
|
+
constructor(docs) {
|
|
530
|
+
this.docs = docs;
|
|
531
|
+
}
|
|
532
|
+
docs;
|
|
533
|
+
initialised = null;
|
|
534
|
+
async ensureSchema() {
|
|
535
|
+
if (!this.initialised) {
|
|
536
|
+
this.initialised = (async () => {
|
|
537
|
+
await this.docs.ensureCollection(COLLECTION3, {
|
|
538
|
+
indexes: [
|
|
539
|
+
{ path: ["userId", "tenantId"], unique: true },
|
|
540
|
+
{ path: "userId" },
|
|
541
|
+
{ path: "tenantId" }
|
|
542
|
+
]
|
|
543
|
+
});
|
|
544
|
+
})();
|
|
545
|
+
}
|
|
546
|
+
await this.initialised;
|
|
547
|
+
}
|
|
548
|
+
async addMembership(input) {
|
|
549
|
+
await this.ensureSchema();
|
|
550
|
+
await this.docs.insertOne(COLLECTION3, {
|
|
551
|
+
userId: input.userId,
|
|
552
|
+
tenantId: input.tenantId,
|
|
553
|
+
groupId: input.groupId
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
async setGroup(userId, tenantId, groupId) {
|
|
557
|
+
await this.ensureSchema();
|
|
558
|
+
const [existing] = await this.docs.find(
|
|
559
|
+
COLLECTION3,
|
|
560
|
+
{ $and: [{ userId: { $eq: userId } }, { tenantId: { $eq: tenantId } }] },
|
|
561
|
+
{ limit: 1 }
|
|
562
|
+
);
|
|
563
|
+
if (!existing) {
|
|
564
|
+
throw new Error(
|
|
565
|
+
`memberships-store: setGroup on missing membership userId=${userId} tenantId=${tenantId}`
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
await this.docs.updateMany(
|
|
569
|
+
COLLECTION3,
|
|
570
|
+
{ $and: [{ userId: { $eq: userId } }, { tenantId: { $eq: tenantId } }] },
|
|
571
|
+
{ $set: { groupId } }
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
async listByUser(userId) {
|
|
575
|
+
await this.ensureSchema();
|
|
576
|
+
const docs = await this.docs.find(
|
|
577
|
+
COLLECTION3,
|
|
578
|
+
{ userId: { $eq: userId } },
|
|
579
|
+
{ sort: { tenantId: 1 } }
|
|
580
|
+
);
|
|
581
|
+
return docs.map(docToMembership);
|
|
582
|
+
}
|
|
583
|
+
async listByTenant(tenantId) {
|
|
584
|
+
await this.ensureSchema();
|
|
585
|
+
const docs = await this.docs.find(
|
|
586
|
+
COLLECTION3,
|
|
587
|
+
{ tenantId: { $eq: tenantId } },
|
|
588
|
+
{ sort: { userId: 1 } }
|
|
589
|
+
);
|
|
590
|
+
return docs.map(docToMembership);
|
|
591
|
+
}
|
|
592
|
+
async removeMembership(userId, tenantId) {
|
|
593
|
+
await this.ensureSchema();
|
|
594
|
+
await this.docs.deleteMany(
|
|
595
|
+
COLLECTION3,
|
|
596
|
+
{ $and: [{ userId: { $eq: userId } }, { tenantId: { $eq: tenantId } }] }
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
/** Cascade hook — call when a `User` is deleted. */
|
|
600
|
+
async removeAllForUser(userId) {
|
|
601
|
+
await this.ensureSchema();
|
|
602
|
+
await this.docs.deleteMany(COLLECTION3, { userId: { $eq: userId } });
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
var COLLECTION4 = "invites";
|
|
606
|
+
var TOKEN_BYTES = 32;
|
|
607
|
+
var generateToken = () => randomBytes(TOKEN_BYTES).toString("base64url");
|
|
608
|
+
var hashToken2 = (plain) => createHash("sha256").update(plain).digest("hex");
|
|
609
|
+
var generateInviteId = () => randomBytes(16).toString("base64url");
|
|
610
|
+
var docToInvite = (doc) => ({
|
|
611
|
+
inviteId: doc.inviteId,
|
|
612
|
+
email: doc.email,
|
|
613
|
+
tenantId: doc.tenantId,
|
|
614
|
+
groupId: doc.groupId,
|
|
615
|
+
status: doc.status,
|
|
616
|
+
createdBy: doc.createdBy,
|
|
617
|
+
expiresAt: doc.expiresAt,
|
|
618
|
+
createdAt: doc.createdAt,
|
|
619
|
+
updatedAt: doc.updatedAt
|
|
620
|
+
});
|
|
621
|
+
var InvitesStore = class {
|
|
622
|
+
constructor(docs, now = Date.now) {
|
|
623
|
+
this.docs = docs;
|
|
624
|
+
this.now = now;
|
|
625
|
+
}
|
|
626
|
+
docs;
|
|
627
|
+
now;
|
|
628
|
+
initialised = null;
|
|
629
|
+
async ensureSchema() {
|
|
630
|
+
if (!this.initialised) {
|
|
631
|
+
this.initialised = (async () => {
|
|
632
|
+
await this.docs.ensureCollection(COLLECTION4, {
|
|
633
|
+
indexes: [
|
|
634
|
+
{ path: "inviteId", unique: true },
|
|
635
|
+
{ path: "tokenHash", unique: true },
|
|
636
|
+
// Active-invite uniqueness is enforced in code, so this index
|
|
637
|
+
// is non-unique and just speeds the "is there an active invite
|
|
638
|
+
// for (email, tenantId)" lookup.
|
|
639
|
+
{ path: ["tenantId", "email", "status"] },
|
|
640
|
+
// TTL is best-effort; `findByToken` also checks expiresAt > now.
|
|
641
|
+
// `ttl: 0` means "sweep as soon as `expiresAt` is in the past".
|
|
642
|
+
{ path: "expiresAt", ttl: 0 }
|
|
643
|
+
]
|
|
644
|
+
});
|
|
645
|
+
})();
|
|
646
|
+
}
|
|
647
|
+
await this.initialised;
|
|
648
|
+
}
|
|
649
|
+
async createInvite(input) {
|
|
650
|
+
await this.ensureSchema();
|
|
651
|
+
const email = canonicalizeEmail(input.email);
|
|
652
|
+
const [existingActive] = await this.docs.find(
|
|
653
|
+
COLLECTION4,
|
|
654
|
+
{
|
|
655
|
+
$and: [
|
|
656
|
+
{ tenantId: { $eq: input.tenantId } },
|
|
657
|
+
{ email: { $eq: email } },
|
|
658
|
+
{ status: { $eq: "active" } },
|
|
659
|
+
{ expiresAt: { $gt: this.now() } }
|
|
660
|
+
]
|
|
661
|
+
},
|
|
662
|
+
{ limit: 1 }
|
|
663
|
+
);
|
|
664
|
+
if (existingActive) {
|
|
665
|
+
throw new Error(
|
|
666
|
+
`invites-store: an active invite already exists for email=${email} tenantId=${input.tenantId}; revoke it before issuing a new one`
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
const inviteId = generateInviteId();
|
|
670
|
+
const activationToken = generateToken();
|
|
671
|
+
const tokenHash = hashToken2(activationToken);
|
|
672
|
+
const expiresAt = this.now() + input.ttlMs;
|
|
673
|
+
await this.docs.insertOne(COLLECTION4, {
|
|
674
|
+
inviteId,
|
|
675
|
+
email,
|
|
676
|
+
tenantId: input.tenantId,
|
|
677
|
+
groupId: input.groupId,
|
|
678
|
+
status: "active",
|
|
679
|
+
createdBy: input.createdBy,
|
|
680
|
+
expiresAt,
|
|
681
|
+
tokenHash
|
|
682
|
+
});
|
|
683
|
+
return { inviteId, activationToken, expiresAt };
|
|
684
|
+
}
|
|
685
|
+
async findById(inviteId) {
|
|
686
|
+
await this.ensureSchema();
|
|
687
|
+
const [doc] = await this.docs.find(
|
|
688
|
+
COLLECTION4,
|
|
689
|
+
{ inviteId: { $eq: inviteId } },
|
|
690
|
+
{ limit: 1 }
|
|
691
|
+
);
|
|
692
|
+
return doc ? docToInvite(doc) : null;
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Look up an invite by the plaintext activation token. Returns `null`
|
|
696
|
+
* for missing, expired, used, or revoked invites — callers do not
|
|
697
|
+
* need to filter further.
|
|
698
|
+
*
|
|
699
|
+
* Returns a discriminated result so callers can distinguish:
|
|
700
|
+
* - { kind: 'not_found' } — token hash not in DB (garbage/unknown token)
|
|
701
|
+
* - { kind: 'invalid' } — token exists but expired, used, or revoked
|
|
702
|
+
* - { kind: 'ok', invite } — valid, active invite ready for activation
|
|
703
|
+
*/
|
|
704
|
+
async findByToken(plainToken) {
|
|
705
|
+
await this.ensureSchema();
|
|
706
|
+
const tokenHash = hashToken2(plainToken);
|
|
707
|
+
const [doc] = await this.docs.find(
|
|
708
|
+
COLLECTION4,
|
|
709
|
+
{ tokenHash: { $eq: tokenHash } },
|
|
710
|
+
{ limit: 1 }
|
|
711
|
+
);
|
|
712
|
+
if (!doc) {
|
|
713
|
+
return { kind: "not_found" };
|
|
714
|
+
}
|
|
715
|
+
if (doc.status !== "active" || doc.expiresAt <= this.now()) {
|
|
716
|
+
return { kind: "invalid" };
|
|
717
|
+
}
|
|
718
|
+
return { kind: "ok", invite: docToInvite(doc) };
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Atomically mark an active invite as used.
|
|
722
|
+
*
|
|
723
|
+
* Returns `true` if THIS call flipped the invite from `active` → `used`,
|
|
724
|
+
* `false` if it was already used/revoked (or unknown). The filter pins
|
|
725
|
+
* `status: 'active'`, so a concurrent double-activation of the same token
|
|
726
|
+
* results in exactly one caller seeing `true` — the activation flow relies
|
|
727
|
+
* on this to close the TOCTOU window between `findByToken` and account
|
|
728
|
+
* creation (a second parallel request must NOT create a duplicate account).
|
|
729
|
+
*/
|
|
730
|
+
async consume(inviteId) {
|
|
731
|
+
await this.ensureSchema();
|
|
732
|
+
const modified = await this.docs.updateMany(
|
|
733
|
+
COLLECTION4,
|
|
734
|
+
{ $and: [{ inviteId: { $eq: inviteId } }, { status: { $eq: "active" } }] },
|
|
735
|
+
{ $set: { status: "used" } }
|
|
736
|
+
);
|
|
737
|
+
return modified > 0;
|
|
738
|
+
}
|
|
739
|
+
async revoke(inviteId) {
|
|
740
|
+
await this.ensureSchema();
|
|
741
|
+
await this.docs.updateMany(
|
|
742
|
+
COLLECTION4,
|
|
743
|
+
{ $and: [{ inviteId: { $eq: inviteId } }, { status: { $eq: "active" } }] },
|
|
744
|
+
{ $set: { status: "revoked" } }
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
/** List all invites for a tenant (active + used + revoked). */
|
|
748
|
+
async listByTenant(tenantId) {
|
|
749
|
+
await this.ensureSchema();
|
|
750
|
+
const docs = await this.docs.find(
|
|
751
|
+
COLLECTION4,
|
|
752
|
+
{ tenantId: { $eq: tenantId } }
|
|
753
|
+
);
|
|
754
|
+
return docs.map(docToInvite);
|
|
755
|
+
}
|
|
756
|
+
};
|
|
757
|
+
var FAMILIES = "session_families";
|
|
758
|
+
var REFRESHES = "refresh_tokens";
|
|
759
|
+
var RefreshNotFoundError = class extends Error {
|
|
760
|
+
constructor() {
|
|
761
|
+
super("refresh token not found");
|
|
762
|
+
this.name = "RefreshNotFoundError";
|
|
763
|
+
}
|
|
764
|
+
};
|
|
765
|
+
var RefreshExpiredError = class extends Error {
|
|
766
|
+
constructor() {
|
|
767
|
+
super("refresh token expired");
|
|
768
|
+
this.name = "RefreshExpiredError";
|
|
769
|
+
}
|
|
770
|
+
};
|
|
771
|
+
var RefreshReuseDetectedError = class extends Error {
|
|
772
|
+
constructor(familyId) {
|
|
773
|
+
super(`refresh token reuse detected (family ${familyId} killed)`);
|
|
774
|
+
this.familyId = familyId;
|
|
775
|
+
this.name = "RefreshReuseDetectedError";
|
|
776
|
+
}
|
|
777
|
+
familyId;
|
|
778
|
+
};
|
|
779
|
+
var newId = (bytes = 16) => randomBytes(bytes).toString("base64url");
|
|
780
|
+
var SessionsStore = class {
|
|
781
|
+
constructor(docs, opts) {
|
|
782
|
+
this.docs = docs;
|
|
783
|
+
this.now = opts.now ?? Date.now;
|
|
784
|
+
this.refreshTtlMs = opts.refreshTtlMs;
|
|
785
|
+
this.graceWindowMs = opts.graceWindowMs;
|
|
786
|
+
}
|
|
787
|
+
docs;
|
|
788
|
+
initialised = null;
|
|
789
|
+
now;
|
|
790
|
+
refreshTtlMs;
|
|
791
|
+
graceWindowMs;
|
|
792
|
+
async ensureSchema() {
|
|
793
|
+
if (!this.initialised) {
|
|
794
|
+
this.initialised = (async () => {
|
|
795
|
+
await this.docs.ensureCollection(FAMILIES, {
|
|
796
|
+
indexes: [
|
|
797
|
+
{ path: "familyId", unique: true },
|
|
798
|
+
{ path: "userId" }
|
|
799
|
+
]
|
|
800
|
+
});
|
|
801
|
+
await this.docs.ensureCollection(REFRESHES, {
|
|
802
|
+
indexes: [
|
|
803
|
+
{ path: "jti", unique: true },
|
|
804
|
+
{ path: "familyId" },
|
|
805
|
+
{ path: "userId" },
|
|
806
|
+
// Best-effort sweep; `rotateRefresh` checks expiresAt > now (CD-9).
|
|
807
|
+
{ path: "expiresAt", ttl: 0 }
|
|
808
|
+
]
|
|
809
|
+
});
|
|
810
|
+
})();
|
|
811
|
+
}
|
|
812
|
+
await this.initialised;
|
|
813
|
+
}
|
|
814
|
+
async createSession(input) {
|
|
815
|
+
await this.ensureSchema();
|
|
816
|
+
const familyId = newId();
|
|
817
|
+
const refreshJti = newId();
|
|
818
|
+
const now = this.now();
|
|
819
|
+
const refreshExpiresAt = now + this.refreshTtlMs;
|
|
820
|
+
await this.docs.transaction(async (tx) => {
|
|
821
|
+
await tx.insertOne(FAMILIES, {
|
|
822
|
+
familyId,
|
|
823
|
+
userId: input.userId,
|
|
824
|
+
tenantId: input.tenantId,
|
|
825
|
+
lastUsedAt: now,
|
|
826
|
+
userAgent: input.deviceCtx.userAgent,
|
|
827
|
+
ipFirst: input.deviceCtx.ip
|
|
828
|
+
});
|
|
829
|
+
await tx.insertOne(REFRESHES, {
|
|
830
|
+
jti: refreshJti,
|
|
831
|
+
familyId,
|
|
832
|
+
userId: input.userId,
|
|
833
|
+
expiresAt: refreshExpiresAt
|
|
834
|
+
});
|
|
835
|
+
});
|
|
836
|
+
return { familyId, refreshJti, refreshExpiresAt };
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Consume `oldJti` and issue a new refresh in the same family.
|
|
840
|
+
*
|
|
841
|
+
* Throws:
|
|
842
|
+
* - `RefreshNotFoundError` if jti is unknown or its family was revoked.
|
|
843
|
+
* - `RefreshExpiredError` if jti is past `expiresAt` (family stays alive).
|
|
844
|
+
* - `RefreshReuseDetectedError` if jti was already consumed and the
|
|
845
|
+
* grace window has passed — family is killed before throwing.
|
|
846
|
+
*
|
|
847
|
+
* On grace-window retry (same already-consumed jti within
|
|
848
|
+
* `graceWindowMs`) the previously-issued replacement is returned and
|
|
849
|
+
* no new refresh is created.
|
|
850
|
+
*/
|
|
851
|
+
async rotateRefresh(oldJti) {
|
|
852
|
+
await this.ensureSchema();
|
|
853
|
+
const now = this.now();
|
|
854
|
+
const newJti = newId();
|
|
855
|
+
const newExpiresAt = now + this.refreshTtlMs;
|
|
856
|
+
const outcome = await this.docs.transaction(async (tx) => {
|
|
857
|
+
const [current] = await tx.find(
|
|
858
|
+
REFRESHES,
|
|
859
|
+
{ jti: { $eq: oldJti } },
|
|
860
|
+
{ limit: 1 }
|
|
861
|
+
);
|
|
862
|
+
if (!current) {
|
|
863
|
+
return { tag: "not-found" };
|
|
864
|
+
}
|
|
865
|
+
if (current.consumedAt !== void 0) {
|
|
866
|
+
const elapsed = now - current.consumedAt;
|
|
867
|
+
if (elapsed <= this.graceWindowMs && current.replacedBy) {
|
|
868
|
+
const [replacement] = await tx.find(
|
|
869
|
+
REFRESHES,
|
|
870
|
+
{ jti: { $eq: current.replacedBy } },
|
|
871
|
+
{ limit: 1 }
|
|
872
|
+
);
|
|
873
|
+
if (replacement) {
|
|
874
|
+
return {
|
|
875
|
+
tag: "ok",
|
|
876
|
+
result: {
|
|
877
|
+
newJti: replacement.jti,
|
|
878
|
+
familyId: current.familyId,
|
|
879
|
+
newRefreshExpiresAt: replacement.expiresAt
|
|
880
|
+
}
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
if (current.expiresAt <= now) {
|
|
885
|
+
return { tag: "expired" };
|
|
886
|
+
}
|
|
887
|
+
return { tag: "reuse", familyId: current.familyId };
|
|
888
|
+
}
|
|
889
|
+
if (current.expiresAt <= now) {
|
|
890
|
+
return { tag: "expired" };
|
|
891
|
+
}
|
|
892
|
+
await tx.updateMany(
|
|
893
|
+
REFRESHES,
|
|
894
|
+
{ jti: { $eq: oldJti } },
|
|
895
|
+
{ $set: { consumedAt: now, replacedBy: newJti } }
|
|
896
|
+
);
|
|
897
|
+
await tx.insertOne(REFRESHES, {
|
|
898
|
+
jti: newJti,
|
|
899
|
+
familyId: current.familyId,
|
|
900
|
+
userId: current.userId,
|
|
901
|
+
expiresAt: newExpiresAt
|
|
902
|
+
});
|
|
903
|
+
await tx.updateMany(
|
|
904
|
+
FAMILIES,
|
|
905
|
+
{ familyId: { $eq: current.familyId } },
|
|
906
|
+
{ $set: { lastUsedAt: now } }
|
|
907
|
+
);
|
|
908
|
+
return {
|
|
909
|
+
tag: "ok",
|
|
910
|
+
result: { newJti, familyId: current.familyId, newRefreshExpiresAt: newExpiresAt }
|
|
911
|
+
};
|
|
912
|
+
});
|
|
913
|
+
switch (outcome.tag) {
|
|
914
|
+
case "ok":
|
|
915
|
+
return outcome.result;
|
|
916
|
+
case "not-found":
|
|
917
|
+
throw new RefreshNotFoundError();
|
|
918
|
+
case "expired":
|
|
919
|
+
throw new RefreshExpiredError();
|
|
920
|
+
case "reuse":
|
|
921
|
+
await this.revokeFamily(outcome.familyId);
|
|
922
|
+
throw new RefreshReuseDetectedError(outcome.familyId);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
async revokeFamily(familyId) {
|
|
926
|
+
await this.ensureSchema();
|
|
927
|
+
await this.docs.transaction(async (tx) => {
|
|
928
|
+
await tx.deleteMany(REFRESHES, { familyId: { $eq: familyId } });
|
|
929
|
+
await tx.deleteMany(FAMILIES, { familyId: { $eq: familyId } });
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
async revokeAllUserSessions(userId) {
|
|
933
|
+
await this.ensureSchema();
|
|
934
|
+
await this.docs.transaction(async (tx) => {
|
|
935
|
+
await tx.deleteMany(REFRESHES, { userId: { $eq: userId } });
|
|
936
|
+
await tx.deleteMany(FAMILIES, { userId: { $eq: userId } });
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
async revokeAllUserSessionsExcept(userId, exceptFamilyId) {
|
|
940
|
+
await this.ensureSchema();
|
|
941
|
+
await this.docs.transaction(async (tx) => {
|
|
942
|
+
const families = await tx.find(FAMILIES, { userId: { $eq: userId } });
|
|
943
|
+
const targets = families.filter((f) => f.familyId !== exceptFamilyId).map((f) => f.familyId);
|
|
944
|
+
if (targets.length === 0) {
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
await tx.deleteMany(
|
|
948
|
+
REFRESHES,
|
|
949
|
+
{ $and: [{ userId: { $eq: userId } }, { familyId: { $in: targets } }] }
|
|
950
|
+
);
|
|
951
|
+
await tx.deleteMany(
|
|
952
|
+
FAMILIES,
|
|
953
|
+
{ $and: [{ userId: { $eq: userId } }, { familyId: { $in: targets } }] }
|
|
954
|
+
);
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
async listFamiliesByUser(userId) {
|
|
958
|
+
await this.ensureSchema();
|
|
959
|
+
const docs = await this.docs.find(
|
|
960
|
+
FAMILIES,
|
|
961
|
+
{ userId: { $eq: userId } },
|
|
962
|
+
{ sort: { lastUsedAt: -1 } }
|
|
963
|
+
);
|
|
964
|
+
return docs.map((d) => ({
|
|
965
|
+
familyId: d.familyId,
|
|
966
|
+
userId: d.userId,
|
|
967
|
+
tenantId: d.tenantId,
|
|
968
|
+
createdAt: d.createdAt,
|
|
969
|
+
lastUsedAt: d.lastUsedAt,
|
|
970
|
+
userAgent: d.userAgent,
|
|
971
|
+
ipFirst: d.ipFirst
|
|
972
|
+
}));
|
|
973
|
+
}
|
|
974
|
+
};
|
|
975
|
+
var noopLogger = {
|
|
976
|
+
warn: () => void 0,
|
|
977
|
+
info: () => void 0,
|
|
978
|
+
error: () => void 0
|
|
979
|
+
};
|
|
980
|
+
var sha1Hex = (input) => createHash("sha1").update(input).digest("hex").toUpperCase();
|
|
981
|
+
var queryHibp = async (plain, doFetch) => {
|
|
982
|
+
const hash = sha1Hex(plain);
|
|
983
|
+
const prefix = hash.slice(0, 5);
|
|
984
|
+
const wantedSuffix = hash.slice(5);
|
|
985
|
+
const resp = await doFetch(`https://api.pwnedpasswords.com/range/${prefix}`);
|
|
986
|
+
if (!resp.ok) {
|
|
987
|
+
throw new Error(`HIBP ${resp.status}`);
|
|
988
|
+
}
|
|
989
|
+
const text = await resp.text();
|
|
990
|
+
for (const line of text.split(/\r?\n/)) {
|
|
991
|
+
const trimmed = line.trim();
|
|
992
|
+
if (!trimmed) {
|
|
993
|
+
continue;
|
|
994
|
+
}
|
|
995
|
+
const sepIdx = trimmed.indexOf(":");
|
|
996
|
+
if (sepIdx < 0) {
|
|
997
|
+
continue;
|
|
998
|
+
}
|
|
999
|
+
const suffix = trimmed.slice(0, sepIdx).toUpperCase();
|
|
1000
|
+
if (suffix === wantedSuffix) {
|
|
1001
|
+
const count = Number.parseInt(trimmed.slice(sepIdx + 1), 10);
|
|
1002
|
+
return Number.isFinite(count) ? count : 1;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
return 0;
|
|
1006
|
+
};
|
|
1007
|
+
var createPasswordPolicy = (opts) => {
|
|
1008
|
+
const log = opts.logger ?? noopLogger;
|
|
1009
|
+
const doFetch = opts.fetch ?? fetch;
|
|
1010
|
+
return {
|
|
1011
|
+
async validate(plain) {
|
|
1012
|
+
if (plain.length < opts.minLength) {
|
|
1013
|
+
return { ok: false, reason: "too_short" };
|
|
1014
|
+
}
|
|
1015
|
+
if (plain.length > opts.maxLength) {
|
|
1016
|
+
return { ok: false, reason: "too_long" };
|
|
1017
|
+
}
|
|
1018
|
+
if (!opts.hibpEnabled) {
|
|
1019
|
+
return { ok: true };
|
|
1020
|
+
}
|
|
1021
|
+
try {
|
|
1022
|
+
const count = await queryHibp(plain, doFetch);
|
|
1023
|
+
if (count > 0) {
|
|
1024
|
+
return { ok: false, reason: "pwned" };
|
|
1025
|
+
}
|
|
1026
|
+
return { ok: true };
|
|
1027
|
+
} catch (err) {
|
|
1028
|
+
log.warn("password-policy: HIBP unavailable, accepting password", { err: String(err) });
|
|
1029
|
+
return { ok: true, warning: "hibp_unavailable" };
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
};
|
|
1033
|
+
};
|
|
1034
|
+
var issueCsrfToken = () => randomBytes(32).toString("base64url");
|
|
1035
|
+
var verifyCsrfToken = (cookie, header) => {
|
|
1036
|
+
if (!cookie || !header) {
|
|
1037
|
+
return false;
|
|
1038
|
+
}
|
|
1039
|
+
if (cookie.length !== header.length) {
|
|
1040
|
+
return false;
|
|
1041
|
+
}
|
|
1042
|
+
const a = Buffer.from(cookie, "utf8");
|
|
1043
|
+
const b = Buffer.from(header, "utf8");
|
|
1044
|
+
return timingSafeEqual(a, b);
|
|
1045
|
+
};
|
|
1046
|
+
|
|
1047
|
+
// src/rate-limit.ts
|
|
1048
|
+
var retryAfterFor = async (kv, key, cfg) => {
|
|
1049
|
+
const remainingTtlMs = await kv.ttl(key);
|
|
1050
|
+
return remainingTtlMs !== null ? Math.max(1, Math.ceil(remainingTtlMs / 1e3)) : Math.ceil(cfg.windowMs / 1e3);
|
|
1051
|
+
};
|
|
1052
|
+
var createRateLimiter = (kv) => ({
|
|
1053
|
+
async check(key, cfg) {
|
|
1054
|
+
const count = await kv.incr(key, 1);
|
|
1055
|
+
if (count === 1) {
|
|
1056
|
+
await kv.expire(key, cfg.windowMs);
|
|
1057
|
+
}
|
|
1058
|
+
if (count > cfg.max) {
|
|
1059
|
+
return { allowed: false, retryAfterSec: await retryAfterFor(kv, key, cfg) };
|
|
1060
|
+
}
|
|
1061
|
+
return { allowed: true, remaining: cfg.max - count };
|
|
1062
|
+
},
|
|
1063
|
+
async peek(key, cfg) {
|
|
1064
|
+
const raw = await kv.get(key);
|
|
1065
|
+
const count = typeof raw === "number" ? raw : 0;
|
|
1066
|
+
if (count >= cfg.max) {
|
|
1067
|
+
return { allowed: false, retryAfterSec: await retryAfterFor(kv, key, cfg) };
|
|
1068
|
+
}
|
|
1069
|
+
return { allowed: true, remaining: cfg.max - count };
|
|
1070
|
+
}
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
// src/tenant-resolver.ts
|
|
1074
|
+
var SLUG_RE = /^[a-z0-9][a-z0-9-]{0,38}[a-z0-9]$/;
|
|
1075
|
+
var RESERVED_SUBDOMAINS = /* @__PURE__ */ new Set([
|
|
1076
|
+
"api",
|
|
1077
|
+
"www",
|
|
1078
|
+
"docs",
|
|
1079
|
+
"studio",
|
|
1080
|
+
"mail",
|
|
1081
|
+
"admin",
|
|
1082
|
+
"static",
|
|
1083
|
+
"cdn"
|
|
1084
|
+
]);
|
|
1085
|
+
var compilePattern = (pattern) => {
|
|
1086
|
+
const escapedTail = pattern.replace("{tenant}.", "").replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
1087
|
+
return new RegExp(`^([^.]+)\\.${escapedTail}$`);
|
|
1088
|
+
};
|
|
1089
|
+
var createTenantResolver = (opts) => {
|
|
1090
|
+
const re = compilePattern(opts.pattern);
|
|
1091
|
+
const reserved = opts.reserved ?? RESERVED_SUBDOMAINS;
|
|
1092
|
+
const cache = /* @__PURE__ */ new Map();
|
|
1093
|
+
const CACHE_LIMIT = 100;
|
|
1094
|
+
return {
|
|
1095
|
+
resolve(host) {
|
|
1096
|
+
if (!host) {
|
|
1097
|
+
return null;
|
|
1098
|
+
}
|
|
1099
|
+
const normalised = host.toLowerCase().split(":")[0] ?? "";
|
|
1100
|
+
if (!normalised) {
|
|
1101
|
+
return null;
|
|
1102
|
+
}
|
|
1103
|
+
if (cache.has(normalised)) {
|
|
1104
|
+
return cache.get(normalised);
|
|
1105
|
+
}
|
|
1106
|
+
let result = null;
|
|
1107
|
+
const m = normalised.match(re);
|
|
1108
|
+
if (m) {
|
|
1109
|
+
const candidate = m[1];
|
|
1110
|
+
if (SLUG_RE.test(candidate) && !reserved.has(candidate)) {
|
|
1111
|
+
result = candidate;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
if (cache.size >= CACHE_LIMIT) {
|
|
1115
|
+
const firstKey = cache.keys().next().value;
|
|
1116
|
+
if (firstKey !== void 0) {
|
|
1117
|
+
cache.delete(firstKey);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
cache.set(normalised, result);
|
|
1121
|
+
return result;
|
|
1122
|
+
}
|
|
1123
|
+
};
|
|
1124
|
+
};
|
|
1125
|
+
var dummyHashFor = (cost) => bcrypt.hash("dummy-password-for-timing", cost);
|
|
1126
|
+
var isPasswordInput = (raw) => {
|
|
1127
|
+
if (!raw || typeof raw !== "object") {
|
|
1128
|
+
return false;
|
|
1129
|
+
}
|
|
1130
|
+
const r = raw;
|
|
1131
|
+
return typeof r.email === "string" && typeof r.password === "string" && r.email.length > 0 && r.password.length > 0;
|
|
1132
|
+
};
|
|
1133
|
+
var createEmailPasswordProvider = (opts) => {
|
|
1134
|
+
const dummyHashPromise = dummyHashFor(opts.bcryptCost);
|
|
1135
|
+
return {
|
|
1136
|
+
id: "email-password",
|
|
1137
|
+
kind: "password",
|
|
1138
|
+
async authenticate(input) {
|
|
1139
|
+
if (!isPasswordInput(input)) {
|
|
1140
|
+
await bcrypt.compare("x", await dummyHashPromise);
|
|
1141
|
+
return { ok: false, reason: "invalid" };
|
|
1142
|
+
}
|
|
1143
|
+
const email = canonicalizeEmail(input.email);
|
|
1144
|
+
const user = await opts.users.findByEmailTenant(email, opts.tenantId);
|
|
1145
|
+
if (!user) {
|
|
1146
|
+
await bcrypt.compare(input.password, await dummyHashPromise);
|
|
1147
|
+
return { ok: false, reason: "unknown" };
|
|
1148
|
+
}
|
|
1149
|
+
if (user.status !== "active") {
|
|
1150
|
+
await bcrypt.compare(input.password, await dummyHashPromise);
|
|
1151
|
+
return { ok: false, reason: "disabled" };
|
|
1152
|
+
}
|
|
1153
|
+
const cred = await opts.credentials.getCredential(user.userId, "email-password");
|
|
1154
|
+
if (!cred) {
|
|
1155
|
+
await bcrypt.compare(input.password, await dummyHashPromise);
|
|
1156
|
+
return { ok: false, reason: "unknown" };
|
|
1157
|
+
}
|
|
1158
|
+
const match = await bcrypt.compare(input.password, cred.hash);
|
|
1159
|
+
if (!match) {
|
|
1160
|
+
return { ok: false, reason: "invalid" };
|
|
1161
|
+
}
|
|
1162
|
+
return { ok: true, email: user.email, externalId: user.userId };
|
|
1163
|
+
}
|
|
1164
|
+
};
|
|
1165
|
+
};
|
|
1166
|
+
var OidcConfigSchema = z.object({
|
|
1167
|
+
type: z.literal("oidc"),
|
|
1168
|
+
id: z.string().min(1),
|
|
1169
|
+
issuer: z.string().url().refine((u) => u.startsWith("https://"), { message: "issuer must be https" }),
|
|
1170
|
+
clientId: z.string().min(1),
|
|
1171
|
+
/** Direct secret (discouraged — prefer clientSecretEnv). */
|
|
1172
|
+
clientSecret: z.string().min(1).optional(),
|
|
1173
|
+
/** Name of the env var holding the secret (preferred). */
|
|
1174
|
+
clientSecretEnv: z.string().min(1).optional(),
|
|
1175
|
+
/** OAuth scopes; `openid` is always included. Default `['openid','email']`. */
|
|
1176
|
+
scopes: z.array(z.string()).optional(),
|
|
1177
|
+
/** Enable PKCE (S256). */
|
|
1178
|
+
pkce: z.boolean().optional(),
|
|
1179
|
+
/** Accept tokens whose email_verified is false (default false). */
|
|
1180
|
+
allowUnverifiedEmail: z.boolean().optional()
|
|
1181
|
+
}).passthrough();
|
|
1182
|
+
var VERIFY_ALGS = ["RS256", "ES256"];
|
|
1183
|
+
function base64url(buf) {
|
|
1184
|
+
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1185
|
+
}
|
|
1186
|
+
function resolveSecret(config) {
|
|
1187
|
+
if (config.clientSecretEnv) {
|
|
1188
|
+
const fromEnv = process.env[config.clientSecretEnv];
|
|
1189
|
+
if (!fromEnv) {
|
|
1190
|
+
throw new Error(
|
|
1191
|
+
`oidc provider "${config.id}": clientSecretEnv "${config.clientSecretEnv}" is not set`
|
|
1192
|
+
);
|
|
1193
|
+
}
|
|
1194
|
+
return fromEnv;
|
|
1195
|
+
}
|
|
1196
|
+
if (config.clientSecret) {
|
|
1197
|
+
return config.clientSecret;
|
|
1198
|
+
}
|
|
1199
|
+
throw new Error(
|
|
1200
|
+
`oidc provider "${config.id}": no client secret \u2014 set clientSecretEnv (preferred) or clientSecret`
|
|
1201
|
+
);
|
|
1202
|
+
}
|
|
1203
|
+
function asBundle(input) {
|
|
1204
|
+
return input && typeof input === "object" ? input : {};
|
|
1205
|
+
}
|
|
1206
|
+
function createOidcProvider(rawConfig, deps) {
|
|
1207
|
+
const config = OidcConfigSchema.parse(rawConfig);
|
|
1208
|
+
const secret = resolveSecret(config);
|
|
1209
|
+
const scopes = config.scopes && config.scopes.length > 0 ? config.scopes : ["openid", "email"];
|
|
1210
|
+
const scopeParam = [.../* @__PURE__ */ new Set(["openid", ...scopes])].join(" ");
|
|
1211
|
+
const doFetch = deps.fetch ?? globalThis.fetch;
|
|
1212
|
+
let discoveryCache = null;
|
|
1213
|
+
let jwksCache = null;
|
|
1214
|
+
async function discover() {
|
|
1215
|
+
if (discoveryCache) {
|
|
1216
|
+
return discoveryCache;
|
|
1217
|
+
}
|
|
1218
|
+
const url = `${config.issuer.replace(/\/$/, "")}/.well-known/openid-configuration`;
|
|
1219
|
+
const res = await doFetch(url);
|
|
1220
|
+
if (!res.ok) {
|
|
1221
|
+
throw new Error(`oidc "${config.id}": discovery failed (${res.status})`);
|
|
1222
|
+
}
|
|
1223
|
+
const json = await res.json();
|
|
1224
|
+
discoveryCache = json;
|
|
1225
|
+
return json;
|
|
1226
|
+
}
|
|
1227
|
+
async function getJwks(force = false) {
|
|
1228
|
+
if (jwksCache && !force) {
|
|
1229
|
+
return jwksCache;
|
|
1230
|
+
}
|
|
1231
|
+
const { jwks_uri } = await discover();
|
|
1232
|
+
const res = await doFetch(jwks_uri);
|
|
1233
|
+
if (!res.ok) {
|
|
1234
|
+
throw new Error(`oidc "${config.id}": JWKS fetch failed (${res.status})`);
|
|
1235
|
+
}
|
|
1236
|
+
const jwks = await res.json();
|
|
1237
|
+
jwksCache = createLocalJWKSet(jwks);
|
|
1238
|
+
return jwksCache;
|
|
1239
|
+
}
|
|
1240
|
+
async function verifyIdToken(idToken) {
|
|
1241
|
+
const opts = { algorithms: VERIFY_ALGS, issuer: config.issuer, audience: config.clientId };
|
|
1242
|
+
try {
|
|
1243
|
+
const { payload } = await jwtVerify(idToken, await getJwks(), opts);
|
|
1244
|
+
return payload;
|
|
1245
|
+
} catch (err) {
|
|
1246
|
+
const code = err.code;
|
|
1247
|
+
if (code === "ERR_JWKS_NO_MATCHING_KEY") {
|
|
1248
|
+
const { payload } = await jwtVerify(idToken, await getJwks(true), opts);
|
|
1249
|
+
return payload;
|
|
1250
|
+
}
|
|
1251
|
+
throw err;
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
const provider = {
|
|
1255
|
+
id: config.id,
|
|
1256
|
+
kind: "redirect",
|
|
1257
|
+
async startAuthorization(ctx) {
|
|
1258
|
+
const { authorization_endpoint } = await discover();
|
|
1259
|
+
const nonce = base64url(randomBytes(16));
|
|
1260
|
+
const url = new URL(authorization_endpoint);
|
|
1261
|
+
url.searchParams.set("response_type", "code");
|
|
1262
|
+
url.searchParams.set("client_id", config.clientId);
|
|
1263
|
+
url.searchParams.set("redirect_uri", ctx.redirectUri);
|
|
1264
|
+
url.searchParams.set("scope", scopeParam);
|
|
1265
|
+
url.searchParams.set("state", ctx.state);
|
|
1266
|
+
url.searchParams.set("nonce", nonce);
|
|
1267
|
+
const session = { nonce };
|
|
1268
|
+
if (config.pkce) {
|
|
1269
|
+
const codeVerifier = base64url(randomBytes(32));
|
|
1270
|
+
const challenge = base64url(createHash("sha256").update(codeVerifier).digest());
|
|
1271
|
+
url.searchParams.set("code_challenge", challenge);
|
|
1272
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
1273
|
+
session.codeVerifier = codeVerifier;
|
|
1274
|
+
}
|
|
1275
|
+
return { redirectUrl: url.toString(), session };
|
|
1276
|
+
},
|
|
1277
|
+
async authenticate(input) {
|
|
1278
|
+
const bundle = asBundle(input);
|
|
1279
|
+
if (bundle.error) {
|
|
1280
|
+
return { ok: false, reason: "invalid" };
|
|
1281
|
+
}
|
|
1282
|
+
if (!bundle.code || !bundle.redirectUri) {
|
|
1283
|
+
return { ok: false, reason: "invalid" };
|
|
1284
|
+
}
|
|
1285
|
+
const { token_endpoint } = await discover();
|
|
1286
|
+
const body = new URLSearchParams({
|
|
1287
|
+
grant_type: "authorization_code",
|
|
1288
|
+
code: bundle.code,
|
|
1289
|
+
redirect_uri: bundle.redirectUri,
|
|
1290
|
+
client_id: config.clientId,
|
|
1291
|
+
client_secret: secret
|
|
1292
|
+
});
|
|
1293
|
+
if (config.pkce && bundle.session?.codeVerifier) {
|
|
1294
|
+
body.set("code_verifier", bundle.session.codeVerifier);
|
|
1295
|
+
}
|
|
1296
|
+
const tokenRes = await doFetch(token_endpoint, {
|
|
1297
|
+
method: "POST",
|
|
1298
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
1299
|
+
body: body.toString()
|
|
1300
|
+
});
|
|
1301
|
+
if (!tokenRes.ok) {
|
|
1302
|
+
throw new Error(`oidc "${config.id}": token endpoint error (${tokenRes.status})`);
|
|
1303
|
+
}
|
|
1304
|
+
const tokenJson = await tokenRes.json();
|
|
1305
|
+
const idToken = tokenJson.id_token;
|
|
1306
|
+
if (!idToken) {
|
|
1307
|
+
return { ok: false, reason: "invalid" };
|
|
1308
|
+
}
|
|
1309
|
+
let payload;
|
|
1310
|
+
try {
|
|
1311
|
+
payload = await verifyIdToken(idToken);
|
|
1312
|
+
} catch {
|
|
1313
|
+
return { ok: false, reason: "invalid" };
|
|
1314
|
+
}
|
|
1315
|
+
const expectedNonce = bundle.session?.nonce;
|
|
1316
|
+
if (!expectedNonce || payload.nonce !== expectedNonce) {
|
|
1317
|
+
return { ok: false, reason: "invalid" };
|
|
1318
|
+
}
|
|
1319
|
+
const email = typeof payload.email === "string" ? payload.email : void 0;
|
|
1320
|
+
if (!email) {
|
|
1321
|
+
return { ok: false, reason: "invalid" };
|
|
1322
|
+
}
|
|
1323
|
+
const emailVerified = payload.email_verified === true;
|
|
1324
|
+
if (!emailVerified && !config.allowUnverifiedEmail) {
|
|
1325
|
+
return { ok: false, reason: "invalid" };
|
|
1326
|
+
}
|
|
1327
|
+
return {
|
|
1328
|
+
ok: true,
|
|
1329
|
+
email: canonicalizeEmail(email),
|
|
1330
|
+
externalId: typeof payload.sub === "string" ? payload.sub : void 0
|
|
1331
|
+
};
|
|
1332
|
+
}
|
|
1333
|
+
};
|
|
1334
|
+
return provider;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
// src/provider-registry.ts
|
|
1338
|
+
var ProviderRegistry = class {
|
|
1339
|
+
providers = /* @__PURE__ */ new Map();
|
|
1340
|
+
register(provider) {
|
|
1341
|
+
if (this.providers.has(provider.id)) {
|
|
1342
|
+
throw new Error(`provider-registry: id "${provider.id}" is already registered`);
|
|
1343
|
+
}
|
|
1344
|
+
this.providers.set(provider.id, provider);
|
|
1345
|
+
}
|
|
1346
|
+
get(id) {
|
|
1347
|
+
return this.providers.get(id);
|
|
1348
|
+
}
|
|
1349
|
+
has(id) {
|
|
1350
|
+
return this.providers.has(id);
|
|
1351
|
+
}
|
|
1352
|
+
list() {
|
|
1353
|
+
return [...this.providers.values()].map((p) => ({ id: p.id, kind: p.kind }));
|
|
1354
|
+
}
|
|
1355
|
+
};
|
|
1356
|
+
|
|
1357
|
+
// src/provider-loader.ts
|
|
1358
|
+
var emailPasswordFactory = (_config, deps) => createEmailPasswordProvider({
|
|
1359
|
+
users: deps.users,
|
|
1360
|
+
credentials: deps.credentials,
|
|
1361
|
+
tenantId: deps.tenantId,
|
|
1362
|
+
bcryptCost: deps.bcryptCost
|
|
1363
|
+
});
|
|
1364
|
+
var BUILTIN_FACTORIES = {
|
|
1365
|
+
"email-password": emailPasswordFactory,
|
|
1366
|
+
oidc: createOidcProvider
|
|
1367
|
+
};
|
|
1368
|
+
async function resolveFactory(id, type) {
|
|
1369
|
+
const builtin = BUILTIN_FACTORIES[type];
|
|
1370
|
+
if (builtin) {
|
|
1371
|
+
return builtin;
|
|
1372
|
+
}
|
|
1373
|
+
let mod;
|
|
1374
|
+
try {
|
|
1375
|
+
mod = await import(type);
|
|
1376
|
+
} catch (err) {
|
|
1377
|
+
throw new Error(
|
|
1378
|
+
`identity provider "${id}": failed to load package "${type}": ${err.message}`
|
|
1379
|
+
);
|
|
1380
|
+
}
|
|
1381
|
+
const factory = mod.createIdentityProvider ?? mod.default;
|
|
1382
|
+
if (typeof factory !== "function") {
|
|
1383
|
+
throw new Error(
|
|
1384
|
+
`identity provider "${id}": package "${type}" exports neither createIdentityProvider nor a default factory`
|
|
1385
|
+
);
|
|
1386
|
+
}
|
|
1387
|
+
return factory;
|
|
1388
|
+
}
|
|
1389
|
+
async function loadIdentityProviders(config, deps) {
|
|
1390
|
+
const registry = new ProviderRegistry();
|
|
1391
|
+
const entries = Object.entries(config ?? {});
|
|
1392
|
+
if (entries.length === 0) {
|
|
1393
|
+
const provider = await emailPasswordFactory(
|
|
1394
|
+
{ },
|
|
1395
|
+
deps
|
|
1396
|
+
);
|
|
1397
|
+
registry.register(provider);
|
|
1398
|
+
return registry;
|
|
1399
|
+
}
|
|
1400
|
+
for (const [id, entry] of entries) {
|
|
1401
|
+
const factory = await resolveFactory(id, entry.type);
|
|
1402
|
+
const provider = await factory({ ...entry, id }, deps);
|
|
1403
|
+
registry.register(provider);
|
|
1404
|
+
}
|
|
1405
|
+
return registry;
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// src/oauth-state-store.ts
|
|
1409
|
+
var DEFAULT_TTL_MS = 10 * 60 * 1e3;
|
|
1410
|
+
var KEY_PREFIX = "oauth:state:";
|
|
1411
|
+
var OAuthStateStore = class {
|
|
1412
|
+
constructor(kv, opts = {}) {
|
|
1413
|
+
this.kv = kv;
|
|
1414
|
+
this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
1415
|
+
}
|
|
1416
|
+
kv;
|
|
1417
|
+
ttlMs;
|
|
1418
|
+
key(state) {
|
|
1419
|
+
return `${KEY_PREFIX}${state}`;
|
|
1420
|
+
}
|
|
1421
|
+
/** Persist a state record with the configured TTL. */
|
|
1422
|
+
async put(state, record) {
|
|
1423
|
+
await this.kv.set(this.key(state), record, { ttlMs: this.ttlMs });
|
|
1424
|
+
}
|
|
1425
|
+
/**
|
|
1426
|
+
* One-shot read. Returns the record to the first caller and deletes it;
|
|
1427
|
+
* concurrent / subsequent callers get `null`. The `delete` result gates
|
|
1428
|
+
* the return so exactly one caller wins even under a shared KV.
|
|
1429
|
+
*/
|
|
1430
|
+
async consume(state) {
|
|
1431
|
+
const key = this.key(state);
|
|
1432
|
+
const record = await this.kv.get(key);
|
|
1433
|
+
if (!record) {
|
|
1434
|
+
return null;
|
|
1435
|
+
}
|
|
1436
|
+
const removed = await this.kv.delete(key);
|
|
1437
|
+
if (!removed) {
|
|
1438
|
+
return null;
|
|
1439
|
+
}
|
|
1440
|
+
return record;
|
|
1441
|
+
}
|
|
1442
|
+
};
|
|
1443
|
+
var ALL_PERMISSIONS = Object.freeze(Object.values(PERMISSIONS));
|
|
1444
|
+
var MACHINE_PERMISSIONS = Object.freeze([PERMISSIONS.MACHINE_REGISTER]);
|
|
1445
|
+
var GROUP_PERMISSIONS = Object.freeze({
|
|
1446
|
+
"tenant-admin": ALL_PERMISSIONS,
|
|
1447
|
+
"tenant-member": Object.freeze([])
|
|
1448
|
+
});
|
|
1449
|
+
var createStubPDP = (opts) => {
|
|
1450
|
+
const permissionsForUser = async (identity) => {
|
|
1451
|
+
const list = await opts.memberships.listByUser(identity.userId);
|
|
1452
|
+
const inTenant = list.find((m) => m.tenantId === identity.tenantId);
|
|
1453
|
+
if (!inTenant) {
|
|
1454
|
+
return [];
|
|
1455
|
+
}
|
|
1456
|
+
return GROUP_PERMISSIONS[inTenant.groupId];
|
|
1457
|
+
};
|
|
1458
|
+
return {
|
|
1459
|
+
async check(identity, action, _resource, _ctx) {
|
|
1460
|
+
if (identity.type === "machine") {
|
|
1461
|
+
if (MACHINE_PERMISSIONS.includes(action)) {
|
|
1462
|
+
return { allow: true };
|
|
1463
|
+
}
|
|
1464
|
+
return { allow: true };
|
|
1465
|
+
}
|
|
1466
|
+
const perms = await permissionsForUser(identity);
|
|
1467
|
+
if (perms.length === 0) {
|
|
1468
|
+
return { allow: false, reason: "no_membership" };
|
|
1469
|
+
}
|
|
1470
|
+
if (!perms.includes(action)) {
|
|
1471
|
+
return { allow: false, reason: "permission_denied" };
|
|
1472
|
+
}
|
|
1473
|
+
return { allow: true };
|
|
1474
|
+
},
|
|
1475
|
+
async enumeratePermissions(identity) {
|
|
1476
|
+
if (identity.type === "machine") {
|
|
1477
|
+
return [...MACHINE_PERMISSIONS];
|
|
1478
|
+
}
|
|
1479
|
+
const perms = await permissionsForUser(identity);
|
|
1480
|
+
return [...perms];
|
|
1481
|
+
}
|
|
1482
|
+
};
|
|
1483
|
+
};
|
|
1484
|
+
var isNonEmptyString = (v) => typeof v === "string" && v.length > 0;
|
|
1485
|
+
var ensureBootstrapAdmin = async (opts) => {
|
|
1486
|
+
const { bootstrap, users, credentials, memberships, bcryptCost, logger } = opts;
|
|
1487
|
+
if (!bootstrap) {
|
|
1488
|
+
logger.info("bootstrap-admin: no bootstrap config provided, skipping");
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
if (!isNonEmptyString(bootstrap.adminEmail) || !isNonEmptyString(bootstrap.adminPassword) || !isNonEmptyString(bootstrap.tenantId)) {
|
|
1492
|
+
throw new Error(
|
|
1493
|
+
"bootstrap-admin: incomplete bootstrap config (need adminEmail, adminPassword, tenantId)"
|
|
1494
|
+
);
|
|
1495
|
+
}
|
|
1496
|
+
const existing = await users.findByEmailTenant(bootstrap.adminEmail, bootstrap.tenantId);
|
|
1497
|
+
if (existing) {
|
|
1498
|
+
if (existing.status === "active") {
|
|
1499
|
+
logger.info("bootstrap-admin: admin already exists and is active, skipping");
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1502
|
+
logger.warn(
|
|
1503
|
+
"bootstrap-admin: a user with the bootstrap email already exists in a non-active state; not touching it",
|
|
1504
|
+
{ userId: existing.userId, status: existing.status, tenantId: existing.tenantId }
|
|
1505
|
+
);
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
const userId = randomUUID();
|
|
1509
|
+
await users.create({
|
|
1510
|
+
userId,
|
|
1511
|
+
tenantId: bootstrap.tenantId,
|
|
1512
|
+
email: bootstrap.adminEmail,
|
|
1513
|
+
status: "active"
|
|
1514
|
+
});
|
|
1515
|
+
const hash = await bcrypt.hash(bootstrap.adminPassword, bcryptCost);
|
|
1516
|
+
await credentials.setCredential({
|
|
1517
|
+
userId,
|
|
1518
|
+
providerId: "email-password",
|
|
1519
|
+
hash
|
|
1520
|
+
});
|
|
1521
|
+
await memberships.addMembership({
|
|
1522
|
+
userId,
|
|
1523
|
+
tenantId: bootstrap.tenantId,
|
|
1524
|
+
groupId: "tenant-admin"
|
|
1525
|
+
});
|
|
1526
|
+
logger.info("bootstrap-admin: provisioned tenant-admin", {
|
|
1527
|
+
userId,
|
|
1528
|
+
tenantId: bootstrap.tenantId
|
|
1529
|
+
});
|
|
1530
|
+
};
|
|
1531
|
+
var AuthError = class extends Error {
|
|
1532
|
+
constructor(code, reason) {
|
|
1533
|
+
super(code);
|
|
1534
|
+
this.code = code;
|
|
1535
|
+
this.reason = reason;
|
|
1536
|
+
}
|
|
1537
|
+
code;
|
|
1538
|
+
reason;
|
|
1539
|
+
name = "AuthError";
|
|
1540
|
+
};
|
|
1541
|
+
var toPublicUser = (u) => ({
|
|
1542
|
+
userId: u.userId,
|
|
1543
|
+
email: u.email,
|
|
1544
|
+
tenantId: u.tenantId
|
|
1545
|
+
});
|
|
1546
|
+
var mapPolicyResultToError = (r) => {
|
|
1547
|
+
if (r.ok) {
|
|
1548
|
+
return null;
|
|
1549
|
+
}
|
|
1550
|
+
return new AuthError("weak_password", r.reason);
|
|
1551
|
+
};
|
|
1552
|
+
var createUserAuthService = (opts) => {
|
|
1553
|
+
opts.now ?? Date.now;
|
|
1554
|
+
const issueSessionTokens = async (userId, tenantId, familyId, refreshJti) => {
|
|
1555
|
+
const access = await signUserAccessToken(
|
|
1556
|
+
{ userId, tenantId, familyId, ttlSec: opts.accessTtlSec },
|
|
1557
|
+
opts.jwtConfig
|
|
1558
|
+
);
|
|
1559
|
+
const refresh2 = await signUserRefreshToken(
|
|
1560
|
+
{ userId, tenantId, familyId, ttlSec: opts.refreshTtlSec, jti: refreshJti },
|
|
1561
|
+
opts.jwtConfig
|
|
1562
|
+
);
|
|
1563
|
+
return {
|
|
1564
|
+
access: { token: access.token, expiresInSec: opts.accessTtlSec },
|
|
1565
|
+
refresh: { token: refresh2, expiresInSec: opts.refreshTtlSec },
|
|
1566
|
+
csrf: issueCsrfToken()
|
|
1567
|
+
};
|
|
1568
|
+
};
|
|
1569
|
+
const login = async (input, tenantId, deviceCtx) => {
|
|
1570
|
+
const provider = opts.providers.get(input.providerId);
|
|
1571
|
+
if (!provider) {
|
|
1572
|
+
throw new AuthError("unknown_provider");
|
|
1573
|
+
}
|
|
1574
|
+
const result = await provider.authenticate(input.input);
|
|
1575
|
+
if (!result.ok) {
|
|
1576
|
+
throw new AuthError("invalid_credentials");
|
|
1577
|
+
}
|
|
1578
|
+
const user = await opts.users.findByEmailTenant(canonicalizeEmail(result.email), tenantId);
|
|
1579
|
+
if (!user || user.status !== "active") {
|
|
1580
|
+
throw new AuthError("invalid_credentials");
|
|
1581
|
+
}
|
|
1582
|
+
const sess = await opts.sessions.createSession({
|
|
1583
|
+
userId: user.userId,
|
|
1584
|
+
tenantId,
|
|
1585
|
+
deviceCtx
|
|
1586
|
+
});
|
|
1587
|
+
const tokens = await issueSessionTokens(user.userId, tenantId, sess.familyId, sess.refreshJti);
|
|
1588
|
+
return { user: toPublicUser(user), ...tokens, familyId: sess.familyId };
|
|
1589
|
+
};
|
|
1590
|
+
const refresh = async (refreshToken) => {
|
|
1591
|
+
const payload = await verifyUserRefreshToken(refreshToken, opts.jwtConfig);
|
|
1592
|
+
if (!payload) {
|
|
1593
|
+
throw new AuthError("invalid_refresh");
|
|
1594
|
+
}
|
|
1595
|
+
const user = await opts.users.getById(payload.userId);
|
|
1596
|
+
if (!user || user.status !== "active") {
|
|
1597
|
+
await opts.sessions.revokeFamily(payload.familyId);
|
|
1598
|
+
throw new AuthError("user_disabled");
|
|
1599
|
+
}
|
|
1600
|
+
let rotated;
|
|
1601
|
+
try {
|
|
1602
|
+
rotated = await opts.sessions.rotateRefresh(payload.jti);
|
|
1603
|
+
} catch (err) {
|
|
1604
|
+
if (err instanceof RefreshReuseDetectedError) {
|
|
1605
|
+
throw new AuthError("refresh_reuse");
|
|
1606
|
+
}
|
|
1607
|
+
if (err instanceof RefreshExpiredError) {
|
|
1608
|
+
throw new AuthError("invalid_refresh");
|
|
1609
|
+
}
|
|
1610
|
+
if (err instanceof RefreshNotFoundError) {
|
|
1611
|
+
throw new AuthError("invalid_refresh");
|
|
1612
|
+
}
|
|
1613
|
+
throw err;
|
|
1614
|
+
}
|
|
1615
|
+
const tokens = await issueSessionTokens(user.userId, user.tenantId, rotated.familyId, rotated.newJti);
|
|
1616
|
+
return { user: toPublicUser(user), ...tokens, familyId: rotated.familyId };
|
|
1617
|
+
};
|
|
1618
|
+
const logout = async (refreshToken) => {
|
|
1619
|
+
const payload = await verifyUserRefreshToken(refreshToken, opts.jwtConfig);
|
|
1620
|
+
if (!payload) {
|
|
1621
|
+
return;
|
|
1622
|
+
}
|
|
1623
|
+
await opts.sessions.revokeFamily(payload.familyId);
|
|
1624
|
+
};
|
|
1625
|
+
const changePassword = async (input) => {
|
|
1626
|
+
const user = await opts.users.getById(input.userId);
|
|
1627
|
+
if (!user || user.status !== "active") {
|
|
1628
|
+
throw new AuthError("invalid_current_password");
|
|
1629
|
+
}
|
|
1630
|
+
const cred = await opts.credentials.getCredential(user.userId, "email-password");
|
|
1631
|
+
if (!cred) {
|
|
1632
|
+
throw new AuthError("invalid_current_password");
|
|
1633
|
+
}
|
|
1634
|
+
const ok = await bcrypt.compare(input.currentPassword, cred.hash);
|
|
1635
|
+
if (!ok) {
|
|
1636
|
+
throw new AuthError("invalid_current_password");
|
|
1637
|
+
}
|
|
1638
|
+
const policyResult = await opts.passwordPolicy.validate(input.newPassword);
|
|
1639
|
+
const polErr = mapPolicyResultToError(policyResult);
|
|
1640
|
+
if (polErr) {
|
|
1641
|
+
throw polErr;
|
|
1642
|
+
}
|
|
1643
|
+
const hash = await bcrypt.hash(input.newPassword, opts.bcryptCost);
|
|
1644
|
+
await opts.credentials.setCredential({
|
|
1645
|
+
userId: user.userId,
|
|
1646
|
+
providerId: "email-password",
|
|
1647
|
+
hash
|
|
1648
|
+
});
|
|
1649
|
+
await opts.sessions.revokeAllUserSessionsExcept(user.userId, input.currentFamilyId);
|
|
1650
|
+
};
|
|
1651
|
+
const activate = async (input) => {
|
|
1652
|
+
const tokenResult = await opts.invites.findByToken(input.activationToken);
|
|
1653
|
+
if (tokenResult.kind === "not_found") {
|
|
1654
|
+
throw new AuthError("unknown_invite");
|
|
1655
|
+
}
|
|
1656
|
+
if (tokenResult.kind === "invalid") {
|
|
1657
|
+
throw new AuthError("invalid_invite");
|
|
1658
|
+
}
|
|
1659
|
+
const invite = tokenResult.invite;
|
|
1660
|
+
const policyResult = await opts.passwordPolicy.validate(input.password);
|
|
1661
|
+
const polErr = mapPolicyResultToError(policyResult);
|
|
1662
|
+
if (polErr) {
|
|
1663
|
+
throw polErr;
|
|
1664
|
+
}
|
|
1665
|
+
const consumed = await opts.invites.consume(invite.inviteId);
|
|
1666
|
+
if (!consumed) {
|
|
1667
|
+
throw new AuthError("invalid_invite");
|
|
1668
|
+
}
|
|
1669
|
+
const userId = randomUUID();
|
|
1670
|
+
await opts.users.create({
|
|
1671
|
+
userId,
|
|
1672
|
+
tenantId: invite.tenantId,
|
|
1673
|
+
email: invite.email,
|
|
1674
|
+
status: "active"
|
|
1675
|
+
});
|
|
1676
|
+
const hash = await bcrypt.hash(input.password, opts.bcryptCost);
|
|
1677
|
+
await opts.credentials.setCredential({
|
|
1678
|
+
userId,
|
|
1679
|
+
providerId: "email-password",
|
|
1680
|
+
hash
|
|
1681
|
+
});
|
|
1682
|
+
await opts.memberships.addMembership({
|
|
1683
|
+
userId,
|
|
1684
|
+
tenantId: invite.tenantId,
|
|
1685
|
+
groupId: invite.groupId
|
|
1686
|
+
});
|
|
1687
|
+
const sess = await opts.sessions.createSession({
|
|
1688
|
+
userId,
|
|
1689
|
+
tenantId: invite.tenantId,
|
|
1690
|
+
deviceCtx: input.deviceCtx
|
|
1691
|
+
});
|
|
1692
|
+
const tokens = await issueSessionTokens(userId, invite.tenantId, sess.familyId, sess.refreshJti);
|
|
1693
|
+
return {
|
|
1694
|
+
user: { userId, email: invite.email, tenantId: invite.tenantId },
|
|
1695
|
+
...tokens,
|
|
1696
|
+
familyId: sess.familyId
|
|
1697
|
+
};
|
|
1698
|
+
};
|
|
1699
|
+
return { login, refresh, logout, changePassword, activate };
|
|
1700
|
+
};
|
|
1701
|
+
|
|
1702
|
+
export { AuthError, AuthService, BUILTIN_FACTORIES, CredentialsStore, InvitesStore, MembershipsStore, OAuthStateStore, ProviderRegistry, RESERVED_SUBDOMAINS, RefreshExpiredError, RefreshNotFoundError, RefreshReuseDetectedError, SessionsStore, UsersStore, buildClientRecord, canonicalizeEmail, consumeRefreshToken, createEmailPasswordProvider, createOidcProvider, createPasswordPolicy, createRateLimiter, createStubPDP, createTenantResolver, createUserAuthService, ensureBootstrapAdmin, generateClientId, generateClientSecret, generateHostId, getClient, getClientByHandle, getClientByHostId, getPublicKey, isHandleTaken, issueCsrfToken, loadIdentityProviders, saveClient, savePublicKey, saveRefreshToken, signAccessToken, signRefreshToken, signUserAccessToken, signUserRefreshToken, verifyAccessToken, verifyClientSecret, verifyCsrfToken, verifyRefreshToken, verifyUserAccessToken, verifyUserRefreshToken };
|
|
213
1703
|
//# sourceMappingURL=index.js.map
|
|
214
1704
|
//# sourceMappingURL=index.js.map
|