@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.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
- { hostId, namespaceId: stored.namespaceId, tier: "free", type: "machine" },
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
- export { AuthService, buildClientRecord, consumeRefreshToken, generateClientId, generateClientSecret, generateHostId, getClient, getClientByHostId, getPublicKey, saveClient, savePublicKey, saveRefreshToken, signAccessToken, signRefreshToken, verifyAccessToken, verifyClientSecret, verifyRefreshToken };
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