@korajs/auth 0.1.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -1,7 +1,23 @@
1
1
  import {
2
+ OperationEncryptionError,
3
+ OperationEncryptor,
2
4
  computePublicKeyThumbprint,
5
+ decodeCbor,
6
+ fromBase64Url,
7
+ isEncryptedField,
8
+ toBase64Url,
3
9
  verifyChallenge
4
- } from "./chunk-L554ZDPY.js";
10
+ } from "./chunk-FSU4SK32.js";
11
+ import {
12
+ hashPassword,
13
+ verifyPassword
14
+ } from "./chunk-HOZXDR6Y.js";
15
+
16
+ // src/provider/built-in/auth-routes.ts
17
+ import { randomBytes as randomBytes2 } from "crypto";
18
+
19
+ // src/tokens/token-manager.ts
20
+ import { randomBytes, randomUUID } from "crypto";
5
21
 
6
22
  // src/types.ts
7
23
  import { KoraError } from "@korajs/core";
@@ -60,6 +76,9 @@ function verifyJwt(token, secret) {
60
76
  if (headerSegment === void 0 || payloadSegment === void 0 || signatureSegment === void 0) {
61
77
  return null;
62
78
  }
79
+ if (headerSegment !== ENCODED_HEADER) {
80
+ return null;
81
+ }
63
82
  const signingInput = `${headerSegment}.${payloadSegment}`;
64
83
  const expectedSignature = hmacSha256Base64url(signingInput, secret);
65
84
  if (expectedSignature.length !== signatureSegment.length) {
@@ -83,25 +102,83 @@ function verifyJwt(token, secret) {
83
102
  return null;
84
103
  }
85
104
  }
105
+ var CLOCK_SKEW_TOLERANCE_SECONDS = 5;
86
106
  function isExpired(payload) {
87
107
  if (typeof payload.exp !== "number") {
88
108
  return false;
89
109
  }
90
110
  const nowSeconds = Math.floor(Date.now() / 1e3);
91
- return nowSeconds >= payload.exp;
111
+ return nowSeconds >= payload.exp + CLOCK_SKEW_TOLERANCE_SECONDS;
92
112
  }
93
113
 
94
114
  // src/tokens/token-manager.ts
115
+ var MIN_SECRET_LENGTH = 32;
116
+ var InMemoryTokenRevocationStore = class {
117
+ revokedTokens = /* @__PURE__ */ new Map();
118
+ revokedDevices = /* @__PURE__ */ new Set();
119
+ async isRevoked(jti) {
120
+ return this.revokedTokens.has(jti);
121
+ }
122
+ async revoke(jti, expiresAt) {
123
+ this.revokedTokens.set(jti, expiresAt);
124
+ }
125
+ async revokeAllForDevice(deviceId) {
126
+ this.revokedDevices.add(deviceId);
127
+ }
128
+ /**
129
+ * Check if a device has been revoked.
130
+ */
131
+ isDeviceRevoked(deviceId) {
132
+ return this.revokedDevices.has(deviceId);
133
+ }
134
+ /**
135
+ * Remove expired revocations to prevent unbounded memory growth.
136
+ * Call periodically (e.g., every hour) in long-running servers.
137
+ */
138
+ cleanup() {
139
+ const nowSeconds = Math.floor(Date.now() / 1e3);
140
+ for (const [jti, expiresAt] of this.revokedTokens) {
141
+ if (nowSeconds > expiresAt) {
142
+ this.revokedTokens.delete(jti);
143
+ }
144
+ }
145
+ }
146
+ };
95
147
  var TokenManager = class {
96
- secret;
148
+ /** All signing/verification secrets (index 0 = current signing key) */
149
+ secrets;
97
150
  accessTokenLifetime;
98
151
  refreshTokenLifetime;
99
152
  deviceCredentialLifetime;
153
+ revocationStore;
100
154
  constructor(config) {
101
- this.secret = config.secret;
155
+ const secrets = Array.isArray(config.secret) ? config.secret : [config.secret];
156
+ if (secrets.length === 0) {
157
+ throw new Error("TokenManager requires at least one secret.");
158
+ }
159
+ for (const secret of secrets) {
160
+ if (secret.length < MIN_SECRET_LENGTH) {
161
+ throw new Error(
162
+ `JWT secret must be at least ${MIN_SECRET_LENGTH} characters (256 bits) for HMAC-SHA256 security. Received ${secret.length} characters. Use TokenManager.generateSecret() to generate a secure secret.`
163
+ );
164
+ }
165
+ }
166
+ this.secrets = secrets;
102
167
  this.accessTokenLifetime = config.accessTokenLifetime ?? DEFAULT_ACCESS_TOKEN_LIFETIME;
103
168
  this.refreshTokenLifetime = config.refreshTokenLifetime ?? DEFAULT_REFRESH_TOKEN_LIFETIME;
104
169
  this.deviceCredentialLifetime = config.deviceCredentialLifetime ?? DEFAULT_DEVICE_CREDENTIAL_LIFETIME;
170
+ this.revocationStore = config.revocationStore;
171
+ }
172
+ /**
173
+ * Generate a cryptographically random secret suitable for HMAC-SHA256 signing.
174
+ *
175
+ * Returns a 64-character hex string (32 bytes / 256 bits of entropy).
176
+ * Store this securely (environment variable, secrets manager) — never in source code.
177
+ *
178
+ * @returns A random 256-bit hex-encoded secret
179
+ */
180
+ static generateSecret() {
181
+ return randomBytes(32).toString("hex");
105
182
  }
106
183
  /**
107
184
  * Issue a signed JWT access token.
@@ -117,13 +194,14 @@ var TokenManager = class {
117
194
  issueAccessToken(userId, deviceId) {
118
195
  const nowSeconds = Math.floor(Date.now() / 1e3);
119
196
  const payload = {
197
+ jti: randomUUID(),
120
198
  sub: userId,
121
199
  dev: deviceId,
122
200
  type: "access",
123
201
  iat: nowSeconds,
124
202
  exp: nowSeconds + Math.floor(this.accessTokenLifetime / 1e3)
125
203
  };
126
- return encodeJwt(payload, this.secret);
204
+ return encodeJwt(payload, this.secrets[0]);
127
205
  }
128
206
  /**
129
207
  * Issue a signed JWT refresh token.
@@ -139,13 +217,14 @@ var TokenManager = class {
139
217
  issueRefreshToken(userId, deviceId) {
140
218
  const nowSeconds = Math.floor(Date.now() / 1e3);
141
219
  const payload = {
220
+ jti: randomUUID(),
142
221
  sub: userId,
143
222
  dev: deviceId,
144
223
  type: "refresh",
145
224
  iat: nowSeconds,
146
225
  exp: nowSeconds + Math.floor(this.refreshTokenLifetime / 1e3)
147
226
  };
148
- return encodeJwt(payload, this.secret);
227
+ return encodeJwt(payload, this.secrets[0]);
149
228
  }
150
229
  /**
151
230
  * Issue a signed device credential token.
@@ -163,6 +242,7 @@ var TokenManager = class {
163
242
  const nowSeconds = Math.floor(Date.now() / 1e3);
164
243
  const lifetimeSeconds = Math.floor(this.deviceCredentialLifetime / 1e3);
165
244
  const payload = {
245
+ jti: randomUUID(),
166
246
  sub: userId,
167
247
  dev: deviceId,
168
248
  type: "device_credential",
@@ -171,7 +251,7 @@ var TokenManager = class {
171
251
  dpk: publicKeyThumbprint,
172
252
  mustCheckinBy: nowSeconds + lifetimeSeconds
173
253
  };
174
- return encodeJwt(payload, this.secret);
254
+ return encodeJwt(payload, this.secrets[0]);
175
255
  }
176
256
  /**
177
257
  * Issue a complete set of authentication tokens.
@@ -202,7 +282,8 @@ var TokenManager = class {
202
282
  /**
203
283
  * Validate and decode a token.
204
284
  *
205
- * Verifies the HMAC-SHA256 signature and checks that the token has not expired.
285
+ * Verifies the HMAC-SHA256 signature (trying all configured secrets for key rotation),
286
+ * checks that the token has not expired, and validates all required claims.
206
287
  * Returns null (rather than throwing) for invalid or expired tokens, so callers
207
288
  * can handle authentication failure without try/catch.
208
289
  *
@@ -211,14 +292,20 @@ var TokenManager = class {
211
292
  * invalid, expired, or missing required claims
212
293
  */
213
294
  validateToken(token) {
214
- const decoded = verifyJwt(token, this.secret);
295
+ let decoded = null;
296
+ for (const secret of this.secrets) {
297
+ decoded = verifyJwt(token, secret);
298
+ if (decoded !== null) {
299
+ break;
300
+ }
301
+ }
215
302
  if (decoded === null) {
216
303
  return null;
217
304
  }
218
305
  if (isExpired(decoded)) {
219
306
  return null;
220
307
  }
221
- if (typeof decoded["sub"] !== "string" || typeof decoded["dev"] !== "string" || typeof decoded["type"] !== "string" || typeof decoded["iat"] !== "number" || typeof decoded["exp"] !== "number") {
308
+ if (typeof decoded["jti"] !== "string" || typeof decoded["sub"] !== "string" || typeof decoded["dev"] !== "string" || typeof decoded["type"] !== "string" || typeof decoded["iat"] !== "number" || typeof decoded["exp"] !== "number") {
222
309
  return null;
223
310
  }
224
311
  const type = decoded["type"];
@@ -226,6 +313,7 @@ var TokenManager = class {
226
313
  return null;
227
314
  }
228
315
  return {
316
+ jti: decoded["jti"],
229
317
  sub: decoded["sub"],
230
318
  dev: decoded["dev"],
231
319
  type,
@@ -233,19 +321,69 @@ var TokenManager = class {
233
321
  exp: decoded["exp"]
234
322
  };
235
323
  }
324
+ /**
325
+ * Validate a token and check it against the revocation store.
326
+ *
327
+ * Like {@link validateToken}, but also checks whether the token's `jti` has been
328
+ * revoked. Requires a revocation store to be configured.
329
+ *
330
+ * @param token - The JWT string to validate
331
+ * @returns The decoded {@link TokenPayload} if valid and not revoked, or null otherwise
332
+ */
333
+ async validateTokenWithRevocation(token) {
334
+ const payload = this.validateToken(token);
335
+ if (payload === null) {
336
+ return null;
337
+ }
338
+ if (this.revocationStore) {
339
+ const revoked = await this.revocationStore.isRevoked(payload.jti);
340
+ if (revoked) {
341
+ return null;
342
+ }
343
+ }
344
+ return payload;
345
+ }
346
+ /**
347
+ * Revoke a specific token by its JWT ID.
348
+ *
349
+ * Requires a revocation store to be configured. After revocation, the token
350
+ * will be rejected by {@link validateTokenWithRevocation}.
351
+ *
352
+ * @param jti - The JWT ID of the token to revoke
353
+ * @param expiresAt - The token's expiration time (seconds since epoch)
354
+ */
355
+ async revokeToken(jti, expiresAt) {
356
+ if (this.revocationStore) {
357
+ await this.revocationStore.revoke(jti, expiresAt);
358
+ }
359
+ }
360
+ /**
361
+ * Revoke all tokens for a specific device.
362
+ *
363
+ * Called when a device is revoked to ensure all its existing tokens
364
+ * (access, refresh, and device credentials) are invalidated.
365
+ *
366
+ * @param deviceId - The device ID whose tokens should be revoked
367
+ */
368
+ async revokeDeviceTokens(deviceId) {
369
+ if (this.revocationStore) {
370
+ await this.revocationStore.revokeAllForDevice(deviceId);
371
+ }
372
+ }
236
373
  /**
237
374
  * Refresh an access token using a valid refresh token.
238
375
  *
239
- * Implements **refresh token rotation**: a new refresh token is issued alongside
240
- * the new access token, and the old refresh token should be considered consumed.
241
- * This limits the window of vulnerability if a refresh token is compromised.
376
+ * Implements **refresh token rotation with reuse detection**: a new refresh token
377
+ * is issued alongside the new access token. The old refresh token's `jti` is
378
+ * recorded in the revocation store (if configured). If a previously consumed
379
+ * refresh token is presented again, it indicates potential token theft.
242
380
  *
243
381
  * Returns null if the provided token is invalid, expired, or not a refresh token.
244
382
  *
245
383
  * @param refreshToken - The refresh token JWT string
246
384
  * @returns A new access/refresh token pair, or null if the refresh token is invalid
247
385
  */
248
- refreshAccessToken(refreshToken) {
386
+ async refreshAccessToken(refreshToken) {
249
387
  const payload = this.validateToken(refreshToken);
250
388
  if (payload === null) {
251
389
  return null;
@@ -253,6 +391,14 @@ var TokenManager = class {
253
391
  if (payload.type !== "refresh") {
254
392
  return null;
255
393
  }
394
+ if (this.revocationStore) {
395
+ const wasRevoked = await this.revocationStore.isRevoked(payload.jti);
396
+ if (wasRevoked) {
397
+ await this.revocationStore.revokeAllForDevice(payload.dev);
398
+ return null;
399
+ }
400
+ await this.revocationStore.revoke(payload.jti, payload.exp);
401
+ }
256
402
  return {
257
403
  accessToken: this.issueAccessToken(payload.sub, payload.dev),
258
404
  refreshToken: this.issueRefreshToken(payload.sub, payload.dev)
@@ -260,62 +406,14 @@ var TokenManager = class {
260
406
  }
261
407
  };
262
408
 
263
- // src/provider/built-in/password-hash.ts
264
- import { randomBytes, pbkdf2, timingSafeEqual } from "crypto";
265
- var PBKDF2_ITERATIONS = 6e5;
266
- var PBKDF2_DIGEST = "sha512";
267
- var KEY_LENGTH = 64;
268
- var SALT_LENGTH = 32;
269
- async function hashPassword(password) {
270
- const salt = randomBytes(SALT_LENGTH);
271
- const derivedKey = await pbkdf2Async(
272
- password,
273
- salt,
274
- PBKDF2_ITERATIONS,
275
- KEY_LENGTH,
276
- PBKDF2_DIGEST
277
- );
278
- return {
279
- hash: derivedKey.toString("hex"),
280
- salt: salt.toString("hex")
281
- };
282
- }
283
- async function verifyPassword(password, hash, salt) {
284
- const saltBuffer = Buffer.from(salt, "hex");
285
- const derivedKey = await pbkdf2Async(
286
- password,
287
- saltBuffer,
288
- PBKDF2_ITERATIONS,
289
- KEY_LENGTH,
290
- PBKDF2_DIGEST
291
- );
292
- const hashBuffer = Buffer.from(hash, "hex");
293
- if (derivedKey.length !== hashBuffer.length) {
294
- return false;
295
- }
296
- return timingSafeEqual(derivedKey, hashBuffer);
297
- }
298
- function pbkdf2Async(password, salt, iterations, keyLength, digest) {
299
- return new Promise((resolve, reject) => {
300
- pbkdf2(password, salt, iterations, keyLength, digest, (err, derivedKey) => {
301
- if (err) {
302
- reject(err);
303
- } else {
304
- resolve(derivedKey);
305
- }
306
- });
307
- });
308
- }
309
-
310
409
  // src/provider/built-in/user-store.ts
311
410
  import { KoraError as KoraError2 } from "@korajs/core";
312
- import { randomUUID } from "crypto";
411
+ import { randomUUID as randomUUID2 } from "crypto";
313
412
  var DuplicateEmailError = class extends KoraError2 {
314
- constructor(email) {
413
+ constructor() {
315
414
  super(
316
- `A user with email "${email}" already exists. Use a different email address or sign in to the existing account.`,
317
- "DUPLICATE_EMAIL",
318
- { email }
415
+ "A user with this email already exists.",
416
+ "DUPLICATE_EMAIL"
319
417
  );
320
418
  this.name = "DuplicateEmailError";
321
419
  }
@@ -343,14 +441,15 @@ var InMemoryUserStore = class {
343
441
  async createUser(params) {
344
442
  const normalizedEmail = params.email.toLowerCase();
345
443
  if (this.usersByEmail.has(normalizedEmail)) {
346
- throw new DuplicateEmailError(params.email);
444
+ throw new DuplicateEmailError();
347
445
  }
348
446
  const now = Date.now();
349
- const id = randomUUID();
447
+ const id = randomUUID2();
350
448
  const storedUser = {
351
449
  id,
352
450
  email: normalizedEmail,
353
451
  name: params.name,
452
+ emailVerified: false,
354
453
  createdAt: now,
355
454
  passwordHash: params.passwordHash,
356
455
  salt: params.salt
@@ -458,6 +557,69 @@ var InMemoryUserStore = class {
458
557
  device.revoked = true;
459
558
  }
460
559
  }
560
+ /**
561
+ * Set a user's email verification status.
562
+ *
563
+ * @param userId - The user whose email to verify
564
+ * @param verified - Whether the email is verified
565
+ */
566
+ async setEmailVerified(userId, verified) {
567
+ const user = this.usersById.get(userId);
568
+ if (!user) return;
569
+ const updated = { ...user, emailVerified: verified };
570
+ this.usersById.set(userId, updated);
571
+ this.usersByEmail.set(user.email, updated);
572
+ }
573
+ /**
574
+ * Update a user's password hash and salt.
575
+ *
576
+ * @param userId - The user whose password to update
577
+ * @param passwordHash - New hex-encoded PBKDF2 derived key
578
+ * @param salt - New hex-encoded salt
579
+ */
580
+ async updatePassword(userId, passwordHash, salt) {
581
+ const user = this.usersById.get(userId);
582
+ if (!user) return;
583
+ const updated = { ...user, passwordHash, salt };
584
+ this.usersById.set(userId, updated);
585
+ this.usersByEmail.set(user.email, updated);
586
+ }
587
+ /**
588
+ * List all users. For admin/development use.
589
+ */
590
+ async listAll() {
591
+ return [...this.usersById.values()];
592
+ }
593
+ /**
594
+ * Update a stored user record.
595
+ */
596
+ async update(user) {
597
+ const existing = this.usersById.get(user.id);
598
+ if (!existing) return;
599
+ if (existing.email !== user.email) {
600
+ this.usersByEmail.delete(existing.email);
601
+ this.usersByEmail.set(user.email, user);
602
+ } else {
603
+ this.usersByEmail.set(user.email, user);
604
+ }
605
+ this.usersById.set(user.id, user);
606
+ }
607
+ /**
608
+ * Delete a user and all associated devices.
609
+ */
610
+ async delete(userId) {
611
+ const user = this.usersById.get(userId);
612
+ if (!user) return;
613
+ this.usersById.delete(userId);
614
+ this.usersByEmail.delete(user.email);
615
+ const deviceIds = this.devicesByUserId.get(userId);
616
+ if (deviceIds) {
617
+ for (const deviceId of deviceIds) {
618
+ this.devicesById.delete(deviceId);
619
+ }
620
+ this.devicesByUserId.delete(userId);
621
+ }
622
+ }
461
623
  /**
462
624
  * Update the last-seen timestamp for a device.
463
625
  *
@@ -478,12 +640,73 @@ function toAuthUser(stored) {
478
640
  id: stored.id,
479
641
  email: stored.email,
480
642
  name: stored.name,
643
+ emailVerified: stored.emailVerified,
481
644
  createdAt: stored.createdAt
482
645
  };
483
646
  }
484
647
 
485
648
  // src/provider/built-in/auth-routes.ts
649
+ var InMemoryChallengeStore = class {
650
+ challenges = /* @__PURE__ */ new Map();
651
+ async store(challenge, deviceId, expiresAt) {
652
+ this.challenges.set(challenge, { deviceId, expiresAt });
653
+ }
654
+ async consume(challenge) {
655
+ const entry = this.challenges.get(challenge);
656
+ if (entry === void 0) {
657
+ return null;
658
+ }
659
+ this.challenges.delete(challenge);
660
+ if (Date.now() > entry.expiresAt) {
661
+ return null;
662
+ }
663
+ return { deviceId: entry.deviceId };
664
+ }
665
+ /**
666
+ * Remove expired challenges to prevent unbounded memory growth.
667
+ */
668
+ cleanup() {
669
+ const now = Date.now();
670
+ for (const [challenge, entry] of this.challenges) {
671
+ if (now > entry.expiresAt) {
672
+ this.challenges.delete(challenge);
673
+ }
674
+ }
675
+ }
676
+ };
677
+ var InMemoryRateLimiter = class {
678
+ attempts = /* @__PURE__ */ new Map();
679
+ maxAttempts;
680
+ windowMs;
681
+ /**
682
+ * @param maxAttempts - Maximum number of attempts within the time window (default: 10)
683
+ * @param windowMs - Time window in milliseconds (default: 60,000 = 1 minute)
684
+ */
685
+ constructor(maxAttempts = 10, windowMs = 6e4) {
686
+ this.maxAttempts = maxAttempts;
687
+ this.windowMs = windowMs;
688
+ }
689
+ async isAllowed(key) {
690
+ const now = Date.now();
691
+ const attempts = this.attempts.get(key) ?? [];
692
+ const recentAttempts = attempts.filter((t) => now - t < this.windowMs);
693
+ return recentAttempts.length < this.maxAttempts;
694
+ }
695
+ async record(key) {
696
+ const now = Date.now();
697
+ const attempts = this.attempts.get(key) ?? [];
698
+ const recentAttempts = attempts.filter((t) => now - t < this.windowMs);
699
+ recentAttempts.push(now);
700
+ this.attempts.set(key, recentAttempts);
701
+ }
702
+ async reset(key) {
703
+ this.attempts.delete(key);
704
+ }
705
+ };
486
706
  var MIN_PASSWORD_LENGTH = 8;
707
+ var MAX_PASSWORD_LENGTH = 128;
708
+ var MAX_NAME_LENGTH = 200;
709
+ var CHALLENGE_TTL_MS = 6e4;
487
710
  function isValidEmail(email) {
488
711
  if (email.length === 0 || email.length > 254) {
489
712
  return false;
@@ -504,12 +727,24 @@ function isValidEmail(email) {
504
727
  }
505
728
  return true;
506
729
  }
730
+ function sanitizeName(name) {
731
+ const cleaned = name.replace(/[\x00-\x1f\x7f]/g, "");
732
+ const trimmed = cleaned.trim();
733
+ if (trimmed.length > MAX_NAME_LENGTH) {
734
+ return trimmed.slice(0, MAX_NAME_LENGTH);
735
+ }
736
+ return trimmed;
737
+ }
507
738
  var BuiltInAuthRoutes = class {
508
739
  userStore;
509
740
  tokenManager;
741
+ challengeStore;
742
+ rateLimiter;
510
743
  constructor(config) {
511
744
  this.userStore = config.userStore;
512
745
  this.tokenManager = config.tokenManager;
746
+ this.challengeStore = config.challengeStore ?? new InMemoryChallengeStore();
747
+ this.rateLimiter = config.rateLimiter ?? new InMemoryRateLimiter();
513
748
  }
514
749
  /**
515
750
  * Handle user sign-up (POST /auth/signup).
@@ -519,13 +754,22 @@ var BuiltInAuthRoutes = class {
519
754
  *
520
755
  * @param body - Sign-up request body
521
756
  * @param body.email - The user's email address
522
- * @param body.password - The plaintext password (min 8 characters)
757
+ * @param body.password - The plaintext password (8-128 characters)
523
758
  * @param body.name - Optional display name (defaults to email local part)
524
759
  * @param body.deviceId - Optional device ID to register
525
760
  * @param body.devicePublicKey - Optional device public key (base64url)
761
+ * @param clientIp - Optional client IP for rate limiting
526
762
  * @returns Auth response with the created user and tokens, or an error
527
763
  */
528
- async handleSignUp(body) {
764
+ async handleSignUp(body, clientIp) {
765
+ const rateLimitKey = clientIp ?? "global";
766
+ if (!await this.rateLimiter.isAllowed(rateLimitKey)) {
767
+ return {
768
+ status: 429,
769
+ body: { error: "Too many requests. Please try again later." }
770
+ };
771
+ }
772
+ await this.rateLimiter.record(rateLimitKey);
529
773
  if (!isValidEmail(body.email)) {
530
774
  return {
531
775
  status: 400,
@@ -542,14 +786,24 @@ var BuiltInAuthRoutes = class {
542
786
  }
543
787
  };
544
788
  }
789
+ if (body.password.length > MAX_PASSWORD_LENGTH) {
790
+ return {
791
+ status: 400,
792
+ body: {
793
+ error: `Password must be at most ${MAX_PASSWORD_LENGTH} characters long.`
794
+ }
795
+ };
796
+ }
545
797
  const { hash, salt } = await hashPassword(body.password);
798
+ const rawName = body.name ?? body.email.split("@")[0] ?? body.email;
799
+ const name = sanitizeName(rawName);
546
800
  let user;
547
801
  try {
548
802
  user = await this.userStore.createUser({
549
803
  email: body.email,
550
804
  passwordHash: hash,
551
805
  salt,
552
- name: body.name ?? body.email.split("@")[0] ?? body.email
806
+ name
553
807
  });
554
808
  } catch (err) {
555
809
  if (err instanceof Error && err.name === "DuplicateEmailError") {
@@ -586,9 +840,18 @@ var BuiltInAuthRoutes = class {
586
840
  * @param body.password - The plaintext password
587
841
  * @param body.deviceId - Optional device ID to register
588
842
  * @param body.devicePublicKey - Optional device public key (base64url)
843
+ * @param clientIp - Optional client IP for rate limiting
589
844
  * @returns Auth response with the user and tokens, or an error
590
845
  */
591
- async handleSignIn(body) {
846
+ async handleSignIn(body, clientIp) {
847
+ const rateLimitKey = clientIp ? `signin:${body.email.toLowerCase()}:${clientIp}` : `signin:${body.email.toLowerCase()}`;
848
+ if (!await this.rateLimiter.isAllowed(rateLimitKey)) {
849
+ return {
850
+ status: 429,
851
+ body: { error: "Too many sign-in attempts. Please try again later." }
852
+ };
853
+ }
854
+ await this.rateLimiter.record(rateLimitKey);
592
855
  const storedUser = await this.userStore.findByEmail(body.email);
593
856
  if (storedUser === null) {
594
857
  return {
@@ -607,6 +870,7 @@ var BuiltInAuthRoutes = class {
607
870
  body: { error: "Invalid email or password." }
608
871
  };
609
872
  }
873
+ await this.rateLimiter.reset(rateLimitKey);
610
874
  const deviceId = body.deviceId ?? `device-${storedUser.id}`;
611
875
  if (body.deviceId !== void 0 && body.devicePublicKey !== void 0) {
612
876
  await this.userStore.registerDevice({
@@ -621,6 +885,7 @@ var BuiltInAuthRoutes = class {
621
885
  id: storedUser.id,
622
886
  email: storedUser.email,
623
887
  name: storedUser.name,
888
+ emailVerified: storedUser.emailVerified,
624
889
  createdAt: storedUser.createdAt
625
890
  };
626
891
  return {
@@ -632,15 +897,15 @@ var BuiltInAuthRoutes = class {
632
897
  * Handle token refresh (POST /auth/refresh).
633
898
  *
634
899
  * Validates the provided refresh token and issues a new token pair
635
- * (refresh token rotation). The old refresh token should be considered
636
- * consumed after this call.
900
+ * (refresh token rotation with reuse detection). The old refresh token
901
+ * is marked as consumed in the revocation store.
637
902
  *
638
903
  * @param body - Refresh request body
639
904
  * @param body.refreshToken - The current refresh token
640
905
  * @returns Auth response with new tokens, or an error
641
906
  */
642
907
  async handleRefresh(body) {
643
- const result = this.tokenManager.refreshAccessToken(body.refreshToken);
908
+ const result = await this.tokenManager.refreshAccessToken(body.refreshToken);
644
909
  if (result === null) {
645
910
  return {
646
911
  status: 401,
@@ -652,6 +917,38 @@ var BuiltInAuthRoutes = class {
652
917
  body: { data: result }
653
918
  };
654
919
  }
920
+ /**
921
+ * Handle sign-out (POST /auth/signout).
922
+ *
923
+ * Validates the access token and revokes the current refresh token
924
+ * (if a revocation store is configured). This ensures that stolen
925
+ * refresh tokens cannot be used after the user signs out.
926
+ *
927
+ * @param accessToken - The JWT access token (without "Bearer " prefix)
928
+ * @param body - Sign-out request body
929
+ * @param body.refreshToken - The current refresh token to revoke
930
+ * @returns Auth response with success flag, or an error
931
+ */
932
+ async handleSignOut(accessToken, body) {
933
+ const payload = this.tokenManager.validateToken(accessToken);
934
+ if (payload === null || payload.type !== "access") {
935
+ return {
936
+ status: 401,
937
+ body: { error: "Invalid or expired access token." }
938
+ };
939
+ }
940
+ await this.tokenManager.revokeToken(payload.jti, payload.exp);
941
+ if (body.refreshToken) {
942
+ const refreshPayload = this.tokenManager.validateToken(body.refreshToken);
943
+ if (refreshPayload !== null && refreshPayload.type === "refresh") {
944
+ await this.tokenManager.revokeToken(refreshPayload.jti, refreshPayload.exp);
945
+ }
946
+ }
947
+ return {
948
+ status: 200,
949
+ body: { data: { success: true } }
950
+ };
951
+ }
655
952
  /**
656
953
  * Handle get-current-user (GET /auth/me).
657
954
  *
@@ -679,6 +976,7 @@ var BuiltInAuthRoutes = class {
679
976
  id: storedUser.id,
680
977
  email: storedUser.email,
681
978
  name: storedUser.name,
979
+ emailVerified: storedUser.emailVerified,
682
980
  createdAt: storedUser.createdAt
683
981
  };
684
982
  return {
@@ -711,8 +1009,8 @@ var BuiltInAuthRoutes = class {
711
1009
  /**
712
1010
  * Handle device revocation (DELETE /auth/device/:id).
713
1011
  *
714
- * Validates the access token and revokes the specified device.
715
- * Only the device's owner can revoke it.
1012
+ * Validates the access token, revokes the specified device, and invalidates
1013
+ * all tokens issued to that device. Only the device's owner can revoke it.
716
1014
  *
717
1015
  * @param accessToken - The JWT access token (without "Bearer " prefix)
718
1016
  * @param deviceId - The ID of the device to revoke
@@ -740,11 +1038,207 @@ var BuiltInAuthRoutes = class {
740
1038
  };
741
1039
  }
742
1040
  await this.userStore.revokeDevice(deviceId);
1041
+ await this.tokenManager.revokeDeviceTokens(deviceId);
743
1042
  return {
744
1043
  status: 200,
745
1044
  body: { data: { success: true } }
746
1045
  };
747
1046
  }
1047
+ /**
1048
+ * Handle device registration (POST /auth/device/register).
1049
+ *
1050
+ * Requires a valid access token. Registers a new device for the authenticated
1051
+ * user and issues a device credential token bound to the device's public key.
1052
+ *
1053
+ * @param accessToken - The JWT access token (without "Bearer " prefix)
1054
+ * @param body - Device registration request body
1055
+ * @param body.deviceId - Unique identifier for the device
1056
+ * @param body.publicKey - The device's public key as a JWK JSON string
1057
+ * @param body.name - Human-readable device name (e.g., "Chrome on MacBook")
1058
+ * @returns Auth response with the registered device and device credential, or an error
1059
+ */
1060
+ async handleDeviceRegister(accessToken, body) {
1061
+ const payload = this.tokenManager.validateToken(accessToken);
1062
+ if (payload === null || payload.type !== "access") {
1063
+ return {
1064
+ status: 401,
1065
+ body: { error: "Invalid or expired access token." }
1066
+ };
1067
+ }
1068
+ const deviceName = sanitizeName(body.name);
1069
+ if (deviceName.length === 0) {
1070
+ return {
1071
+ status: 400,
1072
+ body: { error: "Device name must not be empty." }
1073
+ };
1074
+ }
1075
+ let publicKeyJwk;
1076
+ try {
1077
+ publicKeyJwk = JSON.parse(body.publicKey);
1078
+ } catch {
1079
+ return {
1080
+ status: 400,
1081
+ body: { error: "Invalid public key format. Expected a JSON-encoded JWK string." }
1082
+ };
1083
+ }
1084
+ let thumbprint;
1085
+ try {
1086
+ thumbprint = await computePublicKeyThumbprint(publicKeyJwk);
1087
+ } catch {
1088
+ return {
1089
+ status: 400,
1090
+ body: { error: "Failed to compute public key thumbprint. Ensure the key is a valid EC P-256 JWK." }
1091
+ };
1092
+ }
1093
+ const device = await this.userStore.registerDevice({
1094
+ id: body.deviceId,
1095
+ userId: payload.sub,
1096
+ publicKey: body.publicKey,
1097
+ name: deviceName
1098
+ });
1099
+ const deviceCredential = this.tokenManager.issueDeviceCredential(
1100
+ payload.sub,
1101
+ body.deviceId,
1102
+ thumbprint
1103
+ );
1104
+ return {
1105
+ status: 201,
1106
+ body: { data: { device, deviceCredential } }
1107
+ };
1108
+ }
1109
+ /**
1110
+ * Generate a challenge for device proof-of-possession verification.
1111
+ *
1112
+ * Creates a cryptographically random challenge, stores it server-side with
1113
+ * a 60-second TTL and the target device ID, and returns the challenge string.
1114
+ * The client signs this challenge with its private key and submits it via
1115
+ * {@link handleDeviceVerify}.
1116
+ *
1117
+ * @param accessToken - The JWT access token (without "Bearer " prefix)
1118
+ * @param deviceId - The device this challenge is intended for
1119
+ * @returns Auth response with the challenge string, or an error
1120
+ */
1121
+ async handleDeviceChallenge(accessToken, deviceId) {
1122
+ const payload = this.tokenManager.validateToken(accessToken);
1123
+ if (payload === null || payload.type !== "access") {
1124
+ return {
1125
+ status: 401,
1126
+ body: { error: "Invalid or expired access token." }
1127
+ };
1128
+ }
1129
+ const device = await this.userStore.findDevice(deviceId);
1130
+ if (device === null || device.userId !== payload.sub) {
1131
+ return {
1132
+ status: 404,
1133
+ body: { error: "Device not found." }
1134
+ };
1135
+ }
1136
+ if (device.revoked) {
1137
+ return {
1138
+ status: 403,
1139
+ body: { error: "Device has been revoked." }
1140
+ };
1141
+ }
1142
+ const challenge = randomBytes2(32).toString("hex");
1143
+ const expiresAt = Date.now() + CHALLENGE_TTL_MS;
1144
+ await this.challengeStore.store(challenge, deviceId, expiresAt);
1145
+ return {
1146
+ status: 200,
1147
+ body: { data: { challenge } }
1148
+ };
1149
+ }
1150
+ /**
1151
+ * Handle device proof-of-possession verification (POST /auth/device/verify).
1152
+ *
1153
+ * Verifies that the device holds the private key corresponding to its registered
1154
+ * public key by checking a signed challenge. The challenge must have been previously
1155
+ * issued via {@link handleDeviceChallenge} and is single-use.
1156
+ *
1157
+ * On success, issues fresh tokens for the device.
1158
+ *
1159
+ * @param body - Device verification request body
1160
+ * @param body.deviceId - The ID of the device to verify
1161
+ * @param body.challenge - The challenge string (from handleDeviceChallenge)
1162
+ * @param body.signature - The base64url-encoded ECDSA signature of the challenge
1163
+ * @returns Auth response with fresh tokens on success, or an error
1164
+ */
1165
+ async handleDeviceVerify(body) {
1166
+ const challengeEntry = await this.challengeStore.consume(body.challenge);
1167
+ if (challengeEntry === null) {
1168
+ return {
1169
+ status: 401,
1170
+ body: { error: "Invalid or expired challenge. Request a new challenge and try again." }
1171
+ };
1172
+ }
1173
+ if (challengeEntry.deviceId !== body.deviceId) {
1174
+ return {
1175
+ status: 401,
1176
+ body: { error: "Challenge was not issued for this device." }
1177
+ };
1178
+ }
1179
+ const device = await this.userStore.findDevice(body.deviceId);
1180
+ if (device === null) {
1181
+ return {
1182
+ status: 404,
1183
+ body: { error: "Device not found." }
1184
+ };
1185
+ }
1186
+ if (device.revoked) {
1187
+ return {
1188
+ status: 403,
1189
+ body: { error: "Device has been revoked and cannot authenticate." }
1190
+ };
1191
+ }
1192
+ let publicKeyJwk;
1193
+ try {
1194
+ publicKeyJwk = JSON.parse(device.publicKey);
1195
+ } catch {
1196
+ return {
1197
+ status: 500,
1198
+ body: { error: "Device has an invalid stored public key." }
1199
+ };
1200
+ }
1201
+ let isValid;
1202
+ try {
1203
+ isValid = await verifyChallenge(publicKeyJwk, body.challenge, body.signature);
1204
+ } catch {
1205
+ return {
1206
+ status: 400,
1207
+ body: { error: "Signature verification failed. The signature or public key format may be invalid." }
1208
+ };
1209
+ }
1210
+ if (!isValid) {
1211
+ return {
1212
+ status: 401,
1213
+ body: { error: "Invalid signature. Proof-of-possession verification failed." }
1214
+ };
1215
+ }
1216
+ let thumbprint;
1217
+ try {
1218
+ thumbprint = await computePublicKeyThumbprint(publicKeyJwk);
1219
+ } catch {
1220
+ return {
1221
+ status: 500,
1222
+ body: { error: "Failed to compute public key thumbprint." }
1223
+ };
1224
+ }
1225
+ const tokens = this.tokenManager.issueTokens(device.userId, device.id, thumbprint);
1226
+ return {
1227
+ status: 200,
1228
+ body: { data: { tokens } }
1229
+ };
1230
+ }
1231
+ /**
1232
+ * Generates a random challenge string for proof-of-possession verification.
1233
+ *
1234
+ * **Deprecated:** Use {@link handleDeviceChallenge} instead, which stores
1235
+ * the challenge server-side with expiry and single-use semantics.
1236
+ *
1237
+ * @returns A 64-character hex string (32 random bytes)
1238
+ */
1239
+ static generateChallenge() {
1240
+ return randomBytes2(32).toString("hex");
1241
+ }
748
1242
  /**
749
1243
  * Creates a sync server auth provider compatible with `@korajs/server`.
750
1244
  *
@@ -753,6 +1247,9 @@ var BuiltInAuthRoutes = class {
753
1247
  * context containing the user ID and device metadata. This bridges
754
1248
  * the built-in auth system with the sync server's authentication layer.
755
1249
  *
1250
+ * Also checks device revocation status during authentication, ensuring
1251
+ * that revoked devices are rejected even if their tokens haven't expired.
1252
+ *
756
1253
  * @returns An object with an `authenticate` method suitable for KoraSyncServer's `auth` config
757
1254
  *
758
1255
  * @example
@@ -777,6 +1274,10 @@ var BuiltInAuthRoutes = class {
777
1274
  if (user === null) {
778
1275
  return null;
779
1276
  }
1277
+ const device = await userStore.findDevice(payload.dev);
1278
+ if (device !== null && device.revoked) {
1279
+ return null;
1280
+ }
780
1281
  await userStore.touchDevice(payload.dev);
781
1282
  return {
782
1283
  userId: payload.sub,
@@ -791,6 +1292,722 @@ var BuiltInAuthRoutes = class {
791
1292
  }
792
1293
  };
793
1294
 
1295
+ // src/provider/built-in/password-reset.ts
1296
+ import { KoraError as KoraError3 } from "@korajs/core";
1297
+ var PasswordResetError = class extends KoraError3 {
1298
+ constructor(message, code, context) {
1299
+ super(message, code, context);
1300
+ this.name = "PasswordResetError";
1301
+ }
1302
+ };
1303
+ var ResetTokenExpiredError = class extends PasswordResetError {
1304
+ constructor() {
1305
+ super("Password reset token has expired.", "RESET_TOKEN_EXPIRED");
1306
+ }
1307
+ };
1308
+ var ResetTokenNotFoundError = class extends PasswordResetError {
1309
+ constructor() {
1310
+ super("Password reset token not found or already used.", "RESET_TOKEN_NOT_FOUND");
1311
+ }
1312
+ };
1313
+ var ResetRateLimitedError = class extends PasswordResetError {
1314
+ constructor() {
1315
+ super("Too many password reset requests. Please try again later.", "RESET_RATE_LIMITED");
1316
+ }
1317
+ };
1318
+ var InMemoryPasswordResetStore = class {
1319
+ tokens = /* @__PURE__ */ new Map();
1320
+ async store(token) {
1321
+ this.tokens.set(token.token, token);
1322
+ }
1323
+ async get(token) {
1324
+ return this.tokens.get(token) ?? null;
1325
+ }
1326
+ async consume(token) {
1327
+ const entry = this.tokens.get(token);
1328
+ if (entry) {
1329
+ this.tokens.set(token, { ...entry, consumed: true });
1330
+ }
1331
+ }
1332
+ async countActiveForEmail(email) {
1333
+ const now = Date.now();
1334
+ let count = 0;
1335
+ for (const token of this.tokens.values()) {
1336
+ if (token.email === email && !token.consumed && now < token.expiresAt) {
1337
+ count++;
1338
+ }
1339
+ }
1340
+ return count;
1341
+ }
1342
+ async cleanExpired() {
1343
+ const now = Date.now();
1344
+ let count = 0;
1345
+ for (const [key, token] of this.tokens) {
1346
+ if (now > token.expiresAt) {
1347
+ this.tokens.delete(key);
1348
+ count++;
1349
+ }
1350
+ }
1351
+ return count;
1352
+ }
1353
+ };
1354
+ var DEFAULT_TOKEN_TTL_MS = 60 * 60 * 1e3;
1355
+ var DEFAULT_MAX_REQUESTS = 3;
1356
+ var MIN_PASSWORD_LENGTH2 = 8;
1357
+ var MAX_PASSWORD_LENGTH2 = 128;
1358
+ var PasswordResetManager = class {
1359
+ userStore;
1360
+ resetStore;
1361
+ tokenTtlMs;
1362
+ maxRequestsPerEmail;
1363
+ onResetRequested;
1364
+ constructor(config) {
1365
+ this.userStore = config.userStore;
1366
+ this.resetStore = config.resetStore ?? new InMemoryPasswordResetStore();
1367
+ this.tokenTtlMs = config.tokenTtlMs ?? DEFAULT_TOKEN_TTL_MS;
1368
+ this.maxRequestsPerEmail = config.maxRequestsPerEmail ?? DEFAULT_MAX_REQUESTS;
1369
+ this.onResetRequested = config.onResetRequested;
1370
+ }
1371
+ /**
1372
+ * Request a password reset for an email.
1373
+ * Always returns success to prevent email enumeration.
1374
+ *
1375
+ * If a callback is configured, invokes it with the token.
1376
+ * In development mode (no callback), returns the token in the response.
1377
+ */
1378
+ async requestReset(email) {
1379
+ const normalizedEmail = email.toLowerCase().trim();
1380
+ const successResponse = {
1381
+ status: 200,
1382
+ body: { data: { message: "If an account with that email exists, a password reset link has been sent." } }
1383
+ };
1384
+ const user = await this.userStore.findByEmail(normalizedEmail);
1385
+ if (!user) {
1386
+ return successResponse;
1387
+ }
1388
+ const activeCount = await this.resetStore.countActiveForEmail(normalizedEmail);
1389
+ if (activeCount >= this.maxRequestsPerEmail) {
1390
+ return successResponse;
1391
+ }
1392
+ const token = generateSecureToken();
1393
+ const now = Date.now();
1394
+ const resetToken = {
1395
+ token,
1396
+ userId: user.id,
1397
+ email: normalizedEmail,
1398
+ createdAt: now,
1399
+ expiresAt: now + this.tokenTtlMs,
1400
+ consumed: false
1401
+ };
1402
+ await this.resetStore.store(resetToken);
1403
+ if (this.onResetRequested) {
1404
+ try {
1405
+ await this.onResetRequested(normalizedEmail, token, resetToken.expiresAt);
1406
+ } catch {
1407
+ }
1408
+ }
1409
+ if (!this.onResetRequested) {
1410
+ successResponse.body = { data: { message: "Password reset token generated.", token } };
1411
+ }
1412
+ return successResponse;
1413
+ }
1414
+ /**
1415
+ * Consume a reset token and set a new password.
1416
+ */
1417
+ async resetPassword(token, newPassword) {
1418
+ if (typeof newPassword !== "string" || newPassword.length < MIN_PASSWORD_LENGTH2) {
1419
+ return {
1420
+ status: 400,
1421
+ body: { error: `Password must be at least ${MIN_PASSWORD_LENGTH2} characters.` }
1422
+ };
1423
+ }
1424
+ if (newPassword.length > MAX_PASSWORD_LENGTH2) {
1425
+ return {
1426
+ status: 400,
1427
+ body: { error: `Password must be at most ${MAX_PASSWORD_LENGTH2} characters.` }
1428
+ };
1429
+ }
1430
+ const resetToken = await this.resetStore.get(token);
1431
+ if (!resetToken || resetToken.consumed) {
1432
+ return { status: 404, body: { error: "Password reset token not found or already used." } };
1433
+ }
1434
+ if (Date.now() > resetToken.expiresAt) {
1435
+ await this.resetStore.consume(token);
1436
+ return { status: 410, body: { error: "Password reset token has expired." } };
1437
+ }
1438
+ await this.resetStore.consume(token);
1439
+ const hashed = await hashPassword(newPassword);
1440
+ await this.userStore.updatePassword(resetToken.userId, hashed.hash, hashed.salt);
1441
+ return { status: 200, body: { data: { message: "Password has been reset successfully." } } };
1442
+ }
1443
+ /**
1444
+ * Change password for an authenticated user (requires current password verification).
1445
+ */
1446
+ async changePassword(userId, currentPassword, newPassword) {
1447
+ if (typeof newPassword !== "string" || newPassword.length < MIN_PASSWORD_LENGTH2) {
1448
+ return {
1449
+ status: 400,
1450
+ body: { error: `New password must be at least ${MIN_PASSWORD_LENGTH2} characters.` }
1451
+ };
1452
+ }
1453
+ if (newPassword.length > MAX_PASSWORD_LENGTH2) {
1454
+ return {
1455
+ status: 400,
1456
+ body: { error: `New password must be at most ${MAX_PASSWORD_LENGTH2} characters.` }
1457
+ };
1458
+ }
1459
+ const user = await this.userStore.findById(userId);
1460
+ if (!user) {
1461
+ return { status: 404, body: { error: "User not found." } };
1462
+ }
1463
+ const { verifyPassword: verifyPassword2 } = await import("./password-hash-HDH6VQCQ.js");
1464
+ const isValid = await verifyPassword2(currentPassword, user.passwordHash, user.salt);
1465
+ if (!isValid) {
1466
+ return { status: 401, body: { error: "Current password is incorrect." } };
1467
+ }
1468
+ const hashed = await hashPassword(newPassword);
1469
+ await this.userStore.updatePassword(userId, hashed.hash, hashed.salt);
1470
+ return { status: 200, body: { data: { message: "Password changed successfully." } } };
1471
+ }
1472
+ };
1473
+ function generateSecureToken() {
1474
+ const bytes = new Uint8Array(32);
1475
+ globalThis.crypto.getRandomValues(bytes);
1476
+ let binary = "";
1477
+ for (let i = 0; i < bytes.length; i++) {
1478
+ binary += String.fromCharCode(bytes[i]);
1479
+ }
1480
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1481
+ }
1482
+
1483
+ // src/provider/built-in/email-verification.ts
1484
+ import { KoraError as KoraError4 } from "@korajs/core";
1485
+ var EmailVerificationError = class extends KoraError4 {
1486
+ constructor(message, code, context) {
1487
+ super(message, code, context);
1488
+ this.name = "EmailVerificationError";
1489
+ }
1490
+ };
1491
+ var VerificationTokenExpiredError = class extends EmailVerificationError {
1492
+ constructor() {
1493
+ super("Email verification token has expired.", "VERIFICATION_TOKEN_EXPIRED");
1494
+ }
1495
+ };
1496
+ var VerificationTokenNotFoundError = class extends EmailVerificationError {
1497
+ constructor() {
1498
+ super("Email verification token not found or already used.", "VERIFICATION_TOKEN_NOT_FOUND");
1499
+ }
1500
+ };
1501
+ var InMemoryEmailVerificationStore = class {
1502
+ tokens = /* @__PURE__ */ new Map();
1503
+ async store(token) {
1504
+ this.tokens.set(token.token, token);
1505
+ }
1506
+ async get(token) {
1507
+ return this.tokens.get(token) ?? null;
1508
+ }
1509
+ async consume(token) {
1510
+ const entry = this.tokens.get(token);
1511
+ if (entry) {
1512
+ this.tokens.set(token, { ...entry, consumed: true });
1513
+ }
1514
+ }
1515
+ async countActiveForUser(userId) {
1516
+ const now = Date.now();
1517
+ let count = 0;
1518
+ for (const token of this.tokens.values()) {
1519
+ if (token.userId === userId && !token.consumed && now < token.expiresAt) {
1520
+ count++;
1521
+ }
1522
+ }
1523
+ return count;
1524
+ }
1525
+ async cleanExpired() {
1526
+ const now = Date.now();
1527
+ let count = 0;
1528
+ for (const [key, token] of this.tokens) {
1529
+ if (now > token.expiresAt) {
1530
+ this.tokens.delete(key);
1531
+ count++;
1532
+ }
1533
+ }
1534
+ return count;
1535
+ }
1536
+ };
1537
+ var DEFAULT_TOKEN_TTL_MS2 = 24 * 60 * 60 * 1e3;
1538
+ var DEFAULT_MAX_REQUESTS2 = 3;
1539
+ var EmailVerificationManager = class {
1540
+ userStore;
1541
+ verificationStore;
1542
+ tokenTtlMs;
1543
+ maxRequestsPerUser;
1544
+ onVerificationRequired;
1545
+ constructor(config) {
1546
+ this.userStore = config.userStore;
1547
+ this.verificationStore = config.verificationStore ?? new InMemoryEmailVerificationStore();
1548
+ this.tokenTtlMs = config.tokenTtlMs ?? DEFAULT_TOKEN_TTL_MS2;
1549
+ this.maxRequestsPerUser = config.maxRequestsPerUser ?? DEFAULT_MAX_REQUESTS2;
1550
+ this.onVerificationRequired = config.onVerificationRequired;
1551
+ }
1552
+ /**
1553
+ * Send a verification email for a user.
1554
+ * Rate-limited to prevent abuse.
1555
+ */
1556
+ async sendVerification(userId, email) {
1557
+ const normalizedEmail = email.toLowerCase().trim();
1558
+ const activeCount = await this.verificationStore.countActiveForUser(userId);
1559
+ if (activeCount >= this.maxRequestsPerUser) {
1560
+ return { status: 429, body: { error: "Too many verification requests. Please try again later." } };
1561
+ }
1562
+ const token = generateSecureToken2();
1563
+ const now = Date.now();
1564
+ const verificationToken = {
1565
+ token,
1566
+ userId,
1567
+ email: normalizedEmail,
1568
+ createdAt: now,
1569
+ expiresAt: now + this.tokenTtlMs,
1570
+ consumed: false
1571
+ };
1572
+ await this.verificationStore.store(verificationToken);
1573
+ if (this.onVerificationRequired) {
1574
+ try {
1575
+ await this.onVerificationRequired(normalizedEmail, token, verificationToken.expiresAt);
1576
+ } catch {
1577
+ }
1578
+ }
1579
+ const responseData = {
1580
+ message: "Verification email sent."
1581
+ };
1582
+ if (!this.onVerificationRequired) {
1583
+ responseData.token = token;
1584
+ }
1585
+ return { status: 200, body: { data: responseData } };
1586
+ }
1587
+ /**
1588
+ * Verify an email using a verification token.
1589
+ */
1590
+ async verifyEmail(token) {
1591
+ const verificationToken = await this.verificationStore.get(token);
1592
+ if (!verificationToken || verificationToken.consumed) {
1593
+ return { status: 404, body: { error: "Verification token not found or already used." } };
1594
+ }
1595
+ if (Date.now() > verificationToken.expiresAt) {
1596
+ await this.verificationStore.consume(token);
1597
+ return { status: 410, body: { error: "Verification token has expired." } };
1598
+ }
1599
+ await this.verificationStore.consume(token);
1600
+ await this.userStore.setEmailVerified(verificationToken.userId, true);
1601
+ return {
1602
+ status: 200,
1603
+ body: {
1604
+ data: {
1605
+ message: "Email verified successfully.",
1606
+ userId: verificationToken.userId,
1607
+ email: verificationToken.email
1608
+ }
1609
+ }
1610
+ };
1611
+ }
1612
+ /**
1613
+ * Resend verification email for a user.
1614
+ * Delegates to sendVerification with rate limiting.
1615
+ */
1616
+ async resendVerification(userId) {
1617
+ const user = await this.userStore.findById(userId);
1618
+ if (!user) {
1619
+ return { status: 404, body: { error: "User not found." } };
1620
+ }
1621
+ return this.sendVerification(userId, user.email);
1622
+ }
1623
+ };
1624
+ function generateSecureToken2() {
1625
+ const bytes = new Uint8Array(32);
1626
+ globalThis.crypto.getRandomValues(bytes);
1627
+ let binary = "";
1628
+ for (let i = 0; i < bytes.length; i++) {
1629
+ binary += String.fromCharCode(bytes[i]);
1630
+ }
1631
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1632
+ }
1633
+
1634
+ // src/provider/built-in/sqlite-user-store.ts
1635
+ import { randomUUID as randomUUID3 } from "crypto";
1636
+ var SqliteUserStore = class {
1637
+ db;
1638
+ constructor(db) {
1639
+ this.db = db;
1640
+ this.db.pragma("journal_mode = WAL");
1641
+ this.ensureTables();
1642
+ }
1643
+ ensureTables() {
1644
+ this.db.exec(`
1645
+ CREATE TABLE IF NOT EXISTS auth_users (
1646
+ id TEXT PRIMARY KEY,
1647
+ email TEXT NOT NULL UNIQUE COLLATE NOCASE,
1648
+ name TEXT NOT NULL,
1649
+ email_verified INTEGER NOT NULL DEFAULT 0,
1650
+ created_at INTEGER NOT NULL,
1651
+ password_hash TEXT NOT NULL,
1652
+ salt TEXT NOT NULL
1653
+ );
1654
+
1655
+ CREATE TABLE IF NOT EXISTS auth_devices (
1656
+ id TEXT PRIMARY KEY,
1657
+ user_id TEXT NOT NULL,
1658
+ public_key TEXT NOT NULL,
1659
+ name TEXT NOT NULL,
1660
+ revoked INTEGER NOT NULL DEFAULT 0,
1661
+ created_at INTEGER NOT NULL,
1662
+ last_seen_at INTEGER NOT NULL,
1663
+ FOREIGN KEY (user_id) REFERENCES auth_users(id) ON DELETE CASCADE
1664
+ );
1665
+
1666
+ CREATE INDEX IF NOT EXISTS idx_auth_devices_user_id ON auth_devices(user_id);
1667
+ `);
1668
+ }
1669
+ async createUser(params) {
1670
+ const normalizedEmail = params.email.toLowerCase();
1671
+ const now = Date.now();
1672
+ const id = randomUUID3();
1673
+ try {
1674
+ this.db.prepare(`
1675
+ INSERT INTO auth_users (id, email, name, email_verified, created_at, password_hash, salt)
1676
+ VALUES (?, ?, ?, 0, ?, ?, ?)
1677
+ `).run(id, normalizedEmail, params.name, now, params.passwordHash, params.salt);
1678
+ } catch (err) {
1679
+ if (err instanceof Error && err.message.includes("UNIQUE constraint failed")) {
1680
+ throw new DuplicateEmailError();
1681
+ }
1682
+ throw err;
1683
+ }
1684
+ return { id, email: normalizedEmail, name: params.name, emailVerified: false, createdAt: now };
1685
+ }
1686
+ async findByEmail(email) {
1687
+ const row = this.db.prepare(
1688
+ "SELECT * FROM auth_users WHERE email = ?"
1689
+ ).get(email.toLowerCase());
1690
+ return row ? rowToStoredUser(row) : null;
1691
+ }
1692
+ async findById(id) {
1693
+ const row = this.db.prepare(
1694
+ "SELECT * FROM auth_users WHERE id = ?"
1695
+ ).get(id);
1696
+ return row ? rowToStoredUser(row) : null;
1697
+ }
1698
+ async registerDevice(params) {
1699
+ const existing = this.db.prepare(
1700
+ "SELECT * FROM auth_devices WHERE id = ?"
1701
+ ).get(params.id);
1702
+ if (existing && !existing.revoked) {
1703
+ return rowToDevice(existing);
1704
+ }
1705
+ const now = Date.now();
1706
+ if (existing) {
1707
+ this.db.prepare(`
1708
+ UPDATE auth_devices SET revoked = 0, public_key = ?, name = ?, last_seen_at = ?
1709
+ WHERE id = ?
1710
+ `).run(params.publicKey, params.name, now, params.id);
1711
+ } else {
1712
+ this.db.prepare(`
1713
+ INSERT INTO auth_devices (id, user_id, public_key, name, revoked, created_at, last_seen_at)
1714
+ VALUES (?, ?, ?, ?, 0, ?, ?)
1715
+ `).run(params.id, params.userId, params.publicKey, params.name, now, now);
1716
+ }
1717
+ return {
1718
+ id: params.id,
1719
+ userId: params.userId,
1720
+ publicKey: params.publicKey,
1721
+ name: params.name,
1722
+ revoked: false,
1723
+ createdAt: existing ? existing.created_at : now,
1724
+ lastSeenAt: now
1725
+ };
1726
+ }
1727
+ async findDevice(deviceId) {
1728
+ const row = this.db.prepare(
1729
+ "SELECT * FROM auth_devices WHERE id = ?"
1730
+ ).get(deviceId);
1731
+ return row ? rowToDevice(row) : null;
1732
+ }
1733
+ async listDevices(userId) {
1734
+ const rows = this.db.prepare(
1735
+ "SELECT * FROM auth_devices WHERE user_id = ?"
1736
+ ).all(userId);
1737
+ return rows.map(rowToDevice);
1738
+ }
1739
+ async revokeDevice(deviceId) {
1740
+ this.db.prepare("UPDATE auth_devices SET revoked = 1 WHERE id = ?").run(deviceId);
1741
+ }
1742
+ async setEmailVerified(userId, verified) {
1743
+ this.db.prepare(
1744
+ "UPDATE auth_users SET email_verified = ? WHERE id = ?"
1745
+ ).run(verified ? 1 : 0, userId);
1746
+ }
1747
+ async updatePassword(userId, passwordHash, salt) {
1748
+ this.db.prepare(
1749
+ "UPDATE auth_users SET password_hash = ?, salt = ? WHERE id = ?"
1750
+ ).run(passwordHash, salt, userId);
1751
+ }
1752
+ async listAll() {
1753
+ const rows = this.db.prepare("SELECT * FROM auth_users").all();
1754
+ return rows.map(rowToStoredUser);
1755
+ }
1756
+ async update(user) {
1757
+ this.db.prepare(`
1758
+ UPDATE auth_users
1759
+ SET email = ?, name = ?, email_verified = ?, password_hash = ?, salt = ?
1760
+ WHERE id = ?
1761
+ `).run(user.email, user.name, user.emailVerified ? 1 : 0, user.passwordHash, user.salt, user.id);
1762
+ }
1763
+ async delete(userId) {
1764
+ const deleteInTransaction = this.db.transaction(() => {
1765
+ this.db.prepare("DELETE FROM auth_devices WHERE user_id = ?").run(userId);
1766
+ this.db.prepare("DELETE FROM auth_users WHERE id = ?").run(userId);
1767
+ });
1768
+ deleteInTransaction();
1769
+ }
1770
+ async touchDevice(deviceId) {
1771
+ this.db.prepare(
1772
+ "UPDATE auth_devices SET last_seen_at = ? WHERE id = ?"
1773
+ ).run(Date.now(), deviceId);
1774
+ }
1775
+ };
1776
+ async function createSqliteUserStore(options) {
1777
+ const Database = await loadBetterSqlite3();
1778
+ const db = new Database(options.filename);
1779
+ return new SqliteUserStore(db);
1780
+ }
1781
+ async function loadBetterSqlite3() {
1782
+ try {
1783
+ const { createRequire } = await import("module");
1784
+ const require2 = createRequire(import.meta.url);
1785
+ return require2("better-sqlite3");
1786
+ } catch {
1787
+ throw new Error(
1788
+ 'SQLite backend requires the "better-sqlite3" package. Install it in your project dependencies.'
1789
+ );
1790
+ }
1791
+ }
1792
+ function rowToStoredUser(row) {
1793
+ return {
1794
+ id: row.id,
1795
+ email: row.email,
1796
+ name: row.name,
1797
+ emailVerified: Boolean(row.email_verified),
1798
+ createdAt: row.created_at,
1799
+ passwordHash: row.password_hash,
1800
+ salt: row.salt
1801
+ };
1802
+ }
1803
+ function rowToDevice(row) {
1804
+ return {
1805
+ id: row.id,
1806
+ userId: row.user_id,
1807
+ publicKey: row.public_key,
1808
+ name: row.name,
1809
+ revoked: Boolean(row.revoked),
1810
+ createdAt: row.created_at,
1811
+ lastSeenAt: row.last_seen_at
1812
+ };
1813
+ }
1814
+
1815
+ // src/provider/built-in/postgres-user-store.ts
1816
+ import { randomUUID as randomUUID4 } from "crypto";
1817
+ var PostgresUserStore = class {
1818
+ sql;
1819
+ ready;
1820
+ constructor(sql) {
1821
+ this.sql = sql;
1822
+ this.ready = this.ensureTables();
1823
+ }
1824
+ async ensureTables() {
1825
+ await this.sql`
1826
+ CREATE TABLE IF NOT EXISTS auth_users (
1827
+ id TEXT PRIMARY KEY,
1828
+ email TEXT NOT NULL UNIQUE,
1829
+ name TEXT NOT NULL,
1830
+ email_verified BOOLEAN NOT NULL DEFAULT FALSE,
1831
+ created_at BIGINT NOT NULL,
1832
+ password_hash TEXT NOT NULL,
1833
+ salt TEXT NOT NULL
1834
+ )
1835
+ `;
1836
+ await this.sql`
1837
+ CREATE TABLE IF NOT EXISTS auth_devices (
1838
+ id TEXT PRIMARY KEY,
1839
+ user_id TEXT NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE,
1840
+ public_key TEXT NOT NULL,
1841
+ name TEXT NOT NULL,
1842
+ revoked BOOLEAN NOT NULL DEFAULT FALSE,
1843
+ created_at BIGINT NOT NULL,
1844
+ last_seen_at BIGINT NOT NULL
1845
+ )
1846
+ `;
1847
+ await this.sql`
1848
+ CREATE INDEX IF NOT EXISTS idx_auth_devices_user_id ON auth_devices(user_id)
1849
+ `;
1850
+ await this.sql`
1851
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_users_email_lower ON auth_users(LOWER(email))
1852
+ `;
1853
+ }
1854
+ async createUser(params) {
1855
+ await this.ready;
1856
+ const normalizedEmail = params.email.toLowerCase();
1857
+ const now = Date.now();
1858
+ const id = randomUUID4();
1859
+ try {
1860
+ await this.sql`
1861
+ INSERT INTO auth_users (id, email, name, email_verified, created_at, password_hash, salt)
1862
+ VALUES (${id}, ${normalizedEmail}, ${params.name}, FALSE, ${now}, ${params.passwordHash}, ${params.salt})
1863
+ `;
1864
+ } catch (err) {
1865
+ if (err instanceof Error && (err.message.includes("unique constraint") || err.message.includes("duplicate key"))) {
1866
+ throw new DuplicateEmailError();
1867
+ }
1868
+ throw err;
1869
+ }
1870
+ return { id, email: normalizedEmail, name: params.name, emailVerified: false, createdAt: now };
1871
+ }
1872
+ async findByEmail(email) {
1873
+ await this.ready;
1874
+ const rows = await this.sql`
1875
+ SELECT * FROM auth_users WHERE LOWER(email) = ${email.toLowerCase()}
1876
+ `;
1877
+ return rows.length > 0 ? rowToStoredUser2(rows[0]) : null;
1878
+ }
1879
+ async findById(id) {
1880
+ await this.ready;
1881
+ const rows = await this.sql`
1882
+ SELECT * FROM auth_users WHERE id = ${id}
1883
+ `;
1884
+ return rows.length > 0 ? rowToStoredUser2(rows[0]) : null;
1885
+ }
1886
+ async registerDevice(params) {
1887
+ await this.ready;
1888
+ const existingRows = await this.sql`
1889
+ SELECT * FROM auth_devices WHERE id = ${params.id}
1890
+ `;
1891
+ if (existingRows.length > 0 && !existingRows[0].revoked) {
1892
+ return rowToDevice2(existingRows[0]);
1893
+ }
1894
+ const now = Date.now();
1895
+ if (existingRows.length > 0) {
1896
+ await this.sql`
1897
+ UPDATE auth_devices SET revoked = FALSE, public_key = ${params.publicKey}, name = ${params.name}, last_seen_at = ${now}
1898
+ WHERE id = ${params.id}
1899
+ `;
1900
+ } else {
1901
+ await this.sql`
1902
+ INSERT INTO auth_devices (id, user_id, public_key, name, revoked, created_at, last_seen_at)
1903
+ VALUES (${params.id}, ${params.userId}, ${params.publicKey}, ${params.name}, FALSE, ${now}, ${now})
1904
+ `;
1905
+ }
1906
+ return {
1907
+ id: params.id,
1908
+ userId: params.userId,
1909
+ publicKey: params.publicKey,
1910
+ name: params.name,
1911
+ revoked: false,
1912
+ createdAt: existingRows.length > 0 ? Number(existingRows[0].created_at) : now,
1913
+ lastSeenAt: now
1914
+ };
1915
+ }
1916
+ async findDevice(deviceId) {
1917
+ await this.ready;
1918
+ const rows = await this.sql`
1919
+ SELECT * FROM auth_devices WHERE id = ${deviceId}
1920
+ `;
1921
+ return rows.length > 0 ? rowToDevice2(rows[0]) : null;
1922
+ }
1923
+ async listDevices(userId) {
1924
+ await this.ready;
1925
+ const rows = await this.sql`
1926
+ SELECT * FROM auth_devices WHERE user_id = ${userId}
1927
+ `;
1928
+ return rows.map(rowToDevice2);
1929
+ }
1930
+ async revokeDevice(deviceId) {
1931
+ await this.ready;
1932
+ await this.sql`UPDATE auth_devices SET revoked = TRUE WHERE id = ${deviceId}`;
1933
+ }
1934
+ async setEmailVerified(userId, verified) {
1935
+ await this.ready;
1936
+ await this.sql`UPDATE auth_users SET email_verified = ${verified} WHERE id = ${userId}`;
1937
+ }
1938
+ async updatePassword(userId, passwordHash, salt) {
1939
+ await this.ready;
1940
+ await this.sql`
1941
+ UPDATE auth_users SET password_hash = ${passwordHash}, salt = ${salt}
1942
+ WHERE id = ${userId}
1943
+ `;
1944
+ }
1945
+ async listAll() {
1946
+ await this.ready;
1947
+ const rows = await this.sql`SELECT * FROM auth_users`;
1948
+ return rows.map(rowToStoredUser2);
1949
+ }
1950
+ async update(user) {
1951
+ await this.ready;
1952
+ await this.sql`
1953
+ UPDATE auth_users
1954
+ SET email = ${user.email}, name = ${user.name}, email_verified = ${user.emailVerified},
1955
+ password_hash = ${user.passwordHash}, salt = ${user.salt}
1956
+ WHERE id = ${user.id}
1957
+ `;
1958
+ }
1959
+ async delete(userId) {
1960
+ await this.ready;
1961
+ await this.sql.begin(async (tx) => {
1962
+ await tx`DELETE FROM auth_devices WHERE user_id = ${userId}`;
1963
+ await tx`DELETE FROM auth_users WHERE id = ${userId}`;
1964
+ });
1965
+ }
1966
+ async touchDevice(deviceId) {
1967
+ await this.ready;
1968
+ const now = Date.now();
1969
+ await this.sql`UPDATE auth_devices SET last_seen_at = ${now} WHERE id = ${deviceId}`;
1970
+ }
1971
+ };
1972
+ async function createPostgresUserStore(options) {
1973
+ const postgresClient = await loadPostgresDeps();
1974
+ const sql = postgresClient(options.connectionString);
1975
+ return new PostgresUserStore(sql);
1976
+ }
1977
+ async function loadPostgresDeps() {
1978
+ try {
1979
+ const dynamicImport = new Function("specifier", "return import(specifier)");
1980
+ const postgresMod = await dynamicImport("postgres");
1981
+ return postgresMod.default;
1982
+ } catch {
1983
+ throw new Error(
1984
+ 'PostgreSQL backend requires the "postgres" package. Install it in your project dependencies.'
1985
+ );
1986
+ }
1987
+ }
1988
+ function rowToStoredUser2(row) {
1989
+ return {
1990
+ id: row.id,
1991
+ email: row.email,
1992
+ name: row.name,
1993
+ emailVerified: Boolean(row.email_verified),
1994
+ createdAt: Number(row.created_at),
1995
+ passwordHash: row.password_hash,
1996
+ salt: row.salt
1997
+ };
1998
+ }
1999
+ function rowToDevice2(row) {
2000
+ return {
2001
+ id: row.id,
2002
+ userId: row.user_id,
2003
+ publicKey: row.public_key,
2004
+ name: row.name,
2005
+ revoked: Boolean(row.revoked),
2006
+ createdAt: Number(row.created_at),
2007
+ lastSeenAt: Number(row.last_seen_at)
2008
+ };
2009
+ }
2010
+
794
2011
  // src/provider/adapter.ts
795
2012
  var AuthProviderError = class extends Error {
796
2013
  /** HTTP-style status code */
@@ -852,6 +2069,7 @@ var BuiltInProvider = class {
852
2069
  id: stored.id,
853
2070
  email: stored.email,
854
2071
  name: stored.name,
2072
+ emailVerified: stored.emailVerified,
855
2073
  createdAt: stored.createdAt
856
2074
  };
857
2075
  }
@@ -871,20 +2089,3363 @@ var BuiltInProvider = class {
871
2089
  return result.body.data;
872
2090
  }
873
2091
  };
874
- export {
875
- AuthProviderError,
876
- BuiltInAuthRoutes,
877
- BuiltInProvider,
878
- DuplicateEmailError,
879
- InMemoryUserStore,
880
- TokenManager,
881
- computePublicKeyThumbprint,
882
- decodeJwt,
883
- encodeJwt,
884
- hashPassword,
885
- isExpired,
886
- verifyChallenge,
887
- verifyJwt,
888
- verifyPassword
2092
+
2093
+ // src/provider/external/external-jwt-provider.ts
2094
+ import { KoraError as KoraError5 } from "@korajs/core";
2095
+ var ExternalAuthOperationNotSupportedError = class extends KoraError5 {
2096
+ constructor(operation, provider) {
2097
+ super(
2098
+ `The "${operation}" operation is not supported by the external auth provider "${provider}". Perform this operation through your external auth provider's SDK or dashboard instead.`,
2099
+ "AUTH_EXTERNAL_OPERATION_NOT_SUPPORTED",
2100
+ { operation, provider }
2101
+ );
2102
+ this.name = "ExternalAuthOperationNotSupportedError";
2103
+ }
2104
+ };
2105
+ var ExternalTokenValidationError = class extends KoraError5 {
2106
+ constructor(reason, context) {
2107
+ super(
2108
+ `External token validation failed: ${reason}`,
2109
+ "AUTH_EXTERNAL_TOKEN_INVALID",
2110
+ context
2111
+ );
2112
+ this.name = "ExternalTokenValidationError";
2113
+ }
2114
+ };
2115
+ function defaultMapClaims(claims) {
2116
+ const sub = claims["sub"];
2117
+ if (typeof sub !== "string" || sub.length === 0) {
2118
+ throw new ExternalTokenValidationError(
2119
+ 'JWT is missing a valid "sub" (subject) claim. The "sub" claim must be a non-empty string identifying the user.',
2120
+ { availableClaims: Object.keys(claims) }
2121
+ );
2122
+ }
2123
+ return {
2124
+ userId: sub,
2125
+ email: typeof claims["email"] === "string" ? claims["email"] : void 0,
2126
+ name: typeof claims["name"] === "string" ? claims["name"] : void 0,
2127
+ metadata: void 0
2128
+ };
2129
+ }
2130
+ var ExternalJwtProvider = class {
2131
+ providerName;
2132
+ jwtSecret;
2133
+ customValidateToken;
2134
+ mapClaims;
2135
+ constructor(config) {
2136
+ if (config.validateToken === void 0 && config.jwtSecret === void 0) {
2137
+ throw new ExternalTokenValidationError(
2138
+ 'ExternalJwtProvider requires either a "jwtSecret" for HS256 verification or a custom "validateToken" function. Provide at least one.',
2139
+ { providerName: config.providerName }
2140
+ );
2141
+ }
2142
+ this.providerName = config.providerName;
2143
+ this.jwtSecret = config.jwtSecret;
2144
+ this.customValidateToken = config.validateToken;
2145
+ this.mapClaims = config.mapClaims ?? defaultMapClaims;
2146
+ }
2147
+ /**
2148
+ * Not supported for external providers.
2149
+ *
2150
+ * User registration must be performed through the external auth provider's
2151
+ * SDK or UI. Kora does not manage user accounts for external providers.
2152
+ *
2153
+ * @throws {ExternalAuthOperationNotSupportedError} Always
2154
+ */
2155
+ async signUp(_params) {
2156
+ throw new ExternalAuthOperationNotSupportedError("signUp", this.providerName);
2157
+ }
2158
+ /**
2159
+ * Not supported for external providers.
2160
+ *
2161
+ * User authentication must be performed through the external auth provider's
2162
+ * SDK or UI. Kora does not manage credentials for external providers.
2163
+ *
2164
+ * @throws {ExternalAuthOperationNotSupportedError} Always
2165
+ */
2166
+ async signIn(_params) {
2167
+ throw new ExternalAuthOperationNotSupportedError("signIn", this.providerName);
2168
+ }
2169
+ /**
2170
+ * Not supported for external providers.
2171
+ *
2172
+ * Token refresh must be performed through the external auth provider's SDK.
2173
+ * Kora does not manage token lifecycle for external providers.
2174
+ *
2175
+ * @throws {ExternalAuthOperationNotSupportedError} Always
2176
+ */
2177
+ async refreshTokens(_refreshToken) {
2178
+ throw new ExternalAuthOperationNotSupportedError("refreshTokens", this.providerName);
2179
+ }
2180
+ /**
2181
+ * Validate an access token from the external auth provider.
2182
+ *
2183
+ * Uses either the custom `validateToken` function or HS256 HMAC verification
2184
+ * (depending on configuration) to validate the JWT. On success, maps the claims
2185
+ * to Kora's expected format and returns the user ID and device ID.
2186
+ *
2187
+ * The device ID for external providers is derived from the user ID with a
2188
+ * "external-" prefix, since external providers typically don't use Kora's
2189
+ * device identity system.
2190
+ *
2191
+ * @param token - The JWT access token issued by the external provider
2192
+ * @returns User ID and device ID if the token is valid, or null if invalid/expired
2193
+ */
2194
+ async validateAccessToken(token) {
2195
+ const claims = await this.extractClaims(token);
2196
+ if (claims === null) {
2197
+ return null;
2198
+ }
2199
+ let userInfo;
2200
+ try {
2201
+ userInfo = this.mapClaims(claims);
2202
+ } catch {
2203
+ return null;
2204
+ }
2205
+ if (typeof userInfo.userId !== "string" || userInfo.userId.length === 0) {
2206
+ return null;
2207
+ }
2208
+ const deviceId = `external-${this.providerName}-${userInfo.userId}`;
2209
+ return { userId: userInfo.userId, deviceId };
2210
+ }
2211
+ /**
2212
+ * Not supported for external providers.
2213
+ *
2214
+ * User lookup must be performed through the external auth provider's API.
2215
+ *
2216
+ * @throws {ExternalAuthOperationNotSupportedError} Always
2217
+ */
2218
+ async getUser(_userId) {
2219
+ throw new ExternalAuthOperationNotSupportedError("getUser", this.providerName);
2220
+ }
2221
+ /**
2222
+ * Not supported for external providers.
2223
+ *
2224
+ * Device revocation must be managed through the external auth provider
2225
+ * or by revoking the user's tokens at the provider level.
2226
+ *
2227
+ * @throws {ExternalAuthOperationNotSupportedError} Always
2228
+ */
2229
+ async revokeDevice(_accessToken, _deviceId) {
2230
+ throw new ExternalAuthOperationNotSupportedError("revokeDevice", this.providerName);
2231
+ }
2232
+ /**
2233
+ * Not supported for external providers.
2234
+ *
2235
+ * Device listing must be performed through the external auth provider's API.
2236
+ *
2237
+ * @throws {ExternalAuthOperationNotSupportedError} Always
2238
+ */
2239
+ async listDevices(_accessToken) {
2240
+ throw new ExternalAuthOperationNotSupportedError("listDevices", this.providerName);
2241
+ }
2242
+ /**
2243
+ * Creates a sync server auth provider compatible with `@korajs/server`.
2244
+ *
2245
+ * Returns an object with an `authenticate` method that validates the external
2246
+ * JWT and returns a Kora-compatible auth context. Use this to wire the external
2247
+ * auth provider into KoraSyncServer.
2248
+ *
2249
+ * @returns An object with an `authenticate` method for KoraSyncServer's `auth` config
2250
+ *
2251
+ * @example
2252
+ * ```typescript
2253
+ * const externalAuth = new ExternalJwtProvider({ ... })
2254
+ * const syncServer = new KoraSyncServer({
2255
+ * store,
2256
+ * auth: externalAuth.toSyncAuthProvider(),
2257
+ * })
2258
+ * ```
2259
+ */
2260
+ toSyncAuthProvider() {
2261
+ return {
2262
+ authenticate: async (token) => {
2263
+ const claims = await this.extractClaims(token);
2264
+ if (claims === null) {
2265
+ return null;
2266
+ }
2267
+ let userInfo;
2268
+ try {
2269
+ userInfo = this.mapClaims(claims);
2270
+ } catch {
2271
+ return null;
2272
+ }
2273
+ if (typeof userInfo.userId !== "string" || userInfo.userId.length === 0) {
2274
+ return null;
2275
+ }
2276
+ return {
2277
+ userId: userInfo.userId,
2278
+ metadata: {
2279
+ provider: this.providerName,
2280
+ email: userInfo.email,
2281
+ name: userInfo.name,
2282
+ ...userInfo.metadata
2283
+ }
2284
+ };
2285
+ }
2286
+ };
2287
+ }
2288
+ /**
2289
+ * Extract and validate claims from a JWT token.
2290
+ *
2291
+ * Uses the custom validator if configured, otherwise falls back to
2292
+ * HS256 HMAC verification with the configured secret.
2293
+ *
2294
+ * @param token - The raw JWT string
2295
+ * @returns The decoded claims object, or null if the token is invalid
2296
+ */
2297
+ async extractClaims(token) {
2298
+ if (this.customValidateToken !== void 0) {
2299
+ try {
2300
+ const result = await this.customValidateToken(token);
2301
+ if (result === null) {
2302
+ return null;
2303
+ }
2304
+ return result;
2305
+ } catch {
2306
+ return null;
2307
+ }
2308
+ }
2309
+ if (this.jwtSecret !== void 0) {
2310
+ const claims = verifyJwt(token, this.jwtSecret);
2311
+ if (claims === null) {
2312
+ return null;
2313
+ }
2314
+ if (isExpired(claims)) {
2315
+ return null;
2316
+ }
2317
+ return claims;
2318
+ }
2319
+ return null;
2320
+ }
2321
+ };
2322
+
2323
+ // src/provider/external/clerk-adapter.ts
2324
+ function defaultClerkClaimMapping(claims) {
2325
+ const sub = claims["sub"];
2326
+ if (typeof sub !== "string" || sub.length === 0) {
2327
+ return { userId: "" };
2328
+ }
2329
+ const firstName = typeof claims["first_name"] === "string" ? claims["first_name"] : "";
2330
+ const lastName = typeof claims["last_name"] === "string" ? claims["last_name"] : "";
2331
+ const fullName = [firstName, lastName].filter(Boolean).join(" ");
2332
+ const email = typeof claims["email"] === "string" ? claims["email"] : void 0;
2333
+ const metadata = {};
2334
+ if (typeof claims["org_id"] === "string") {
2335
+ metadata["orgId"] = claims["org_id"];
2336
+ }
2337
+ if (typeof claims["org_slug"] === "string") {
2338
+ metadata["orgSlug"] = claims["org_slug"];
2339
+ }
2340
+ if (typeof claims["org_role"] === "string") {
2341
+ metadata["orgRole"] = claims["org_role"];
2342
+ }
2343
+ return {
2344
+ userId: sub,
2345
+ email,
2346
+ name: fullName.length > 0 ? fullName : void 0,
2347
+ metadata: Object.keys(metadata).length > 0 ? metadata : void 0
2348
+ };
2349
+ }
2350
+ function createClerkAdapter(config) {
2351
+ return new ExternalJwtProvider({
2352
+ providerName: "clerk",
2353
+ validateToken: config.validateToken,
2354
+ mapClaims: config.mapClaims ?? defaultClerkClaimMapping
2355
+ });
2356
+ }
2357
+
2358
+ // src/provider/external/supabase-adapter.ts
2359
+ function defaultSupabaseClaimMapping(claims) {
2360
+ const sub = claims["sub"];
2361
+ if (typeof sub !== "string" || sub.length === 0) {
2362
+ return { userId: "" };
2363
+ }
2364
+ const email = typeof claims["email"] === "string" ? claims["email"] : void 0;
2365
+ let name;
2366
+ const userMetadata = claims["user_metadata"];
2367
+ if (typeof userMetadata === "object" && userMetadata !== null && !Array.isArray(userMetadata)) {
2368
+ const meta = userMetadata;
2369
+ if (typeof meta["full_name"] === "string" && meta["full_name"].length > 0) {
2370
+ name = meta["full_name"];
2371
+ } else if (typeof meta["name"] === "string" && meta["name"].length > 0) {
2372
+ name = meta["name"];
2373
+ }
2374
+ }
2375
+ const metadata = {};
2376
+ if (typeof claims["role"] === "string") {
2377
+ metadata["role"] = claims["role"];
2378
+ }
2379
+ if (typeof claims["aud"] === "string") {
2380
+ metadata["aud"] = claims["aud"];
2381
+ }
2382
+ if (typeof claims["app_metadata"] === "object" && claims["app_metadata"] !== null && !Array.isArray(claims["app_metadata"])) {
2383
+ metadata["appMetadata"] = claims["app_metadata"];
2384
+ }
2385
+ return {
2386
+ userId: sub,
2387
+ email,
2388
+ name,
2389
+ metadata: Object.keys(metadata).length > 0 ? metadata : void 0
2390
+ };
2391
+ }
2392
+ function createSupabaseAdapter(config) {
2393
+ return new ExternalJwtProvider({
2394
+ providerName: "supabase",
2395
+ jwtSecret: config.jwtSecret,
2396
+ mapClaims: config.mapClaims ?? defaultSupabaseClaimMapping
2397
+ });
2398
+ }
2399
+
2400
+ // src/passkey/passkey-server.ts
2401
+ import { randomBytes as randomBytes3 } from "crypto";
2402
+ import { KoraError as KoraError6 } from "@korajs/core";
2403
+ var PasskeyVerificationError = class extends KoraError6 {
2404
+ constructor(message, context) {
2405
+ super(message, "PASSKEY_VERIFICATION_ERROR", context);
2406
+ this.name = "PasskeyVerificationError";
2407
+ }
2408
+ };
2409
+ function generateRegistrationOptions(params) {
2410
+ const challengeBytes = randomBytes3(32);
2411
+ const challenge = toBase64Url(challengeBytes.buffer.slice(
2412
+ challengeBytes.byteOffset,
2413
+ challengeBytes.byteOffset + challengeBytes.byteLength
2414
+ ));
2415
+ return {
2416
+ challenge,
2417
+ rpId: params.rpId,
2418
+ rpName: params.rpName,
2419
+ userId: params.userId,
2420
+ userName: params.userName,
2421
+ userDisplayName: params.userDisplayName,
2422
+ excludeCredentialIds: params.existingCredentialIds ?? [],
2423
+ authenticatorSelection: {
2424
+ authenticatorAttachment: "platform",
2425
+ residentKey: "preferred",
2426
+ userVerification: "required"
2427
+ },
2428
+ timeout: 6e4
2429
+ };
2430
+ }
2431
+ async function verifyRegistrationResponse(params) {
2432
+ const { credential, expectedChallenge, expectedOrigin, expectedRpId } = params;
2433
+ const clientDataBytes = fromBase64Url(credential.clientDataJSON);
2434
+ const clientDataText = new TextDecoder().decode(clientDataBytes);
2435
+ let clientData;
2436
+ try {
2437
+ clientData = JSON.parse(clientDataText);
2438
+ } catch {
2439
+ throw new PasskeyVerificationError(
2440
+ "Failed to parse clientDataJSON. The response may be malformed."
2441
+ );
2442
+ }
2443
+ if (clientData.type !== "webauthn.create") {
2444
+ throw new PasskeyVerificationError(
2445
+ `Expected clientData.type "webauthn.create" but received "${clientData.type}".`,
2446
+ { type: clientData.type }
2447
+ );
2448
+ }
2449
+ if (clientData.challenge !== expectedChallenge) {
2450
+ throw new PasskeyVerificationError(
2451
+ "Challenge mismatch. The response does not match the expected challenge. This may indicate a replay attack or session mismatch."
2452
+ );
2453
+ }
2454
+ if (clientData.origin !== expectedOrigin) {
2455
+ throw new PasskeyVerificationError(
2456
+ `Origin mismatch. Expected "${expectedOrigin}" but received "${clientData.origin}".`,
2457
+ { expected: expectedOrigin, received: clientData.origin }
2458
+ );
2459
+ }
2460
+ const attestationBytes = fromBase64Url(credential.attestationObject);
2461
+ const attestationResult = decodeCbor(attestationBytes, 0);
2462
+ const attestationMap = attestationResult.value;
2463
+ const fmt = attestationMap.get("fmt");
2464
+ if (fmt !== "none") {
2465
+ throw new PasskeyVerificationError(
2466
+ `Unsupported attestation format "${String(fmt)}". Only "none" attestation is currently supported.`,
2467
+ { format: String(fmt) }
2468
+ );
2469
+ }
2470
+ const authData = attestationMap.get("authData");
2471
+ if (!(authData instanceof Uint8Array)) {
2472
+ throw new PasskeyVerificationError(
2473
+ "Invalid attestation object: authData is missing or not a byte string."
2474
+ );
2475
+ }
2476
+ const rpIdHash = authData.slice(0, 32);
2477
+ const expectedRpIdHash = await sha256(new TextEncoder().encode(expectedRpId));
2478
+ if (!constantTimeEqual(rpIdHash, new Uint8Array(expectedRpIdHash))) {
2479
+ throw new PasskeyVerificationError(
2480
+ "RP ID hash mismatch. The authenticator data does not match the expected relying party."
2481
+ );
2482
+ }
2483
+ const flags = authData[32];
2484
+ if ((flags & 1) === 0) {
2485
+ throw new PasskeyVerificationError(
2486
+ "User Present flag is not set in authenticator data. The authenticator did not confirm user presence."
2487
+ );
2488
+ }
2489
+ if ((flags & 64) === 0) {
2490
+ throw new PasskeyVerificationError(
2491
+ "Attested Credential Data flag is not set. The authenticator did not include credential data."
2492
+ );
2493
+ }
2494
+ const signCount = authData[33] << 24 | authData[34] << 16 | authData[35] << 8 | authData[36];
2495
+ let offset = 37;
2496
+ offset += 16;
2497
+ const credentialIdLength = authData[offset] << 8 | authData[offset + 1];
2498
+ offset += 2;
2499
+ const credentialIdBytes = authData.slice(offset, offset + credentialIdLength);
2500
+ offset += credentialIdLength;
2501
+ const expectedCredentialId = toBase64Url(credentialIdBytes.buffer);
2502
+ if (expectedCredentialId !== credential.credentialId) {
2503
+ throw new PasskeyVerificationError(
2504
+ "Credential ID mismatch between attestation object and client response."
2505
+ );
2506
+ }
2507
+ const coseKeyResult = decodeCbor(authData, offset);
2508
+ const coseKeyBytes = authData.slice(offset, coseKeyResult.offset);
2509
+ const publicKeyFromAttestation = toBase64Url(coseKeyBytes.buffer);
2510
+ if (publicKeyFromAttestation !== credential.publicKey) {
2511
+ throw new PasskeyVerificationError(
2512
+ "Public key mismatch between attestation object and client response."
2513
+ );
2514
+ }
2515
+ return {
2516
+ verified: true,
2517
+ credentialId: credential.credentialId,
2518
+ publicKey: credential.publicKey,
2519
+ signCount: signCount >>> 0
2520
+ };
2521
+ }
2522
+ function generateAuthenticationOptions(params) {
2523
+ const challengeBytes = randomBytes3(32);
2524
+ const challenge = toBase64Url(challengeBytes.buffer.slice(
2525
+ challengeBytes.byteOffset,
2526
+ challengeBytes.byteOffset + challengeBytes.byteLength
2527
+ ));
2528
+ return {
2529
+ challenge,
2530
+ rpId: params.rpId,
2531
+ allowCredentialIds: params.allowCredentialIds,
2532
+ userVerification: "preferred",
2533
+ timeout: 6e4
2534
+ };
2535
+ }
2536
+ async function verifyAuthenticationResponse(params) {
2537
+ const {
2538
+ assertion,
2539
+ expectedChallenge,
2540
+ expectedOrigin,
2541
+ expectedRpId,
2542
+ publicKey,
2543
+ previousSignCount
2544
+ } = params;
2545
+ const clientDataBytes = fromBase64Url(assertion.clientDataJSON);
2546
+ const clientDataText = new TextDecoder().decode(clientDataBytes);
2547
+ let clientData;
2548
+ try {
2549
+ clientData = JSON.parse(clientDataText);
2550
+ } catch {
2551
+ throw new PasskeyVerificationError(
2552
+ "Failed to parse clientDataJSON. The assertion may be malformed."
2553
+ );
2554
+ }
2555
+ if (clientData.type !== "webauthn.get") {
2556
+ throw new PasskeyVerificationError(
2557
+ `Expected clientData.type "webauthn.get" but received "${clientData.type}".`,
2558
+ { type: clientData.type }
2559
+ );
2560
+ }
2561
+ if (clientData.challenge !== expectedChallenge) {
2562
+ throw new PasskeyVerificationError(
2563
+ "Challenge mismatch. The assertion does not match the expected challenge."
2564
+ );
2565
+ }
2566
+ if (clientData.origin !== expectedOrigin) {
2567
+ throw new PasskeyVerificationError(
2568
+ `Origin mismatch. Expected "${expectedOrigin}" but received "${clientData.origin}".`,
2569
+ { expected: expectedOrigin, received: clientData.origin }
2570
+ );
2571
+ }
2572
+ const authDataBytes = fromBase64Url(assertion.authenticatorData);
2573
+ const rpIdHash = authDataBytes.slice(0, 32);
2574
+ const expectedRpIdHash = await sha256(new TextEncoder().encode(expectedRpId));
2575
+ if (!constantTimeEqual(rpIdHash, new Uint8Array(expectedRpIdHash))) {
2576
+ throw new PasskeyVerificationError(
2577
+ "RP ID hash mismatch. The authenticator data does not match the expected relying party."
2578
+ );
2579
+ }
2580
+ const flags = authDataBytes[32];
2581
+ if ((flags & 1) === 0) {
2582
+ throw new PasskeyVerificationError(
2583
+ "User Present flag is not set in authenticator data."
2584
+ );
2585
+ }
2586
+ const signCount = (authDataBytes[33] << 24 | authDataBytes[34] << 16 | authDataBytes[35] << 8 | authDataBytes[36]) >>> 0;
2587
+ if (previousSignCount > 0 || signCount > 0) {
2588
+ if (signCount <= previousSignCount) {
2589
+ throw new PasskeyVerificationError(
2590
+ `Signature counter did not increase. This may indicate a cloned authenticator. Previous count: ${previousSignCount}, received count: ${signCount}.`,
2591
+ {
2592
+ previousSignCount,
2593
+ receivedSignCount: signCount
2594
+ }
2595
+ );
2596
+ }
2597
+ }
2598
+ const clientDataHash = await sha256(clientDataBytes);
2599
+ const signedData = new Uint8Array(
2600
+ authDataBytes.length + clientDataHash.byteLength
2601
+ );
2602
+ signedData.set(authDataBytes, 0);
2603
+ signedData.set(new Uint8Array(clientDataHash), authDataBytes.length);
2604
+ const coseKeyBytes = fromBase64Url(publicKey);
2605
+ const coseKeyResult = decodeCbor(coseKeyBytes, 0);
2606
+ const coseKeyMap = coseKeyResult.value;
2607
+ const kty = coseKeyMap.get(1);
2608
+ const alg = coseKeyMap.get(3);
2609
+ if (kty !== 2) {
2610
+ throw new PasskeyVerificationError(
2611
+ `Unsupported COSE key type ${String(kty)}. Only EC2 (kty=2) is supported.`,
2612
+ { kty: String(kty) }
2613
+ );
2614
+ }
2615
+ if (alg !== -7) {
2616
+ throw new PasskeyVerificationError(
2617
+ `Unsupported COSE algorithm ${String(alg)}. Only ES256 (alg=-7) is supported.`,
2618
+ { alg: String(alg) }
2619
+ );
2620
+ }
2621
+ const xCoord = coseKeyMap.get(-2);
2622
+ const yCoord = coseKeyMap.get(-3);
2623
+ if (!(xCoord instanceof Uint8Array) || !(yCoord instanceof Uint8Array) || xCoord.length !== 32 || yCoord.length !== 32) {
2624
+ throw new PasskeyVerificationError(
2625
+ "Invalid COSE public key: x and y coordinates must be 32-byte arrays."
2626
+ );
2627
+ }
2628
+ const rawPublicKey = new Uint8Array(65);
2629
+ rawPublicKey[0] = 4;
2630
+ rawPublicKey.set(xCoord, 1);
2631
+ rawPublicKey.set(yCoord, 33);
2632
+ let cryptoKey;
2633
+ try {
2634
+ cryptoKey = await globalThis.crypto.subtle.importKey(
2635
+ "raw",
2636
+ rawPublicKey.buffer,
2637
+ { name: "ECDSA", namedCurve: "P-256" },
2638
+ false,
2639
+ ["verify"]
2640
+ );
2641
+ } catch (error) {
2642
+ throw new PasskeyVerificationError(
2643
+ "Failed to import COSE public key for signature verification.",
2644
+ { cause: error instanceof Error ? error.message : String(error) }
2645
+ );
2646
+ }
2647
+ const signatureBytes = fromBase64Url(assertion.signature);
2648
+ const p1363Signature = derToP1363(signatureBytes, 32);
2649
+ let verified;
2650
+ try {
2651
+ verified = await globalThis.crypto.subtle.verify(
2652
+ { name: "ECDSA", hash: { name: "SHA-256" } },
2653
+ cryptoKey,
2654
+ p1363Signature.buffer,
2655
+ signedData.buffer
2656
+ );
2657
+ } catch (error) {
2658
+ throw new PasskeyVerificationError(
2659
+ "Signature verification operation failed.",
2660
+ { cause: error instanceof Error ? error.message : String(error) }
2661
+ );
2662
+ }
2663
+ if (!verified) {
2664
+ return { verified: false, newSignCount: signCount };
2665
+ }
2666
+ return { verified: true, newSignCount: signCount };
2667
+ }
2668
+ async function sha256(data) {
2669
+ return globalThis.crypto.subtle.digest("SHA-256", data);
2670
+ }
2671
+ function constantTimeEqual(a, b) {
2672
+ if (a.length !== b.length) {
2673
+ return false;
2674
+ }
2675
+ let result = 0;
2676
+ for (let i = 0; i < a.length; i++) {
2677
+ result |= a[i] ^ b[i];
2678
+ }
2679
+ return result === 0;
2680
+ }
2681
+ function derToP1363(derSignature, componentLength) {
2682
+ let offset = 0;
2683
+ if (derSignature[offset] !== 48) {
2684
+ throw new PasskeyVerificationError(
2685
+ "Invalid DER signature: expected SEQUENCE tag (0x30)."
2686
+ );
2687
+ }
2688
+ offset += 1;
2689
+ if (derSignature[offset] & 128) {
2690
+ const lengthBytes = derSignature[offset] & 127;
2691
+ offset += 1 + lengthBytes;
2692
+ } else {
2693
+ offset += 1;
2694
+ }
2695
+ if (derSignature[offset] !== 2) {
2696
+ throw new PasskeyVerificationError(
2697
+ "Invalid DER signature: expected INTEGER tag (0x02) for r component."
2698
+ );
2699
+ }
2700
+ offset += 1;
2701
+ const rLength = derSignature[offset];
2702
+ offset += 1;
2703
+ const rBytes = derSignature.slice(offset, offset + rLength);
2704
+ offset += rLength;
2705
+ if (derSignature[offset] !== 2) {
2706
+ throw new PasskeyVerificationError(
2707
+ "Invalid DER signature: expected INTEGER tag (0x02) for s component."
2708
+ );
2709
+ }
2710
+ offset += 1;
2711
+ const sLength = derSignature[offset];
2712
+ offset += 1;
2713
+ const sBytes = derSignature.slice(offset, offset + sLength);
2714
+ const result = new Uint8Array(componentLength * 2);
2715
+ copyComponentToP1363(rBytes, result, 0, componentLength);
2716
+ copyComponentToP1363(sBytes, result, componentLength, componentLength);
2717
+ return result;
2718
+ }
2719
+ function copyComponentToP1363(component, target, targetOffset, componentLength) {
2720
+ if (component.length === componentLength) {
2721
+ target.set(component, targetOffset);
2722
+ } else if (component.length > componentLength) {
2723
+ const excess = component.length - componentLength;
2724
+ target.set(component.slice(excess), targetOffset);
2725
+ } else {
2726
+ const padding = componentLength - component.length;
2727
+ target.set(component, targetOffset + padding);
2728
+ }
2729
+ }
2730
+
2731
+ // src/org/org-types.ts
2732
+ import { KoraError as KoraError7 } from "@korajs/core";
2733
+ var ORG_ROLES = ["owner", "admin", "member", "viewer", "billing"];
2734
+ var ROLE_HIERARCHY = {
2735
+ viewer: 10,
2736
+ billing: 15,
2737
+ member: 20,
2738
+ admin: 30,
2739
+ owner: 40
2740
+ };
2741
+ function hasRoleLevel(userRole, requiredRole) {
2742
+ return ROLE_HIERARCHY[userRole] >= ROLE_HIERARCHY[requiredRole];
2743
+ }
2744
+ var INVITATION_STATUSES = ["pending", "accepted", "revoked", "expired"];
2745
+ var OrgError = class extends KoraError7 {
2746
+ constructor(message, code, context) {
2747
+ super(message, code, context);
2748
+ this.name = "OrgError";
2749
+ }
2750
+ };
2751
+ var OrgNotFoundError = class extends OrgError {
2752
+ constructor() {
2753
+ super("Organization not found.", "ORG_NOT_FOUND");
2754
+ }
2755
+ };
2756
+ var OrgSlugTakenError = class extends OrgError {
2757
+ constructor() {
2758
+ super("An organization with this slug already exists.", "ORG_SLUG_TAKEN");
2759
+ }
2760
+ };
2761
+ var MembershipNotFoundError = class extends OrgError {
2762
+ constructor() {
2763
+ super("User is not a member of this organization.", "MEMBERSHIP_NOT_FOUND");
2764
+ }
2765
+ };
2766
+ var MemberAlreadyExistsError = class extends OrgError {
2767
+ constructor() {
2768
+ super("User is already a member of this organization.", "MEMBER_ALREADY_EXISTS");
2769
+ }
2770
+ };
2771
+ var InsufficientRoleError = class extends OrgError {
2772
+ constructor(required) {
2773
+ super(
2774
+ `This action requires at least the "${required}" role.`,
2775
+ "INSUFFICIENT_ROLE",
2776
+ { requiredRole: required }
2777
+ );
2778
+ }
2779
+ };
2780
+ var CannotRemoveOwnerError = class extends OrgError {
2781
+ constructor() {
2782
+ super(
2783
+ "The organization owner cannot be removed. Transfer ownership first.",
2784
+ "CANNOT_REMOVE_OWNER"
2785
+ );
2786
+ }
2787
+ };
2788
+ var InvitationNotFoundError = class extends OrgError {
2789
+ constructor() {
2790
+ super("Invitation not found or has already been used.", "INVITATION_NOT_FOUND");
2791
+ }
2792
+ };
2793
+ var InvitationExpiredError = class extends OrgError {
2794
+ constructor() {
2795
+ super("This invitation has expired.", "INVITATION_EXPIRED");
2796
+ }
2797
+ };
2798
+
2799
+ // src/org/org-routes.ts
2800
+ var MAX_ORG_NAME_LENGTH = 200;
2801
+ var MAX_SLUG_LENGTH = 100;
2802
+ var SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,98}[a-z0-9]$/;
2803
+ function isValidEmail2(email) {
2804
+ if (email.length === 0 || email.length > 254) return false;
2805
+ const atIndex = email.indexOf("@");
2806
+ if (atIndex < 1) return false;
2807
+ const domain = email.slice(atIndex + 1);
2808
+ if (domain.length === 0 || !domain.includes(".")) return false;
2809
+ if (email.indexOf("@", atIndex + 1) !== -1) return false;
2810
+ if (email.includes(" ")) return false;
2811
+ return true;
2812
+ }
2813
+ function sanitize(value) {
2814
+ return value.replace(/[\x00-\x1f\x7f]/g, "").trim();
2815
+ }
2816
+ var OrgRoutes = class {
2817
+ store;
2818
+ constructor(config) {
2819
+ this.store = config.orgStore;
2820
+ }
2821
+ // --- Organizations ---
2822
+ /**
2823
+ * Create a new organization. The authenticated user becomes the owner.
2824
+ */
2825
+ async createOrg(userId, params) {
2826
+ if (typeof params.name !== "string" || params.name.trim().length === 0) {
2827
+ return { status: 400, body: { error: "Organization name is required." } };
2828
+ }
2829
+ const name = sanitize(params.name);
2830
+ if (name.length > MAX_ORG_NAME_LENGTH) {
2831
+ return {
2832
+ status: 400,
2833
+ body: { error: `Organization name must be at most ${MAX_ORG_NAME_LENGTH} characters.` }
2834
+ };
2835
+ }
2836
+ let slug;
2837
+ if (params.slug !== void 0) {
2838
+ if (typeof params.slug !== "string") {
2839
+ return { status: 400, body: { error: "Slug must be a string." } };
2840
+ }
2841
+ slug = params.slug.toLowerCase().trim();
2842
+ if (slug.length < 2) {
2843
+ return { status: 400, body: { error: "Slug must be at least 2 characters." } };
2844
+ }
2845
+ if (slug.length > MAX_SLUG_LENGTH) {
2846
+ return {
2847
+ status: 400,
2848
+ body: { error: `Slug must be at most ${MAX_SLUG_LENGTH} characters.` }
2849
+ };
2850
+ }
2851
+ if (!SLUG_PATTERN.test(slug)) {
2852
+ return {
2853
+ status: 400,
2854
+ body: {
2855
+ error: "Slug must contain only lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen."
2856
+ }
2857
+ };
2858
+ }
2859
+ }
2860
+ if (params.metadata !== void 0 && (typeof params.metadata !== "object" || params.metadata === null || Array.isArray(params.metadata))) {
2861
+ return { status: 400, body: { error: "Metadata must be a plain object." } };
2862
+ }
2863
+ try {
2864
+ const createParams = {
2865
+ name,
2866
+ slug,
2867
+ metadata: params.metadata
2868
+ };
2869
+ const org = await this.store.createOrg(userId, createParams);
2870
+ return { status: 201, body: { data: org } };
2871
+ } catch (err) {
2872
+ if (err instanceof OrgSlugTakenError) {
2873
+ return { status: 409, body: { error: err.message } };
2874
+ }
2875
+ throw err;
2876
+ }
2877
+ }
2878
+ /**
2879
+ * Get an organization by ID. Requires membership.
2880
+ */
2881
+ async getOrg(userId, orgId) {
2882
+ const membership = await this.store.getMembership(orgId, userId);
2883
+ if (!membership) {
2884
+ return { status: 404, body: { error: "Organization not found." } };
2885
+ }
2886
+ const org = await this.store.getOrg(orgId);
2887
+ if (!org) {
2888
+ return { status: 404, body: { error: "Organization not found." } };
2889
+ }
2890
+ return { status: 200, body: { data: org } };
2891
+ }
2892
+ /**
2893
+ * Update an organization. Requires admin or higher.
2894
+ */
2895
+ async updateOrg(userId, orgId, params) {
2896
+ const authResult = await this.requireRole(orgId, userId, "admin");
2897
+ if (authResult) return authResult;
2898
+ const updateParams = {};
2899
+ if (params.name !== void 0) {
2900
+ if (typeof params.name !== "string" || params.name.trim().length === 0) {
2901
+ return { status: 400, body: { error: "Organization name must be a non-empty string." } };
2902
+ }
2903
+ updateParams.name = sanitize(params.name);
2904
+ if (updateParams.name.length > MAX_ORG_NAME_LENGTH) {
2905
+ return {
2906
+ status: 400,
2907
+ body: { error: `Organization name must be at most ${MAX_ORG_NAME_LENGTH} characters.` }
2908
+ };
2909
+ }
2910
+ }
2911
+ if (params.slug !== void 0) {
2912
+ if (typeof params.slug !== "string") {
2913
+ return { status: 400, body: { error: "Slug must be a string." } };
2914
+ }
2915
+ updateParams.slug = params.slug.toLowerCase().trim();
2916
+ if (updateParams.slug.length < 2) {
2917
+ return { status: 400, body: { error: "Slug must be at least 2 characters." } };
2918
+ }
2919
+ if (updateParams.slug.length > MAX_SLUG_LENGTH) {
2920
+ return {
2921
+ status: 400,
2922
+ body: { error: `Slug must be at most ${MAX_SLUG_LENGTH} characters.` }
2923
+ };
2924
+ }
2925
+ if (!SLUG_PATTERN.test(updateParams.slug)) {
2926
+ return {
2927
+ status: 400,
2928
+ body: {
2929
+ error: "Slug must contain only lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen."
2930
+ }
2931
+ };
2932
+ }
2933
+ }
2934
+ if (params.metadata !== void 0) {
2935
+ if (typeof params.metadata !== "object" || params.metadata === null || Array.isArray(params.metadata)) {
2936
+ return { status: 400, body: { error: "Metadata must be a plain object." } };
2937
+ }
2938
+ updateParams.metadata = params.metadata;
2939
+ }
2940
+ try {
2941
+ const org = await this.store.updateOrg(orgId, updateParams);
2942
+ return { status: 200, body: { data: org } };
2943
+ } catch (err) {
2944
+ if (err instanceof OrgNotFoundError) {
2945
+ return { status: 404, body: { error: err.message } };
2946
+ }
2947
+ if (err instanceof OrgSlugTakenError) {
2948
+ return { status: 409, body: { error: err.message } };
2949
+ }
2950
+ throw err;
2951
+ }
2952
+ }
2953
+ /**
2954
+ * Delete an organization. Requires owner.
2955
+ */
2956
+ async deleteOrg(userId, orgId) {
2957
+ const authResult = await this.requireRole(orgId, userId, "owner");
2958
+ if (authResult) return authResult;
2959
+ try {
2960
+ await this.store.deleteOrg(orgId);
2961
+ return { status: 200, body: { data: { deleted: true } } };
2962
+ } catch (err) {
2963
+ if (err instanceof OrgNotFoundError) {
2964
+ return { status: 404, body: { error: err.message } };
2965
+ }
2966
+ throw err;
2967
+ }
2968
+ }
2969
+ /**
2970
+ * List all organizations the authenticated user belongs to.
2971
+ */
2972
+ async listUserOrgs(userId) {
2973
+ const orgs = await this.store.listUserOrgs(userId);
2974
+ return { status: 200, body: { data: orgs } };
2975
+ }
2976
+ // --- Members ---
2977
+ /**
2978
+ * Add a member to an organization. Requires admin or higher.
2979
+ */
2980
+ async addMember(userId, orgId, params) {
2981
+ const authResult = await this.requireRole(orgId, userId, "admin");
2982
+ if (authResult) return authResult;
2983
+ if (typeof params.targetUserId !== "string" || params.targetUserId.length === 0) {
2984
+ return { status: 400, body: { error: "Target user ID is required." } };
2985
+ }
2986
+ if (typeof params.role !== "string" || !isValidRole(params.role)) {
2987
+ return { status: 400, body: { error: "A valid role is required (admin, member, viewer, billing)." } };
2988
+ }
2989
+ if (params.role === "owner") {
2990
+ return { status: 400, body: { error: "Cannot assign owner role directly. Use ownership transfer." } };
2991
+ }
2992
+ const callerMembership = await this.store.getMembership(orgId, userId);
2993
+ if (callerMembership && callerMembership.role !== "owner" && params.role === "admin") {
2994
+ return { status: 403, body: { error: "Only the owner can add admin members." } };
2995
+ }
2996
+ try {
2997
+ const membership = await this.store.addMember(orgId, params.targetUserId, params.role, userId);
2998
+ return { status: 201, body: { data: membership } };
2999
+ } catch (err) {
3000
+ if (err instanceof OrgNotFoundError) {
3001
+ return { status: 404, body: { error: err.message } };
3002
+ }
3003
+ if (err instanceof MemberAlreadyExistsError) {
3004
+ return { status: 409, body: { error: err.message } };
3005
+ }
3006
+ throw err;
3007
+ }
3008
+ }
3009
+ /**
3010
+ * Remove a member from an organization. Requires admin or higher.
3011
+ * Members can also remove themselves (leave).
3012
+ */
3013
+ async removeMember(userId, orgId, targetUserId) {
3014
+ const isSelfRemoval = userId === targetUserId;
3015
+ if (!isSelfRemoval) {
3016
+ const authResult = await this.requireRole(orgId, userId, "admin");
3017
+ if (authResult) return authResult;
3018
+ } else {
3019
+ const membership = await this.store.getMembership(orgId, userId);
3020
+ if (!membership) {
3021
+ return { status: 404, body: { error: "Organization not found." } };
3022
+ }
3023
+ }
3024
+ try {
3025
+ await this.store.removeMember(orgId, targetUserId);
3026
+ return { status: 200, body: { data: { removed: true } } };
3027
+ } catch (err) {
3028
+ if (err instanceof OrgNotFoundError) {
3029
+ return { status: 404, body: { error: err.message } };
3030
+ }
3031
+ if (err instanceof CannotRemoveOwnerError) {
3032
+ return { status: 400, body: { error: err.message } };
3033
+ }
3034
+ if (err instanceof MembershipNotFoundError) {
3035
+ return { status: 404, body: { error: err.message } };
3036
+ }
3037
+ throw err;
3038
+ }
3039
+ }
3040
+ /**
3041
+ * Update a member's role. Requires admin or higher.
3042
+ */
3043
+ async updateMemberRole(userId, orgId, params) {
3044
+ const authResult = await this.requireRole(orgId, userId, "admin");
3045
+ if (authResult) return authResult;
3046
+ if (typeof params.targetUserId !== "string" || params.targetUserId.length === 0) {
3047
+ return { status: 400, body: { error: "Target user ID is required." } };
3048
+ }
3049
+ if (typeof params.role !== "string" || !isValidRole(params.role)) {
3050
+ return { status: 400, body: { error: "A valid role is required (admin, member, viewer, billing)." } };
3051
+ }
3052
+ if (params.role === "owner") {
3053
+ return { status: 400, body: { error: "Cannot assign owner role directly. Use ownership transfer." } };
3054
+ }
3055
+ const callerMembership = await this.store.getMembership(orgId, userId);
3056
+ if (callerMembership && callerMembership.role !== "owner" && params.role === "admin") {
3057
+ return { status: 403, body: { error: "Only the owner can assign admin role." } };
3058
+ }
3059
+ try {
3060
+ const membership = await this.store.updateMemberRole(orgId, params.targetUserId, params.role);
3061
+ return { status: 200, body: { data: membership } };
3062
+ } catch (err) {
3063
+ if (err instanceof MembershipNotFoundError) {
3064
+ return { status: 404, body: { error: err.message } };
3065
+ }
3066
+ throw err;
3067
+ }
3068
+ }
3069
+ /**
3070
+ * List all members of an organization. Requires membership.
3071
+ */
3072
+ async listMembers(userId, orgId) {
3073
+ const membership = await this.store.getMembership(orgId, userId);
3074
+ if (!membership) {
3075
+ return { status: 404, body: { error: "Organization not found." } };
3076
+ }
3077
+ try {
3078
+ const members = await this.store.listMembers(orgId);
3079
+ return { status: 200, body: { data: members } };
3080
+ } catch (err) {
3081
+ if (err instanceof OrgNotFoundError) {
3082
+ return { status: 404, body: { error: err.message } };
3083
+ }
3084
+ throw err;
3085
+ }
3086
+ }
3087
+ /**
3088
+ * Transfer ownership to another member. Requires owner.
3089
+ */
3090
+ async transferOwnership(userId, orgId, params) {
3091
+ const authResult = await this.requireRole(orgId, userId, "owner");
3092
+ if (authResult) return authResult;
3093
+ if (typeof params.newOwnerId !== "string" || params.newOwnerId.length === 0) {
3094
+ return { status: 400, body: { error: "New owner ID is required." } };
3095
+ }
3096
+ if (params.newOwnerId === userId) {
3097
+ return { status: 400, body: { error: "You are already the owner." } };
3098
+ }
3099
+ try {
3100
+ await this.store.transferOwnership(orgId, params.newOwnerId);
3101
+ return { status: 200, body: { data: { transferred: true } } };
3102
+ } catch (err) {
3103
+ if (err instanceof OrgNotFoundError) {
3104
+ return { status: 404, body: { error: err.message } };
3105
+ }
3106
+ if (err instanceof MembershipNotFoundError) {
3107
+ return { status: 404, body: { error: "Target user is not a member of this organization." } };
3108
+ }
3109
+ throw err;
3110
+ }
3111
+ }
3112
+ // --- Invitations ---
3113
+ /**
3114
+ * Create an invitation to join the organization. Requires admin or higher.
3115
+ */
3116
+ async createInvitation(userId, orgId, params) {
3117
+ const authResult = await this.requireRole(orgId, userId, "admin");
3118
+ if (authResult) return authResult;
3119
+ if (typeof params.email !== "string" || !isValidEmail2(params.email.trim())) {
3120
+ return { status: 400, body: { error: "A valid email address is required." } };
3121
+ }
3122
+ if (typeof params.role !== "string" || !isValidRole(params.role)) {
3123
+ return { status: 400, body: { error: "A valid role is required (admin, member, viewer, billing)." } };
3124
+ }
3125
+ if (params.role === "owner") {
3126
+ return { status: 400, body: { error: "Cannot invite with owner role. Use ownership transfer." } };
3127
+ }
3128
+ const callerMembership = await this.store.getMembership(orgId, userId);
3129
+ if (callerMembership && callerMembership.role !== "owner" && params.role === "admin") {
3130
+ return { status: 403, body: { error: "Only the owner can invite admin members." } };
3131
+ }
3132
+ try {
3133
+ const invitation = await this.store.createInvitation(orgId, userId, {
3134
+ email: params.email.trim(),
3135
+ role: params.role
3136
+ });
3137
+ return { status: 201, body: { data: invitation } };
3138
+ } catch (err) {
3139
+ if (err instanceof OrgNotFoundError) {
3140
+ return { status: 404, body: { error: err.message } };
3141
+ }
3142
+ throw err;
3143
+ }
3144
+ }
3145
+ /**
3146
+ * Accept an invitation by its token. The authenticated user joins the org.
3147
+ */
3148
+ async acceptInvitation(userId, params) {
3149
+ if (typeof params.token !== "string" || params.token.length === 0) {
3150
+ return { status: 400, body: { error: "Invitation token is required." } };
3151
+ }
3152
+ try {
3153
+ const invitation = await this.store.consumeInvitation(params.token);
3154
+ const membership = await this.store.addMember(
3155
+ invitation.orgId,
3156
+ userId,
3157
+ invitation.role,
3158
+ invitation.invitedBy
3159
+ );
3160
+ return { status: 200, body: { data: membership } };
3161
+ } catch (err) {
3162
+ if (err instanceof InvitationNotFoundError) {
3163
+ return { status: 404, body: { error: err.message } };
3164
+ }
3165
+ if (err instanceof InvitationExpiredError) {
3166
+ return { status: 410, body: { error: err.message } };
3167
+ }
3168
+ if (err instanceof MemberAlreadyExistsError) {
3169
+ return { status: 409, body: { error: "You are already a member of this organization." } };
3170
+ }
3171
+ throw err;
3172
+ }
3173
+ }
3174
+ /**
3175
+ * Revoke a pending invitation. Requires admin or higher.
3176
+ */
3177
+ async revokeInvitation(userId, orgId, invitationId) {
3178
+ const authResult = await this.requireRole(orgId, userId, "admin");
3179
+ if (authResult) return authResult;
3180
+ try {
3181
+ await this.store.revokeInvitation(invitationId);
3182
+ return { status: 200, body: { data: { revoked: true } } };
3183
+ } catch (err) {
3184
+ if (err instanceof InvitationNotFoundError) {
3185
+ return { status: 404, body: { error: err.message } };
3186
+ }
3187
+ throw err;
3188
+ }
3189
+ }
3190
+ /**
3191
+ * List pending invitations for an organization. Requires admin or higher.
3192
+ */
3193
+ async listPendingInvitations(userId, orgId) {
3194
+ const authResult = await this.requireRole(orgId, userId, "admin");
3195
+ if (authResult) return authResult;
3196
+ try {
3197
+ const invitations = await this.store.listPendingInvitations(orgId);
3198
+ return { status: 200, body: { data: invitations } };
3199
+ } catch (err) {
3200
+ if (err instanceof OrgNotFoundError) {
3201
+ return { status: 404, body: { error: err.message } };
3202
+ }
3203
+ throw err;
3204
+ }
3205
+ }
3206
+ /**
3207
+ * List pending invitations for the authenticated user's email.
3208
+ */
3209
+ async listMyInvitations(email) {
3210
+ if (!isValidEmail2(email)) {
3211
+ return { status: 400, body: { error: "A valid email address is required." } };
3212
+ }
3213
+ const invitations = await this.store.listInvitationsForEmail(email);
3214
+ return { status: 200, body: { data: invitations } };
3215
+ }
3216
+ // --- Private helpers ---
3217
+ /**
3218
+ * Check if the caller has the required role in the org.
3219
+ * Returns an error response if not authorized, or null if authorized.
3220
+ */
3221
+ async requireRole(orgId, userId, requiredRole) {
3222
+ const membership = await this.store.getMembership(orgId, userId);
3223
+ if (!membership) {
3224
+ return { status: 404, body: { error: "Organization not found." } };
3225
+ }
3226
+ if (!hasRoleLevel(membership.role, requiredRole)) {
3227
+ return {
3228
+ status: 403,
3229
+ body: { error: `This action requires at least the "${requiredRole}" role.` }
3230
+ };
3231
+ }
3232
+ return null;
3233
+ }
3234
+ };
3235
+ var ASSIGNABLE_ROLES = /* @__PURE__ */ new Set(["admin", "member", "viewer", "billing"]);
3236
+ function isValidRole(role) {
3237
+ return ASSIGNABLE_ROLES.has(role) || role === "owner";
3238
+ }
3239
+
3240
+ // src/org/org-store.ts
3241
+ var InMemoryOrgStore = class {
3242
+ orgs = /* @__PURE__ */ new Map();
3243
+ memberships = /* @__PURE__ */ new Map();
3244
+ invitations = /* @__PURE__ */ new Map();
3245
+ // --- Organizations ---
3246
+ async createOrg(ownerId, params) {
3247
+ const slug = params.slug ?? slugify(params.name);
3248
+ for (const org2 of this.orgs.values()) {
3249
+ if (org2.slug === slug) {
3250
+ throw new OrgSlugTakenError();
3251
+ }
3252
+ }
3253
+ const now = Date.now();
3254
+ const org = {
3255
+ id: generateId(),
3256
+ name: params.name,
3257
+ slug,
3258
+ ownerId,
3259
+ createdAt: now,
3260
+ updatedAt: now,
3261
+ metadata: params.metadata ?? {}
3262
+ };
3263
+ this.orgs.set(org.id, org);
3264
+ await this.addMember(org.id, ownerId, "owner", null);
3265
+ return org;
3266
+ }
3267
+ async getOrg(orgId) {
3268
+ return this.orgs.get(orgId) ?? null;
3269
+ }
3270
+ async getOrgBySlug(slug) {
3271
+ for (const org of this.orgs.values()) {
3272
+ if (org.slug === slug) return org;
3273
+ }
3274
+ return null;
3275
+ }
3276
+ async updateOrg(orgId, params) {
3277
+ const org = this.orgs.get(orgId);
3278
+ if (!org) throw new OrgNotFoundError();
3279
+ if (params.slug !== void 0 && params.slug !== org.slug) {
3280
+ for (const existing of this.orgs.values()) {
3281
+ if (existing.slug === params.slug && existing.id !== orgId) {
3282
+ throw new OrgSlugTakenError();
3283
+ }
3284
+ }
3285
+ }
3286
+ const updated = {
3287
+ ...org,
3288
+ name: params.name ?? org.name,
3289
+ slug: params.slug ?? org.slug,
3290
+ updatedAt: Date.now(),
3291
+ metadata: params.metadata ? { ...org.metadata, ...params.metadata } : org.metadata
3292
+ };
3293
+ this.orgs.set(orgId, updated);
3294
+ return updated;
3295
+ }
3296
+ async deleteOrg(orgId) {
3297
+ if (!this.orgs.has(orgId)) throw new OrgNotFoundError();
3298
+ for (const [id, membership] of this.memberships) {
3299
+ if (membership.orgId === orgId) {
3300
+ this.memberships.delete(id);
3301
+ }
3302
+ }
3303
+ for (const [id, invitation] of this.invitations) {
3304
+ if (invitation.orgId === orgId) {
3305
+ this.invitations.delete(id);
3306
+ }
3307
+ }
3308
+ this.orgs.delete(orgId);
3309
+ }
3310
+ async listUserOrgs(userId) {
3311
+ const orgIds = /* @__PURE__ */ new Set();
3312
+ for (const membership of this.memberships.values()) {
3313
+ if (membership.userId === userId) {
3314
+ orgIds.add(membership.orgId);
3315
+ }
3316
+ }
3317
+ const result = [];
3318
+ for (const orgId of orgIds) {
3319
+ const org = this.orgs.get(orgId);
3320
+ if (org) result.push(org);
3321
+ }
3322
+ return result;
3323
+ }
3324
+ // --- Memberships ---
3325
+ async addMember(orgId, userId, role, invitedBy) {
3326
+ if (!this.orgs.has(orgId)) throw new OrgNotFoundError();
3327
+ for (const membership2 of this.memberships.values()) {
3328
+ if (membership2.orgId === orgId && membership2.userId === userId) {
3329
+ throw new MemberAlreadyExistsError();
3330
+ }
3331
+ }
3332
+ const membership = {
3333
+ id: generateId(),
3334
+ orgId,
3335
+ userId,
3336
+ role,
3337
+ invitedBy,
3338
+ joinedAt: Date.now(),
3339
+ metadata: {}
3340
+ };
3341
+ this.memberships.set(membership.id, membership);
3342
+ return membership;
3343
+ }
3344
+ async removeMember(orgId, userId) {
3345
+ const org = this.orgs.get(orgId);
3346
+ if (!org) throw new OrgNotFoundError();
3347
+ if (org.ownerId === userId) {
3348
+ throw new CannotRemoveOwnerError();
3349
+ }
3350
+ let found = false;
3351
+ for (const [id, membership] of this.memberships) {
3352
+ if (membership.orgId === orgId && membership.userId === userId) {
3353
+ this.memberships.delete(id);
3354
+ found = true;
3355
+ break;
3356
+ }
3357
+ }
3358
+ if (!found) throw new MembershipNotFoundError();
3359
+ }
3360
+ async updateMemberRole(orgId, userId, role) {
3361
+ for (const [id, membership] of this.memberships) {
3362
+ if (membership.orgId === orgId && membership.userId === userId) {
3363
+ const updated = { ...membership, role };
3364
+ this.memberships.set(id, updated);
3365
+ if (role === "owner") {
3366
+ const org = this.orgs.get(orgId);
3367
+ if (org) {
3368
+ for (const [mId, m] of this.memberships) {
3369
+ if (m.orgId === orgId && m.userId === org.ownerId && m.userId !== userId) {
3370
+ this.memberships.set(mId, { ...m, role: "admin" });
3371
+ }
3372
+ }
3373
+ this.orgs.set(orgId, { ...org, ownerId: userId, updatedAt: Date.now() });
3374
+ }
3375
+ }
3376
+ return updated;
3377
+ }
3378
+ }
3379
+ throw new MembershipNotFoundError();
3380
+ }
3381
+ async listMembers(orgId) {
3382
+ if (!this.orgs.has(orgId)) throw new OrgNotFoundError();
3383
+ const result = [];
3384
+ for (const membership of this.memberships.values()) {
3385
+ if (membership.orgId === orgId) {
3386
+ result.push(membership);
3387
+ }
3388
+ }
3389
+ return result;
3390
+ }
3391
+ async getMembership(orgId, userId) {
3392
+ for (const membership of this.memberships.values()) {
3393
+ if (membership.orgId === orgId && membership.userId === userId) {
3394
+ return membership;
3395
+ }
3396
+ }
3397
+ return null;
3398
+ }
3399
+ async transferOwnership(orgId, newOwnerId) {
3400
+ const org = this.orgs.get(orgId);
3401
+ if (!org) throw new OrgNotFoundError();
3402
+ const membership = await this.getMembership(orgId, newOwnerId);
3403
+ if (!membership) throw new MembershipNotFoundError();
3404
+ await this.updateMemberRole(orgId, org.ownerId, "admin");
3405
+ await this.updateMemberRole(orgId, newOwnerId, "owner");
3406
+ }
3407
+ // --- Invitations ---
3408
+ async createInvitation(orgId, invitedBy, params) {
3409
+ if (!this.orgs.has(orgId)) throw new OrgNotFoundError();
3410
+ const now = Date.now();
3411
+ const invitation = {
3412
+ id: generateId(),
3413
+ orgId,
3414
+ email: params.email.toLowerCase().trim(),
3415
+ role: params.role,
3416
+ invitedBy,
3417
+ token: generateToken(),
3418
+ createdAt: now,
3419
+ expiresAt: now + 7 * 24 * 60 * 60 * 1e3,
3420
+ // 7 days
3421
+ status: "pending"
3422
+ };
3423
+ this.invitations.set(invitation.id, invitation);
3424
+ return invitation;
3425
+ }
3426
+ async getInvitationByToken(token) {
3427
+ for (const invitation of this.invitations.values()) {
3428
+ if (invitation.token === token && invitation.status === "pending") {
3429
+ return invitation;
3430
+ }
3431
+ }
3432
+ return null;
3433
+ }
3434
+ async consumeInvitation(token) {
3435
+ for (const [id, invitation] of this.invitations) {
3436
+ if (invitation.token === token) {
3437
+ if (invitation.status !== "pending") {
3438
+ throw new InvitationNotFoundError();
3439
+ }
3440
+ if (Date.now() > invitation.expiresAt) {
3441
+ this.invitations.set(id, { ...invitation, status: "expired" });
3442
+ throw new InvitationExpiredError();
3443
+ }
3444
+ const consumed = { ...invitation, status: "accepted" };
3445
+ this.invitations.set(id, consumed);
3446
+ return consumed;
3447
+ }
3448
+ }
3449
+ throw new InvitationNotFoundError();
3450
+ }
3451
+ async revokeInvitation(invitationId) {
3452
+ const invitation = this.invitations.get(invitationId);
3453
+ if (!invitation || invitation.status !== "pending") {
3454
+ throw new InvitationNotFoundError();
3455
+ }
3456
+ this.invitations.set(invitationId, { ...invitation, status: "revoked" });
3457
+ }
3458
+ async listPendingInvitations(orgId) {
3459
+ if (!this.orgs.has(orgId)) throw new OrgNotFoundError();
3460
+ const result = [];
3461
+ const now = Date.now();
3462
+ for (const invitation of this.invitations.values()) {
3463
+ if (invitation.orgId === orgId && invitation.status === "pending") {
3464
+ if (now > invitation.expiresAt) {
3465
+ continue;
3466
+ }
3467
+ result.push(invitation);
3468
+ }
3469
+ }
3470
+ return result;
3471
+ }
3472
+ async listInvitationsForEmail(email) {
3473
+ const normalizedEmail = email.toLowerCase().trim();
3474
+ const result = [];
3475
+ const now = Date.now();
3476
+ for (const invitation of this.invitations.values()) {
3477
+ if (invitation.email === normalizedEmail && invitation.status === "pending" && now <= invitation.expiresAt) {
3478
+ result.push(invitation);
3479
+ }
3480
+ }
3481
+ return result;
3482
+ }
3483
+ async cleanExpiredInvitations() {
3484
+ let count = 0;
3485
+ const now = Date.now();
3486
+ for (const [id, invitation] of this.invitations) {
3487
+ if (invitation.status === "pending" && now > invitation.expiresAt) {
3488
+ this.invitations.set(id, { ...invitation, status: "expired" });
3489
+ count++;
3490
+ }
3491
+ }
3492
+ return count;
3493
+ }
3494
+ };
3495
+ function generateId() {
3496
+ return globalThis.crypto.randomUUID();
3497
+ }
3498
+ function generateToken() {
3499
+ const bytes = new Uint8Array(32);
3500
+ globalThis.crypto.getRandomValues(bytes);
3501
+ let binary = "";
3502
+ for (let i = 0; i < bytes.length; i++) {
3503
+ binary += String.fromCharCode(bytes[i]);
3504
+ }
3505
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
3506
+ }
3507
+ function slugify(name) {
3508
+ return name.toLowerCase().trim().replace(/[^a-z0-9\s-]/g, "").replace(/[\s]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
3509
+ }
3510
+
3511
+ // src/rbac/rbac-types.ts
3512
+ import { KoraError as KoraError8 } from "@korajs/core";
3513
+ function parsePermission(permission) {
3514
+ const colonIndex = permission.indexOf(":");
3515
+ if (colonIndex === -1) {
3516
+ throw new InvalidPermissionError(permission);
3517
+ }
3518
+ const resource = permission.slice(0, colonIndex);
3519
+ const action = permission.slice(colonIndex + 1);
3520
+ if (resource.length === 0 || action.length === 0) {
3521
+ throw new InvalidPermissionError(permission);
3522
+ }
3523
+ return { resource, action };
3524
+ }
3525
+ function permissionCovers(granted, required) {
3526
+ const g = parsePermission(granted);
3527
+ const r = parsePermission(required);
3528
+ const resourceMatch = g.resource === "*" || g.resource === r.resource;
3529
+ const actionMatch = g.action === "*" || g.action === r.action;
3530
+ return resourceMatch && actionMatch;
3531
+ }
3532
+ var BUILT_IN_ROLES = [
3533
+ {
3534
+ name: "viewer",
3535
+ permissions: ["*:read"]
3536
+ },
3537
+ {
3538
+ name: "billing",
3539
+ permissions: ["org:billing"]
3540
+ },
3541
+ {
3542
+ name: "member",
3543
+ permissions: ["*:write", "*:delete"],
3544
+ inherits: ["viewer"]
3545
+ },
3546
+ {
3547
+ name: "admin",
3548
+ permissions: ["org:manage-members", "org:manage-settings", "org:manage-invitations"],
3549
+ inherits: ["member"]
3550
+ },
3551
+ {
3552
+ name: "owner",
3553
+ permissions: ["*:*"]
3554
+ }
3555
+ ];
3556
+ var RbacError = class extends KoraError8 {
3557
+ constructor(message, code, context) {
3558
+ super(message, code, context);
3559
+ this.name = "RbacError";
3560
+ }
3561
+ };
3562
+ var InvalidPermissionError = class extends RbacError {
3563
+ constructor(permission) {
3564
+ super(
3565
+ `Invalid permission format: "${permission}". Expected "resource:action".`,
3566
+ "INVALID_PERMISSION",
3567
+ { permission }
3568
+ );
3569
+ }
3570
+ };
3571
+ var RoleNotFoundError = class extends RbacError {
3572
+ constructor(role) {
3573
+ super(
3574
+ `Role "${role}" is not defined.`,
3575
+ "ROLE_NOT_FOUND",
3576
+ { role }
3577
+ );
3578
+ }
3579
+ };
3580
+ var CircularInheritanceError = class extends RbacError {
3581
+ constructor(chain) {
3582
+ super(
3583
+ `Circular role inheritance detected: ${chain.join(" \u2192 ")}.`,
3584
+ "CIRCULAR_INHERITANCE",
3585
+ { chain }
3586
+ );
3587
+ }
3588
+ };
3589
+
3590
+ // src/rbac/rbac-engine.ts
3591
+ var RbacEngine = class {
3592
+ orgStore;
3593
+ roleMap;
3594
+ resolvedPermissions = /* @__PURE__ */ new Map();
3595
+ collectionResolvers = /* @__PURE__ */ new Map();
3596
+ constructor(orgStore, config) {
3597
+ this.orgStore = orgStore;
3598
+ const roles = config?.roles ?? [...BUILT_IN_ROLES];
3599
+ this.roleMap = /* @__PURE__ */ new Map();
3600
+ for (const role of roles) {
3601
+ this.roleMap.set(role.name, role);
3602
+ }
3603
+ this.validateRoles();
3604
+ }
3605
+ // --- Permission Checks ---
3606
+ /**
3607
+ * Check if a user has a specific permission in an organization.
3608
+ *
3609
+ * Resolves the user's role from the org membership, then evaluates
3610
+ * the role's permissions (including inherited ones) against the required permission.
3611
+ *
3612
+ * Returns false (not an error) if the user is not a member.
3613
+ */
3614
+ async hasPermission(userId, orgId, permission) {
3615
+ const membership = await this.orgStore.getMembership(orgId, userId);
3616
+ if (!membership) return false;
3617
+ const rolePerms = this.getRolePermissions(membership.role);
3618
+ return rolePerms.some((granted) => permissionCovers(granted, permission));
3619
+ }
3620
+ /**
3621
+ * Get all effective permissions for a user in an organization.
3622
+ *
3623
+ * Returns an empty array if the user is not a member.
3624
+ */
3625
+ async getUserPermissions(userId, orgId) {
3626
+ const membership = await this.orgStore.getMembership(orgId, userId);
3627
+ if (!membership) return [];
3628
+ return this.getRolePermissions(membership.role);
3629
+ }
3630
+ /**
3631
+ * Get all effective permissions for a role name.
3632
+ * Includes permissions from inherited roles.
3633
+ *
3634
+ * @throws {RoleNotFoundError} if the role is not defined
3635
+ */
3636
+ getRolePermissions(roleName) {
3637
+ const cached = this.resolvedPermissions.get(roleName);
3638
+ if (cached) return cached;
3639
+ if (!this.roleMap.has(roleName)) {
3640
+ throw new RoleNotFoundError(roleName);
3641
+ }
3642
+ const perms = this.resolvePermissionsForRole(roleName, /* @__PURE__ */ new Set());
3643
+ this.resolvedPermissions.set(roleName, perms);
3644
+ return perms;
3645
+ }
3646
+ /**
3647
+ * Check if a role has a specific permission.
3648
+ */
3649
+ roleHasPermission(roleName, permission) {
3650
+ const perms = this.getRolePermissions(roleName);
3651
+ return perms.some((granted) => permissionCovers(granted, permission));
3652
+ }
3653
+ // --- Scope Resolution ---
3654
+ /**
3655
+ * Register a custom scope resolver for a collection.
3656
+ *
3657
+ * Custom resolvers override the default scope logic for that collection.
3658
+ */
3659
+ registerScopeResolver(collection, resolver) {
3660
+ this.collectionResolvers.set(collection, resolver);
3661
+ }
3662
+ /**
3663
+ * Resolve sync scopes for a user in an organization.
3664
+ *
3665
+ * The scopes determine what data the user can see and modify during sync.
3666
+ * - Owner/Admin: all org data
3667
+ * - Member: all org data (read + write)
3668
+ * - Viewer: all org data (read-only)
3669
+ * - Billing: no data scopes (billing-only access)
3670
+ *
3671
+ * Custom collection resolvers can override the defaults.
3672
+ *
3673
+ * @param collections - List of collection names to resolve scopes for.
3674
+ * If not provided, only custom-registered collections are included.
3675
+ */
3676
+ async resolveScopes(userId, orgId, collections) {
3677
+ const membership = await this.orgStore.getMembership(orgId, userId);
3678
+ if (!membership) return null;
3679
+ const permissions = this.getRolePermissions(membership.role);
3680
+ const ctx = {
3681
+ userId,
3682
+ orgId,
3683
+ role: membership.role,
3684
+ permissions
3685
+ };
3686
+ const scopes = {};
3687
+ const hasAnyRead = permissions.some(
3688
+ (p) => permissionCovers(p, "*:read")
3689
+ );
3690
+ if (!hasAnyRead) {
3691
+ return scopes;
3692
+ }
3693
+ const isReadOnly = !permissions.some(
3694
+ (p) => permissionCovers(p, "*:write")
3695
+ );
3696
+ const collectionsToResolve = collections ?? [...this.collectionResolvers.keys()];
3697
+ for (const collection of collectionsToResolve) {
3698
+ const customResolver = this.collectionResolvers.get(collection);
3699
+ if (customResolver) {
3700
+ const customScope = customResolver(ctx);
3701
+ if (customScope) {
3702
+ scopes[collection] = customScope;
3703
+ }
3704
+ continue;
3705
+ }
3706
+ const scope = { orgId };
3707
+ if (isReadOnly) {
3708
+ scope.__readonly = true;
3709
+ }
3710
+ scopes[collection] = scope;
3711
+ }
3712
+ return scopes;
3713
+ }
3714
+ // --- Role Management ---
3715
+ /**
3716
+ * Get all defined role names.
3717
+ */
3718
+ getRoleNames() {
3719
+ return [...this.roleMap.keys()];
3720
+ }
3721
+ /**
3722
+ * Get a role definition by name.
3723
+ */
3724
+ getRoleDefinition(roleName) {
3725
+ return this.roleMap.get(roleName) ?? null;
3726
+ }
3727
+ // --- Private ---
3728
+ /**
3729
+ * Validate all role definitions for circular inheritance and unknown references.
3730
+ */
3731
+ validateRoles() {
3732
+ for (const role of this.roleMap.values()) {
3733
+ if (role.inherits) {
3734
+ for (const parent of role.inherits) {
3735
+ if (!this.roleMap.has(parent)) {
3736
+ throw new RoleNotFoundError(parent);
3737
+ }
3738
+ }
3739
+ }
3740
+ }
3741
+ for (const role of this.roleMap.values()) {
3742
+ this.detectCircularInheritance(role.name, /* @__PURE__ */ new Set());
3743
+ }
3744
+ }
3745
+ /**
3746
+ * Detect circular inheritance in role hierarchy.
3747
+ */
3748
+ detectCircularInheritance(roleName, visited) {
3749
+ if (visited.has(roleName)) {
3750
+ throw new CircularInheritanceError([...visited, roleName]);
3751
+ }
3752
+ visited.add(roleName);
3753
+ const role = this.roleMap.get(roleName);
3754
+ if (role?.inherits) {
3755
+ for (const parent of role.inherits) {
3756
+ this.detectCircularInheritance(parent, new Set(visited));
3757
+ }
3758
+ }
3759
+ }
3760
+ /**
3761
+ * Recursively resolve all permissions for a role, including inherited permissions.
3762
+ */
3763
+ resolvePermissionsForRole(roleName, visited) {
3764
+ if (visited.has(roleName)) return [];
3765
+ visited.add(roleName);
3766
+ const role = this.roleMap.get(roleName);
3767
+ if (!role) return [];
3768
+ const perms = new Set(role.permissions);
3769
+ if (role.inherits) {
3770
+ for (const parent of role.inherits) {
3771
+ const parentPerms = this.resolvePermissionsForRole(parent, visited);
3772
+ for (const p of parentPerms) {
3773
+ perms.add(p);
3774
+ }
3775
+ }
3776
+ }
3777
+ return [...perms];
3778
+ }
3779
+ };
3780
+ function defineRoles() {
3781
+ return new RoleBuilder();
3782
+ }
3783
+ var RoleBuilder = class {
3784
+ roles = [];
3785
+ /**
3786
+ * Add a role definition.
3787
+ */
3788
+ role(name, permissions, options) {
3789
+ this.roles.push({
3790
+ name,
3791
+ permissions,
3792
+ inherits: options?.inherits
3793
+ });
3794
+ return this;
3795
+ }
3796
+ /**
3797
+ * Include the built-in roles as a base.
3798
+ */
3799
+ withBuiltInRoles() {
3800
+ this.roles = [...BUILT_IN_ROLES, ...this.roles];
3801
+ return this;
3802
+ }
3803
+ /**
3804
+ * Build and return the role definitions array.
3805
+ */
3806
+ build() {
3807
+ return [...this.roles];
3808
+ }
3809
+ };
3810
+
3811
+ // src/rbac/scope-resolver.ts
3812
+ var OrgScopeResolver = class {
3813
+ orgStore;
3814
+ rbac;
3815
+ collectionScopes = /* @__PURE__ */ new Map();
3816
+ constructor(orgStore, rbac) {
3817
+ this.orgStore = orgStore;
3818
+ this.rbac = rbac;
3819
+ }
3820
+ /**
3821
+ * Register a custom scope resolver for a collection.
3822
+ * This overrides the default orgId-based filtering for that collection.
3823
+ */
3824
+ registerCollectionScope(collection, resolver) {
3825
+ this.collectionScopes.set(collection, resolver);
3826
+ }
3827
+ /**
3828
+ * Resolve sync scopes for all specified collections.
3829
+ *
3830
+ * Returns null if the user is not a member of the organization.
3831
+ * Returns an empty object if the user has no data access (e.g., billing role).
3832
+ */
3833
+ async resolve(userId, orgId, collections) {
3834
+ const membership = await this.orgStore.getMembership(orgId, userId);
3835
+ if (!membership) return null;
3836
+ const permissions = this.rbac.getRolePermissions(membership.role);
3837
+ const ctx = {
3838
+ userId,
3839
+ orgId,
3840
+ role: membership.role,
3841
+ permissions
3842
+ };
3843
+ const scopes = {};
3844
+ for (const collection of collections) {
3845
+ const scope = this.resolveCollectionScope(ctx, collection);
3846
+ if (scope) {
3847
+ scopes[collection] = scope;
3848
+ }
3849
+ }
3850
+ return scopes;
3851
+ }
3852
+ /**
3853
+ * Check if a user can write to a specific collection in an org.
3854
+ */
3855
+ async canWrite(userId, orgId, collection) {
3856
+ return this.rbac.hasPermission(
3857
+ userId,
3858
+ orgId,
3859
+ `${collection}:write`
3860
+ );
3861
+ }
3862
+ /**
3863
+ * Check if a user can read from a specific collection in an org.
3864
+ */
3865
+ async canRead(userId, orgId, collection) {
3866
+ return this.rbac.hasPermission(
3867
+ userId,
3868
+ orgId,
3869
+ `${collection}:read`
3870
+ );
3871
+ }
3872
+ // --- Private ---
3873
+ resolveCollectionScope(ctx, collection) {
3874
+ const canRead = ctx.permissions.some(
3875
+ (p) => permissionCovers(p, `${collection}:read`) || permissionCovers(p, "*:read")
3876
+ );
3877
+ if (!canRead) return null;
3878
+ const customResolver = this.collectionScopes.get(collection);
3879
+ if (customResolver) {
3880
+ return customResolver(ctx);
3881
+ }
3882
+ const scope = { orgId: ctx.orgId };
3883
+ const canWrite = ctx.permissions.some(
3884
+ (p) => permissionCovers(p, `${collection}:write`) || permissionCovers(p, "*:write")
3885
+ );
3886
+ if (!canWrite) {
3887
+ scope.__readonly = true;
3888
+ }
3889
+ return scope;
3890
+ }
3891
+ };
3892
+
3893
+ // src/provider/oauth/oauth-types.ts
3894
+ import { KoraError as KoraError9 } from "@korajs/core";
3895
+ var OAuthError = class extends KoraError9 {
3896
+ constructor(message, code, context) {
3897
+ super(message, code, context);
3898
+ this.name = "OAuthError";
3899
+ }
3900
+ };
3901
+ var OAuthStateMismatchError = class extends OAuthError {
3902
+ constructor() {
3903
+ super("OAuth state parameter does not match. Possible CSRF attack.", "OAUTH_STATE_MISMATCH");
3904
+ }
3905
+ };
3906
+ var OAuthCodeExchangeError = class extends OAuthError {
3907
+ constructor(details) {
3908
+ super(
3909
+ `Failed to exchange authorization code for tokens.${details ? ` ${details}` : ""}`,
3910
+ "OAUTH_CODE_EXCHANGE_FAILED",
3911
+ details ? { details } : void 0
3912
+ );
3913
+ }
3914
+ };
3915
+ var OAuthUserInfoError = class extends OAuthError {
3916
+ constructor(details) {
3917
+ super(
3918
+ `Failed to fetch user info from OAuth provider.${details ? ` ${details}` : ""}`,
3919
+ "OAUTH_USER_INFO_FAILED",
3920
+ details ? { details } : void 0
3921
+ );
3922
+ }
3923
+ };
3924
+ var OAuthProviderNotFoundError = class extends OAuthError {
3925
+ constructor(provider) {
3926
+ super(`OAuth provider "${provider}" is not configured.`, "OAUTH_PROVIDER_NOT_FOUND", { provider });
3927
+ }
3928
+ };
3929
+
3930
+ // src/provider/oauth/oauth-flow.ts
3931
+ var DEFAULT_STATE_TTL_MS = 10 * 60 * 1e3;
3932
+ var InMemoryOAuthStateStore = class {
3933
+ states = /* @__PURE__ */ new Map();
3934
+ async store(state) {
3935
+ this.states.set(state.state, state);
3936
+ }
3937
+ async consume(stateValue) {
3938
+ const state = this.states.get(stateValue);
3939
+ if (!state) return null;
3940
+ this.states.delete(stateValue);
3941
+ if (Date.now() > state.expiresAt) return null;
3942
+ return state;
3943
+ }
3944
+ async cleanExpired() {
3945
+ const now = Date.now();
3946
+ let count = 0;
3947
+ for (const [key, state] of this.states) {
3948
+ if (now > state.expiresAt) {
3949
+ this.states.delete(key);
3950
+ count++;
3951
+ }
3952
+ }
3953
+ return count;
3954
+ }
3955
+ };
3956
+ var OAuthManager = class {
3957
+ providers = /* @__PURE__ */ new Map();
3958
+ stateStore;
3959
+ stateTtlMs;
3960
+ fetchFn;
3961
+ constructor(config) {
3962
+ for (const provider of config.providers) {
3963
+ this.providers.set(provider.providerId, provider);
3964
+ }
3965
+ this.stateStore = config.stateStore ?? new InMemoryOAuthStateStore();
3966
+ this.stateTtlMs = config.stateTtlMs ?? DEFAULT_STATE_TTL_MS;
3967
+ this.fetchFn = config.fetch ?? globalThis.fetch.bind(globalThis);
3968
+ }
3969
+ /**
3970
+ * Generate an authorization URL for the user to visit.
3971
+ * Returns the URL and the state parameter for CSRF validation.
3972
+ */
3973
+ async getAuthorizationUrl(providerId, metadata) {
3974
+ const provider = this.getProvider(providerId);
3975
+ const state = generateState();
3976
+ const now = Date.now();
3977
+ const oauthState = {
3978
+ state,
3979
+ provider: providerId,
3980
+ redirectUri: provider.redirectUri,
3981
+ createdAt: now,
3982
+ expiresAt: now + this.stateTtlMs,
3983
+ metadata
3984
+ };
3985
+ await this.stateStore.store(oauthState);
3986
+ const params = new URLSearchParams({
3987
+ client_id: provider.clientId,
3988
+ redirect_uri: provider.redirectUri,
3989
+ response_type: "code",
3990
+ scope: provider.scopes.join(" "),
3991
+ state
3992
+ });
3993
+ const url = `${provider.authorizationUrl}?${params.toString()}`;
3994
+ return { url, state };
3995
+ }
3996
+ /**
3997
+ * Handle the OAuth callback after the user authorizes.
3998
+ * Validates the state parameter, exchanges the code for tokens,
3999
+ * and fetches user info.
4000
+ *
4001
+ * @param providerId - The OAuth provider
4002
+ * @param code - The authorization code from the callback
4003
+ * @param state - The state parameter from the callback
4004
+ * @returns Tokens and user info from the provider
4005
+ */
4006
+ async handleCallback(providerId, code, state) {
4007
+ const provider = this.getProvider(providerId);
4008
+ const oauthState = await this.stateStore.consume(state);
4009
+ if (!oauthState || oauthState.provider !== providerId) {
4010
+ throw new OAuthStateMismatchError();
4011
+ }
4012
+ const tokens = await this.exchangeCodeForTokens(provider, code);
4013
+ const userInfo = await this.fetchUserInfo(provider, tokens.accessToken);
4014
+ return { tokens, userInfo, stateMetadata: oauthState.metadata };
4015
+ }
4016
+ /**
4017
+ * Get a registered provider by ID.
4018
+ */
4019
+ getProvider(providerId) {
4020
+ const provider = this.providers.get(providerId);
4021
+ if (!provider) {
4022
+ throw new OAuthProviderNotFoundError(providerId);
4023
+ }
4024
+ return provider;
4025
+ }
4026
+ /**
4027
+ * List all registered provider IDs.
4028
+ */
4029
+ getProviderIds() {
4030
+ return [...this.providers.keys()];
4031
+ }
4032
+ // --- Private ---
4033
+ async exchangeCodeForTokens(provider, code) {
4034
+ const body = new URLSearchParams({
4035
+ grant_type: "authorization_code",
4036
+ code,
4037
+ redirect_uri: provider.redirectUri,
4038
+ client_id: provider.clientId,
4039
+ client_secret: provider.clientSecret
4040
+ });
4041
+ let response;
4042
+ try {
4043
+ response = await this.fetchFn(provider.tokenUrl, {
4044
+ method: "POST",
4045
+ headers: {
4046
+ "Content-Type": "application/x-www-form-urlencoded",
4047
+ Accept: "application/json"
4048
+ },
4049
+ body: body.toString()
4050
+ });
4051
+ } catch (err) {
4052
+ throw new OAuthCodeExchangeError(
4053
+ err instanceof Error ? err.message : "Network error"
4054
+ );
4055
+ }
4056
+ if (!response.ok) {
4057
+ let details = `HTTP ${response.status}`;
4058
+ try {
4059
+ const errorBody = await response.text();
4060
+ details += `: ${errorBody}`;
4061
+ } catch {
4062
+ }
4063
+ throw new OAuthCodeExchangeError(details);
4064
+ }
4065
+ const data = await response.json();
4066
+ return {
4067
+ accessToken: data["access_token"],
4068
+ tokenType: data["token_type"] ?? "Bearer",
4069
+ expiresIn: data["expires_in"],
4070
+ refreshToken: data["refresh_token"],
4071
+ idToken: data["id_token"],
4072
+ scope: data["scope"]
4073
+ };
4074
+ }
4075
+ async fetchUserInfo(provider, accessToken) {
4076
+ let response;
4077
+ try {
4078
+ response = await this.fetchFn(provider.userInfoUrl, {
4079
+ headers: {
4080
+ Authorization: `Bearer ${accessToken}`,
4081
+ Accept: "application/json"
4082
+ }
4083
+ });
4084
+ } catch (err) {
4085
+ throw new OAuthUserInfoError(
4086
+ err instanceof Error ? err.message : "Network error"
4087
+ );
4088
+ }
4089
+ if (!response.ok) {
4090
+ throw new OAuthUserInfoError(`HTTP ${response.status}`);
4091
+ }
4092
+ const profile = await response.json();
4093
+ return normalizeUserInfo(provider.providerId, profile);
4094
+ }
4095
+ };
4096
+ function normalizeUserInfo(providerId, profile) {
4097
+ switch (providerId) {
4098
+ case "google":
4099
+ return {
4100
+ providerId: profile["sub"],
4101
+ provider: "google",
4102
+ email: profile["email"] ?? null,
4103
+ emailVerified: profile["email_verified"] ?? false,
4104
+ name: profile["name"] ?? null,
4105
+ avatarUrl: profile["picture"] ?? null,
4106
+ rawProfile: profile
4107
+ };
4108
+ case "github":
4109
+ return {
4110
+ providerId: String(profile["id"]),
4111
+ provider: "github",
4112
+ email: profile["email"] ?? null,
4113
+ emailVerified: false,
4114
+ // GitHub doesn't confirm in the profile response
4115
+ name: profile["name"] ?? profile["login"] ?? null,
4116
+ avatarUrl: profile["avatar_url"] ?? null,
4117
+ rawProfile: profile
4118
+ };
4119
+ case "microsoft":
4120
+ return {
4121
+ providerId: profile["id"],
4122
+ provider: "microsoft",
4123
+ email: profile["mail"] ?? profile["userPrincipalName"] ?? null,
4124
+ emailVerified: false,
4125
+ name: profile["displayName"] ?? null,
4126
+ avatarUrl: null,
4127
+ rawProfile: profile
4128
+ };
4129
+ default:
4130
+ return {
4131
+ providerId: String(profile["id"] ?? profile["sub"] ?? ""),
4132
+ provider: providerId,
4133
+ email: profile["email"] ?? null,
4134
+ emailVerified: profile["email_verified"] ?? false,
4135
+ name: profile["name"] ?? null,
4136
+ avatarUrl: profile["picture"] ?? profile["avatar_url"] ?? null,
4137
+ rawProfile: profile
4138
+ };
4139
+ }
4140
+ }
4141
+ function googleProvider(config) {
4142
+ return {
4143
+ providerId: "google",
4144
+ clientId: config.clientId,
4145
+ clientSecret: config.clientSecret,
4146
+ authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
4147
+ tokenUrl: "https://oauth2.googleapis.com/token",
4148
+ userInfoUrl: "https://openidconnect.googleapis.com/v1/userinfo",
4149
+ scopes: config.scopes ?? ["openid", "email", "profile"],
4150
+ redirectUri: config.redirectUri
4151
+ };
4152
+ }
4153
+ function githubProvider(config) {
4154
+ return {
4155
+ providerId: "github",
4156
+ clientId: config.clientId,
4157
+ clientSecret: config.clientSecret,
4158
+ authorizationUrl: "https://github.com/login/oauth/authorize",
4159
+ tokenUrl: "https://github.com/login/oauth/access_token",
4160
+ userInfoUrl: "https://api.github.com/user",
4161
+ scopes: config.scopes ?? ["read:user", "user:email"],
4162
+ redirectUri: config.redirectUri
4163
+ };
4164
+ }
4165
+ function microsoftProvider(config) {
4166
+ const tenant = config.tenantId ?? "common";
4167
+ return {
4168
+ providerId: "microsoft",
4169
+ clientId: config.clientId,
4170
+ clientSecret: config.clientSecret,
4171
+ authorizationUrl: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize`,
4172
+ tokenUrl: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`,
4173
+ userInfoUrl: "https://graph.microsoft.com/v1.0/me",
4174
+ scopes: config.scopes ?? ["openid", "email", "profile", "User.Read"],
4175
+ redirectUri: config.redirectUri
4176
+ };
4177
+ }
4178
+ function generateState() {
4179
+ const bytes = new Uint8Array(32);
4180
+ globalThis.crypto.getRandomValues(bytes);
4181
+ let binary = "";
4182
+ for (let i = 0; i < bytes.length; i++) {
4183
+ binary += String.fromCharCode(bytes[i]);
4184
+ }
4185
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
4186
+ }
4187
+
4188
+ // src/session/session.ts
4189
+ import { KoraError as KoraError10 } from "@korajs/core";
4190
+ var SessionError = class extends KoraError10 {
4191
+ constructor(message, code, context) {
4192
+ super(message, code, context);
4193
+ this.name = "SessionError";
4194
+ }
4195
+ };
4196
+ var SessionNotFoundError = class extends SessionError {
4197
+ constructor(sessionId) {
4198
+ super("Session not found or expired.", "SESSION_NOT_FOUND", { sessionId });
4199
+ }
4200
+ };
4201
+ var SessionExpiredError = class extends SessionError {
4202
+ constructor(sessionId) {
4203
+ super("Session has expired.", "SESSION_EXPIRED", { sessionId });
4204
+ }
4205
+ };
4206
+ var SessionLimitExceededError = class extends SessionError {
4207
+ constructor(userId, limit) {
4208
+ super(
4209
+ `Maximum concurrent sessions (${limit}) reached. Revoke an existing session first.`,
4210
+ "SESSION_LIMIT_EXCEEDED",
4211
+ { userId, limit }
4212
+ );
4213
+ }
4214
+ };
4215
+ var SessionMfaRequiredError = class extends SessionError {
4216
+ constructor(sessionId) {
4217
+ super("MFA verification is required for this session.", "SESSION_MFA_REQUIRED", { sessionId });
4218
+ }
4219
+ };
4220
+ var InMemorySessionStore = class {
4221
+ sessions = /* @__PURE__ */ new Map();
4222
+ async create(session) {
4223
+ this.sessions.set(session.id, { ...session });
4224
+ }
4225
+ async getById(sessionId) {
4226
+ const session = this.sessions.get(sessionId);
4227
+ return session ? { ...session } : null;
4228
+ }
4229
+ async update(session) {
4230
+ this.sessions.set(session.id, { ...session });
4231
+ }
4232
+ async delete(sessionId) {
4233
+ this.sessions.delete(sessionId);
4234
+ }
4235
+ async listByUserId(userId) {
4236
+ const results = [];
4237
+ for (const session of this.sessions.values()) {
4238
+ if (session.userId === userId) {
4239
+ results.push({ ...session });
4240
+ }
4241
+ }
4242
+ return results.sort((a, b) => b.lastActiveAt - a.lastActiveAt);
4243
+ }
4244
+ async deleteAllForUser(userId) {
4245
+ let count = 0;
4246
+ for (const [id, session] of this.sessions) {
4247
+ if (session.userId === userId) {
4248
+ this.sessions.delete(id);
4249
+ count++;
4250
+ }
4251
+ }
4252
+ return count;
4253
+ }
4254
+ async deleteAllExcept(userId, keepSessionId) {
4255
+ let count = 0;
4256
+ for (const [id, session] of this.sessions) {
4257
+ if (session.userId === userId && id !== keepSessionId) {
4258
+ this.sessions.delete(id);
4259
+ count++;
4260
+ }
4261
+ }
4262
+ return count;
4263
+ }
4264
+ async cleanExpired() {
4265
+ const now = Date.now();
4266
+ let count = 0;
4267
+ for (const [id, session] of this.sessions) {
4268
+ if (now > session.expiresAt) {
4269
+ this.sessions.delete(id);
4270
+ count++;
4271
+ }
4272
+ }
4273
+ return count;
4274
+ }
4275
+ };
4276
+ var DEFAULT_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
4277
+ var DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1e3;
4278
+ var DEFAULT_MAX_SESSIONS = 10;
4279
+ var SessionManager = class {
4280
+ store;
4281
+ sessionTtlMs;
4282
+ idleTimeoutMs;
4283
+ maxSessionsPerUser;
4284
+ slidingWindow;
4285
+ constructor(config) {
4286
+ this.store = config.store;
4287
+ this.sessionTtlMs = config.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS;
4288
+ this.idleTimeoutMs = config.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
4289
+ this.maxSessionsPerUser = config.maxSessionsPerUser ?? DEFAULT_MAX_SESSIONS;
4290
+ this.slidingWindow = config.slidingWindow ?? true;
4291
+ }
4292
+ /**
4293
+ * Create a new session for a user.
4294
+ * Enforces the maximum concurrent sessions limit.
4295
+ */
4296
+ async create(params) {
4297
+ const existing = await this.store.listByUserId(params.userId);
4298
+ const activeSessions = existing.filter((s) => Date.now() <= s.expiresAt);
4299
+ if (activeSessions.length >= this.maxSessionsPerUser) {
4300
+ throw new SessionLimitExceededError(params.userId, this.maxSessionsPerUser);
4301
+ }
4302
+ const now = Date.now();
4303
+ const session = {
4304
+ id: generateSessionId(),
4305
+ userId: params.userId,
4306
+ deviceId: params.deviceId ?? null,
4307
+ ipAddress: params.ipAddress ?? null,
4308
+ userAgent: params.userAgent ?? null,
4309
+ createdAt: now,
4310
+ lastActiveAt: now,
4311
+ expiresAt: now + this.sessionTtlMs,
4312
+ mfaVerified: params.mfaVerified ?? false,
4313
+ metadata: params.metadata
4314
+ };
4315
+ await this.store.create(session);
4316
+ return session;
4317
+ }
4318
+ /**
4319
+ * Validate a session.
4320
+ * Returns the session if valid, throws if not found or expired.
4321
+ */
4322
+ async validate(sessionId) {
4323
+ const session = await this.store.getById(sessionId);
4324
+ if (!session) {
4325
+ throw new SessionNotFoundError(sessionId);
4326
+ }
4327
+ const now = Date.now();
4328
+ if (now > session.expiresAt) {
4329
+ await this.store.delete(sessionId);
4330
+ throw new SessionExpiredError(sessionId);
4331
+ }
4332
+ if (now - session.lastActiveAt > this.idleTimeoutMs) {
4333
+ await this.store.delete(sessionId);
4334
+ throw new SessionExpiredError(sessionId);
4335
+ }
4336
+ return session;
4337
+ }
4338
+ /**
4339
+ * Touch a session to update its last activity time.
4340
+ * If sliding window is enabled, also extends the absolute expiry.
4341
+ */
4342
+ async touch(sessionId) {
4343
+ const session = await this.validate(sessionId);
4344
+ const now = Date.now();
4345
+ session.lastActiveAt = now;
4346
+ if (this.slidingWindow) {
4347
+ session.expiresAt = now + this.sessionTtlMs;
4348
+ }
4349
+ await this.store.update(session);
4350
+ return session;
4351
+ }
4352
+ /**
4353
+ * Mark a session as MFA-verified.
4354
+ */
4355
+ async markMfaVerified(sessionId) {
4356
+ const session = await this.validate(sessionId);
4357
+ session.mfaVerified = true;
4358
+ await this.store.update(session);
4359
+ return session;
4360
+ }
4361
+ /**
4362
+ * Require MFA verification on a session.
4363
+ * Throws SessionMfaRequiredError if MFA is not verified.
4364
+ */
4365
+ async requireMfa(sessionId) {
4366
+ const session = await this.validate(sessionId);
4367
+ if (!session.mfaVerified) {
4368
+ throw new SessionMfaRequiredError(sessionId);
4369
+ }
4370
+ return session;
4371
+ }
4372
+ /**
4373
+ * Revoke (delete) a session.
4374
+ */
4375
+ async revoke(sessionId) {
4376
+ await this.store.delete(sessionId);
4377
+ }
4378
+ /**
4379
+ * Revoke all sessions for a user (sign out everywhere).
4380
+ * Returns the number of sessions revoked.
4381
+ */
4382
+ async revokeAll(userId) {
4383
+ return this.store.deleteAllForUser(userId);
4384
+ }
4385
+ /**
4386
+ * Revoke all sessions for a user except the current one.
4387
+ * Returns the number of sessions revoked.
4388
+ */
4389
+ async revokeOthers(userId, currentSessionId) {
4390
+ return this.store.deleteAllExcept(userId, currentSessionId);
4391
+ }
4392
+ /**
4393
+ * List all active sessions for a user.
4394
+ */
4395
+ async listSessions(userId) {
4396
+ const sessions = await this.store.listByUserId(userId);
4397
+ const now = Date.now();
4398
+ return sessions.filter((s) => now <= s.expiresAt && now - s.lastActiveAt <= this.idleTimeoutMs);
4399
+ }
4400
+ /**
4401
+ * Clean up expired sessions.
4402
+ * Returns the number of sessions cleaned.
4403
+ */
4404
+ async cleanExpired() {
4405
+ return this.store.cleanExpired();
4406
+ }
4407
+ };
4408
+ function generateSessionId() {
4409
+ const bytes = new Uint8Array(32);
4410
+ globalThis.crypto.getRandomValues(bytes);
4411
+ let hex = "";
4412
+ for (let i = 0; i < bytes.length; i++) {
4413
+ hex += bytes[i].toString(16).padStart(2, "0");
4414
+ }
4415
+ return hex;
4416
+ }
4417
+
4418
+ // src/mfa/totp.ts
4419
+ import { KoraError as KoraError11 } from "@korajs/core";
4420
+ var TotpError = class extends KoraError11 {
4421
+ constructor(message, code, context) {
4422
+ super(message, code, context);
4423
+ this.name = "TotpError";
4424
+ }
4425
+ };
4426
+ var TotpInvalidCodeError = class extends TotpError {
4427
+ constructor() {
4428
+ super("Invalid TOTP code.", "TOTP_INVALID_CODE");
4429
+ }
4430
+ };
4431
+ var TotpNotEnabledError = class extends TotpError {
4432
+ constructor(userId) {
4433
+ super("TOTP MFA is not enabled for this user.", "TOTP_NOT_ENABLED", { userId });
4434
+ }
4435
+ };
4436
+ var TotpAlreadyEnabledError = class extends TotpError {
4437
+ constructor(userId) {
4438
+ super("TOTP MFA is already enabled for this user.", "TOTP_ALREADY_ENABLED", { userId });
4439
+ }
4440
+ };
4441
+ var TotpNotVerifiedError = class extends TotpError {
4442
+ constructor(userId) {
4443
+ super(
4444
+ "TOTP MFA setup is pending verification. Verify with a valid code first.",
4445
+ "TOTP_NOT_VERIFIED",
4446
+ { userId }
4447
+ );
4448
+ }
4449
+ };
4450
+ var TotpRecoveryExhaustedError = class extends TotpError {
4451
+ constructor() {
4452
+ super("All recovery codes have been used. Please regenerate.", "TOTP_RECOVERY_EXHAUSTED");
4453
+ }
4454
+ };
4455
+ var InMemoryTotpStore = class {
4456
+ secrets = /* @__PURE__ */ new Map();
4457
+ async save(secret) {
4458
+ this.secrets.set(secret.userId, secret);
4459
+ }
4460
+ async getByUserId(userId) {
4461
+ return this.secrets.get(userId) ?? null;
4462
+ }
4463
+ async delete(userId) {
4464
+ this.secrets.delete(userId);
4465
+ }
4466
+ };
4467
+ var DEFAULT_DIGITS = 6;
4468
+ var DEFAULT_PERIOD = 30;
4469
+ var DEFAULT_ALGORITHM = "SHA-1";
4470
+ var DEFAULT_WINDOW = 1;
4471
+ var DEFAULT_RECOVERY_CODES = 8;
4472
+ var RECOVERY_CODE_LENGTH = 10;
4473
+ var TotpManager = class {
4474
+ store;
4475
+ issuer;
4476
+ digits;
4477
+ period;
4478
+ algorithm;
4479
+ window;
4480
+ recoveryCodeCount;
4481
+ constructor(config) {
4482
+ this.store = config.store;
4483
+ this.issuer = config.issuer;
4484
+ this.digits = config.digits ?? DEFAULT_DIGITS;
4485
+ this.period = config.period ?? DEFAULT_PERIOD;
4486
+ this.algorithm = config.algorithm ?? DEFAULT_ALGORITHM;
4487
+ this.window = config.window ?? DEFAULT_WINDOW;
4488
+ this.recoveryCodeCount = config.recoveryCodes ?? DEFAULT_RECOVERY_CODES;
4489
+ }
4490
+ /**
4491
+ * Enable TOTP MFA for a user.
4492
+ * Returns the secret URI (for QR code) and recovery codes.
4493
+ * The user must verify setup with a valid code before MFA is active.
4494
+ */
4495
+ async enable(userId, accountName) {
4496
+ const existing = await this.store.getByUserId(userId);
4497
+ if (existing?.verified) {
4498
+ throw new TotpAlreadyEnabledError(userId);
4499
+ }
4500
+ const secretBytes = generateSecret(20);
4501
+ const secret = base32Encode(secretBytes);
4502
+ const recoveryCodes = generateRecoveryCodes(this.recoveryCodeCount, RECOVERY_CODE_LENGTH);
4503
+ const hashedCodes = await Promise.all(recoveryCodes.map((c) => hashRecoveryCode(c)));
4504
+ const totpSecret = {
4505
+ userId,
4506
+ secret,
4507
+ verified: false,
4508
+ recoveryCodes: hashedCodes,
4509
+ createdAt: Date.now(),
4510
+ verifiedAt: null
4511
+ };
4512
+ await this.store.save(totpSecret);
4513
+ const uri = buildOtpauthUri({
4514
+ issuer: this.issuer,
4515
+ accountName,
4516
+ secret,
4517
+ algorithm: this.algorithm,
4518
+ digits: this.digits,
4519
+ period: this.period
4520
+ });
4521
+ return { secret, uri, recoveryCodes };
4522
+ }
4523
+ /**
4524
+ * Verify the TOTP setup by confirming the user can generate a valid code.
4525
+ * Must be called after `enable()` and before MFA is enforced.
4526
+ */
4527
+ async verifySetup(userId, code) {
4528
+ const stored = await this.store.getByUserId(userId);
4529
+ if (!stored) {
4530
+ throw new TotpNotEnabledError(userId);
4531
+ }
4532
+ if (stored.verified) {
4533
+ return this.validateCode(stored.secret, code);
4534
+ }
4535
+ const valid = this.validateCode(stored.secret, code);
4536
+ if (!valid) {
4537
+ throw new TotpInvalidCodeError();
4538
+ }
4539
+ stored.verified = true;
4540
+ stored.verifiedAt = Date.now();
4541
+ await this.store.save(stored);
4542
+ return true;
4543
+ }
4544
+ /**
4545
+ * Verify a TOTP code during login.
4546
+ * Returns true if the code is valid, false otherwise.
4547
+ */
4548
+ async verify(userId, code) {
4549
+ const stored = await this.store.getByUserId(userId);
4550
+ if (!stored) {
4551
+ throw new TotpNotEnabledError(userId);
4552
+ }
4553
+ if (!stored.verified) {
4554
+ throw new TotpNotVerifiedError(userId);
4555
+ }
4556
+ return this.validateCode(stored.secret, code);
4557
+ }
4558
+ /**
4559
+ * Verify a recovery code as an alternative to TOTP.
4560
+ * Recovery codes are single-use.
4561
+ */
4562
+ async verifyRecoveryCode(userId, recoveryCode) {
4563
+ const stored = await this.store.getByUserId(userId);
4564
+ if (!stored) {
4565
+ throw new TotpNotEnabledError(userId);
4566
+ }
4567
+ if (!stored.verified) {
4568
+ throw new TotpNotVerifiedError(userId);
4569
+ }
4570
+ const hashed = await hashRecoveryCode(recoveryCode.trim());
4571
+ const index = stored.recoveryCodes.indexOf(hashed);
4572
+ if (index === -1) {
4573
+ return false;
4574
+ }
4575
+ stored.recoveryCodes.splice(index, 1);
4576
+ await this.store.save(stored);
4577
+ return true;
4578
+ }
4579
+ /**
4580
+ * Regenerate recovery codes. Requires a valid TOTP code for authorization.
4581
+ * Replaces all existing recovery codes.
4582
+ */
4583
+ async regenerateRecoveryCodes(userId, totpCode) {
4584
+ const stored = await this.store.getByUserId(userId);
4585
+ if (!stored) {
4586
+ throw new TotpNotEnabledError(userId);
4587
+ }
4588
+ if (!stored.verified) {
4589
+ throw new TotpNotVerifiedError(userId);
4590
+ }
4591
+ const valid = this.validateCode(stored.secret, totpCode);
4592
+ if (!valid) {
4593
+ throw new TotpInvalidCodeError();
4594
+ }
4595
+ const recoveryCodes = generateRecoveryCodes(this.recoveryCodeCount, RECOVERY_CODE_LENGTH);
4596
+ const hashedCodes = await Promise.all(recoveryCodes.map((c) => hashRecoveryCode(c)));
4597
+ stored.recoveryCodes = hashedCodes;
4598
+ await this.store.save(stored);
4599
+ return recoveryCodes;
4600
+ }
4601
+ /**
4602
+ * Disable TOTP MFA for a user.
4603
+ * Requires a valid TOTP code or recovery code for authorization.
4604
+ */
4605
+ async disable(userId, code) {
4606
+ const stored = await this.store.getByUserId(userId);
4607
+ if (!stored) {
4608
+ throw new TotpNotEnabledError(userId);
4609
+ }
4610
+ let authorized = false;
4611
+ if (stored.verified) {
4612
+ authorized = this.validateCode(stored.secret, code);
4613
+ }
4614
+ if (!authorized) {
4615
+ const hashed = await hashRecoveryCode(code.trim());
4616
+ authorized = stored.recoveryCodes.includes(hashed);
4617
+ }
4618
+ if (!authorized) {
4619
+ throw new TotpInvalidCodeError();
4620
+ }
4621
+ await this.store.delete(userId);
4622
+ }
4623
+ /**
4624
+ * Check if a user has TOTP MFA enabled and verified.
4625
+ */
4626
+ async isEnabled(userId) {
4627
+ const stored = await this.store.getByUserId(userId);
4628
+ return stored !== null && stored.verified;
4629
+ }
4630
+ /**
4631
+ * Get the number of remaining recovery codes for a user.
4632
+ */
4633
+ async remainingRecoveryCodes(userId) {
4634
+ const stored = await this.store.getByUserId(userId);
4635
+ if (!stored || !stored.verified) return 0;
4636
+ return stored.recoveryCodes.length;
4637
+ }
4638
+ // --- Private ---
4639
+ validateCode(base32Secret, code) {
4640
+ const secretBytes = base32Decode(base32Secret);
4641
+ const now = Math.floor(Date.now() / 1e3);
4642
+ for (let offset = -this.window; offset <= this.window; offset++) {
4643
+ const timeCounter = Math.floor((now + offset * this.period) / this.period);
4644
+ const expected = generateTotpCode(secretBytes, timeCounter, this.digits, this.algorithm);
4645
+ if (timingSafeEqual(code, expected)) {
4646
+ return true;
4647
+ }
4648
+ }
4649
+ return false;
4650
+ }
4651
+ };
4652
+ function generateTotpCode(secret, counter, digits, algorithm) {
4653
+ const counterBytes = new Uint8Array(8);
4654
+ let c = counter;
4655
+ for (let i = 7; i >= 0; i--) {
4656
+ counterBytes[i] = c & 255;
4657
+ c = Math.floor(c / 256);
4658
+ }
4659
+ const hash = hmacSha(algorithm, secret, counterBytes);
4660
+ const offset = hash[hash.length - 1] & 15;
4661
+ const binary = (hash[offset] & 127) << 24 | (hash[offset + 1] & 255) << 16 | (hash[offset + 2] & 255) << 8 | hash[offset + 3] & 255;
4662
+ const otp = binary % Math.pow(10, digits);
4663
+ return otp.toString().padStart(digits, "0");
4664
+ }
4665
+ function hmacSha(algorithm, key, message) {
4666
+ const blockSize = algorithm === "SHA-512" ? 128 : 64;
4667
+ let keyPad = key;
4668
+ if (keyPad.length > blockSize) {
4669
+ keyPad = sha1(keyPad);
4670
+ }
4671
+ const ipad = new Uint8Array(blockSize);
4672
+ const opad = new Uint8Array(blockSize);
4673
+ for (let i = 0; i < blockSize; i++) {
4674
+ const k = i < keyPad.length ? keyPad[i] : 0;
4675
+ ipad[i] = k ^ 54;
4676
+ opad[i] = k ^ 92;
4677
+ }
4678
+ const innerData = new Uint8Array(blockSize + message.length);
4679
+ innerData.set(ipad);
4680
+ innerData.set(message, blockSize);
4681
+ const innerHash = sha1(innerData);
4682
+ const outerData = new Uint8Array(blockSize + innerHash.length);
4683
+ outerData.set(opad);
4684
+ outerData.set(innerHash, blockSize);
4685
+ return sha1(outerData);
4686
+ }
4687
+ function sha1(data) {
4688
+ let h0 = 1732584193;
4689
+ let h1 = 4023233417;
4690
+ let h2 = 2562383102;
4691
+ let h3 = 271733878;
4692
+ let h4 = 3285377520;
4693
+ const bitLength = data.length * 8;
4694
+ const paddedLength = Math.ceil((data.length + 9) / 64) * 64;
4695
+ const padded = new Uint8Array(paddedLength);
4696
+ padded.set(data);
4697
+ padded[data.length] = 128;
4698
+ const view = new DataView(padded.buffer, padded.byteOffset);
4699
+ view.setUint32(paddedLength - 4, bitLength, false);
4700
+ const w = new Int32Array(80);
4701
+ for (let offset = 0; offset < paddedLength; offset += 64) {
4702
+ for (let i = 0; i < 16; i++) {
4703
+ w[i] = view.getInt32(offset + i * 4, false);
4704
+ }
4705
+ for (let i = 16; i < 80; i++) {
4706
+ w[i] = rotl32(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16] | 0, 1);
4707
+ }
4708
+ let a = h0;
4709
+ let b = h1;
4710
+ let c = h2;
4711
+ let d = h3;
4712
+ let e = h4;
4713
+ for (let i = 0; i < 80; i++) {
4714
+ let f;
4715
+ let k;
4716
+ if (i < 20) {
4717
+ f = b & c | ~b & d;
4718
+ k = 1518500249;
4719
+ } else if (i < 40) {
4720
+ f = b ^ c ^ d;
4721
+ k = 1859775393;
4722
+ } else if (i < 60) {
4723
+ f = b & c | b & d | c & d;
4724
+ k = 2400959708;
4725
+ } else {
4726
+ f = b ^ c ^ d;
4727
+ k = 3395469782;
4728
+ }
4729
+ const temp = rotl32(a, 5) + f + e + k + w[i] | 0;
4730
+ e = d;
4731
+ d = c;
4732
+ c = rotl32(b, 30);
4733
+ b = a;
4734
+ a = temp;
4735
+ }
4736
+ h0 = h0 + a | 0;
4737
+ h1 = h1 + b | 0;
4738
+ h2 = h2 + c | 0;
4739
+ h3 = h3 + d | 0;
4740
+ h4 = h4 + e | 0;
4741
+ }
4742
+ const result = new Uint8Array(20);
4743
+ const rv = new DataView(result.buffer);
4744
+ rv.setInt32(0, h0, false);
4745
+ rv.setInt32(4, h1, false);
4746
+ rv.setInt32(8, h2, false);
4747
+ rv.setInt32(12, h3, false);
4748
+ rv.setInt32(16, h4, false);
4749
+ return result;
4750
+ }
4751
+ function rotl32(value, shift) {
4752
+ return value << shift | value >>> 32 - shift | 0;
4753
+ }
4754
+ var BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
4755
+ function base32Encode(data) {
4756
+ let result = "";
4757
+ let bits = 0;
4758
+ let buffer = 0;
4759
+ for (let i = 0; i < data.length; i++) {
4760
+ buffer = buffer << 8 | data[i];
4761
+ bits += 8;
4762
+ while (bits >= 5) {
4763
+ bits -= 5;
4764
+ result += BASE32_ALPHABET[buffer >>> bits & 31];
4765
+ }
4766
+ }
4767
+ if (bits > 0) {
4768
+ result += BASE32_ALPHABET[buffer << 5 - bits & 31];
4769
+ }
4770
+ return result;
4771
+ }
4772
+ function base32Decode(encoded) {
4773
+ const cleaned = encoded.replace(/=+$/, "").toUpperCase();
4774
+ const output = [];
4775
+ let bits = 0;
4776
+ let buffer = 0;
4777
+ for (let i = 0; i < cleaned.length; i++) {
4778
+ const char = cleaned[i];
4779
+ const value = BASE32_ALPHABET.indexOf(char);
4780
+ if (value === -1) continue;
4781
+ buffer = buffer << 5 | value;
4782
+ bits += 5;
4783
+ if (bits >= 8) {
4784
+ bits -= 8;
4785
+ output.push(buffer >>> bits & 255);
4786
+ }
4787
+ }
4788
+ return new Uint8Array(output);
4789
+ }
4790
+ function generateSecret(byteLength) {
4791
+ const bytes = new Uint8Array(byteLength);
4792
+ globalThis.crypto.getRandomValues(bytes);
4793
+ return bytes;
4794
+ }
4795
+ function generateRecoveryCodes(count, length) {
4796
+ const codes = [];
4797
+ const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
4798
+ for (let i = 0; i < count; i++) {
4799
+ const bytes = new Uint8Array(length);
4800
+ globalThis.crypto.getRandomValues(bytes);
4801
+ let code = "";
4802
+ for (let j = 0; j < length; j++) {
4803
+ code += chars[bytes[j] % chars.length];
4804
+ }
4805
+ codes.push(`${code.slice(0, 5)}-${code.slice(5)}`);
4806
+ }
4807
+ return codes;
4808
+ }
4809
+ async function hashRecoveryCode(code) {
4810
+ const encoded = new TextEncoder().encode(code.toLowerCase().replace(/[-\s]/g, ""));
4811
+ const hash = await globalThis.crypto.subtle.digest("SHA-256", encoded);
4812
+ const bytes = new Uint8Array(hash);
4813
+ let hex = "";
4814
+ for (let i = 0; i < bytes.length; i++) {
4815
+ hex += bytes[i].toString(16).padStart(2, "0");
4816
+ }
4817
+ return hex;
4818
+ }
4819
+ function buildOtpauthUri(params) {
4820
+ const label = `${encodeURIComponent(params.issuer)}:${encodeURIComponent(params.accountName)}`;
4821
+ const query = new URLSearchParams({
4822
+ secret: params.secret,
4823
+ issuer: params.issuer,
4824
+ algorithm: params.algorithm.replace("-", ""),
4825
+ digits: params.digits.toString(),
4826
+ period: params.period.toString()
4827
+ });
4828
+ return `otpauth://totp/${label}?${query.toString()}`;
4829
+ }
4830
+ function timingSafeEqual(a, b) {
4831
+ if (a.length !== b.length) return false;
4832
+ let result = 0;
4833
+ for (let i = 0; i < a.length; i++) {
4834
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
4835
+ }
4836
+ return result === 0;
4837
+ }
4838
+
4839
+ // src/admin/admin-api.ts
4840
+ import { KoraError as KoraError12 } from "@korajs/core";
4841
+ var AdminApiError = class extends KoraError12 {
4842
+ constructor(message, code, context) {
4843
+ super(message, code, context);
4844
+ this.name = "AdminApiError";
4845
+ }
4846
+ };
4847
+ var AdminUserNotFoundError = class extends AdminApiError {
4848
+ constructor(userId) {
4849
+ super(`User "${userId}" not found.`, "ADMIN_USER_NOT_FOUND", { userId });
4850
+ }
4851
+ };
4852
+ var AdminUnauthorizedError = class extends AdminApiError {
4853
+ constructor() {
4854
+ super("Admin privileges required.", "ADMIN_UNAUTHORIZED");
4855
+ }
4856
+ };
4857
+ var AdminApi = class {
4858
+ userStore;
4859
+ sessionStore;
4860
+ auditLogger;
4861
+ constructor(config) {
4862
+ this.userStore = config.userStore;
4863
+ this.sessionStore = config.sessionStore ?? null;
4864
+ this.auditLogger = config.auditLogger ?? null;
4865
+ }
4866
+ /**
4867
+ * Get a user by ID with full details.
4868
+ */
4869
+ async getUser(adminId, userId) {
4870
+ const user = await this.userStore.findById(userId);
4871
+ if (!user) {
4872
+ throw new AdminUserNotFoundError(userId);
4873
+ }
4874
+ await this.audit("admin.user_lookup", adminId, userId, "user");
4875
+ return toAuthUser2(user);
4876
+ }
4877
+ /**
4878
+ * List users with optional filtering and pagination.
4879
+ */
4880
+ async listUsers(query = {}) {
4881
+ const limit = query.limit ?? 50;
4882
+ const offset = query.offset ?? 0;
4883
+ const allUsers = await this.userStore.listAll();
4884
+ let filtered = allUsers;
4885
+ if (query.email) {
4886
+ const searchEmail = query.email.toLowerCase();
4887
+ filtered = filtered.filter((u) => u.email.toLowerCase().includes(searchEmail));
4888
+ }
4889
+ if (query.emailVerified !== void 0) {
4890
+ filtered = filtered.filter((u) => u.emailVerified === query.emailVerified);
4891
+ }
4892
+ const total = filtered.length;
4893
+ const data = filtered.slice(offset, offset + limit).map(toAuthUser2);
4894
+ return { data, total, limit, offset };
4895
+ }
4896
+ /**
4897
+ * Update a user's profile (admin-level).
4898
+ */
4899
+ async updateUser(adminId, userId, updates) {
4900
+ const user = await this.userStore.findById(userId);
4901
+ if (!user) {
4902
+ throw new AdminUserNotFoundError(userId);
4903
+ }
4904
+ if (updates.name !== void 0) {
4905
+ user.name = updates.name;
4906
+ }
4907
+ if (updates.emailVerified !== void 0) {
4908
+ user.emailVerified = updates.emailVerified;
4909
+ }
4910
+ if (updates.email !== void 0) {
4911
+ user.email = updates.email.toLowerCase().trim();
4912
+ }
4913
+ await this.userStore.update(user);
4914
+ await this.audit("user.update", adminId, userId, "user", { updates });
4915
+ return toAuthUser2(user);
4916
+ }
4917
+ /**
4918
+ * Delete a user and all associated sessions.
4919
+ */
4920
+ async deleteUser(adminId, userId) {
4921
+ const user = await this.userStore.findById(userId);
4922
+ if (!user) {
4923
+ throw new AdminUserNotFoundError(userId);
4924
+ }
4925
+ if (this.sessionStore) {
4926
+ await this.sessionStore.deleteAllForUser(userId);
4927
+ }
4928
+ await this.userStore.delete(userId);
4929
+ await this.audit("user.delete", adminId, userId, "user");
4930
+ }
4931
+ /**
4932
+ * Get all active sessions for a user.
4933
+ */
4934
+ async getUserSessions(userId) {
4935
+ if (!this.sessionStore) return [];
4936
+ return this.sessionStore.listByUserId(userId);
4937
+ }
4938
+ /**
4939
+ * Revoke all sessions for a user.
4940
+ */
4941
+ async revokeUserSessions(adminId, userId) {
4942
+ if (!this.sessionStore) return 0;
4943
+ const count = await this.sessionStore.deleteAllForUser(userId);
4944
+ await this.audit("session.revoke_all", adminId, userId, "user", { sessionsRevoked: count });
4945
+ return count;
4946
+ }
4947
+ /**
4948
+ * Revoke a specific session.
4949
+ */
4950
+ async revokeSession(adminId, sessionId) {
4951
+ if (!this.sessionStore) return;
4952
+ await this.sessionStore.delete(sessionId);
4953
+ await this.audit("session.revoke", adminId, sessionId, "session");
4954
+ }
4955
+ /**
4956
+ * Get system statistics.
4957
+ */
4958
+ async getStats() {
4959
+ const allUsers = await this.userStore.listAll();
4960
+ const verified = allUsers.filter((u) => u.emailVerified).length;
4961
+ return {
4962
+ totalUsers: allUsers.length,
4963
+ verifiedUsers: verified,
4964
+ unverifiedUsers: allUsers.length - verified
4965
+ };
4966
+ }
4967
+ // --- Private ---
4968
+ async audit(action, actorId, targetId, targetType, metadata) {
4969
+ if (!this.auditLogger) return;
4970
+ await this.auditLogger.log({
4971
+ action,
4972
+ actorId,
4973
+ actorType: "admin",
4974
+ targetId,
4975
+ targetType,
4976
+ metadata
4977
+ });
4978
+ }
4979
+ };
4980
+ function toAuthUser2(stored) {
4981
+ return {
4982
+ id: stored.id,
4983
+ email: stored.email,
4984
+ name: stored.name,
4985
+ emailVerified: stored.emailVerified,
4986
+ createdAt: stored.createdAt
4987
+ };
4988
+ }
4989
+
4990
+ // src/admin/audit-log.ts
4991
+ import { KoraError as KoraError13 } from "@korajs/core";
4992
+ var AuditLogError = class extends KoraError13 {
4993
+ constructor(message, code, context) {
4994
+ super(message, code, context);
4995
+ this.name = "AuditLogError";
4996
+ }
4997
+ };
4998
+ var InMemoryAuditLogStore = class {
4999
+ entries = [];
5000
+ async append(entry) {
5001
+ this.entries.push({ ...entry });
5002
+ }
5003
+ async query(params) {
5004
+ let results = this.filterEntries(params);
5005
+ results.sort((a, b) => b.timestamp - a.timestamp);
5006
+ const offset = params.offset ?? 0;
5007
+ const limit = params.limit ?? 100;
5008
+ results = results.slice(offset, offset + limit);
5009
+ return results.map((e) => ({ ...e }));
5010
+ }
5011
+ async count(params) {
5012
+ return this.filterEntries(params).length;
5013
+ }
5014
+ async purgeOlderThan(timestamp) {
5015
+ const initialLength = this.entries.length;
5016
+ let writeIndex = 0;
5017
+ for (let i = 0; i < this.entries.length; i++) {
5018
+ if (this.entries[i].timestamp >= timestamp) {
5019
+ this.entries[writeIndex] = this.entries[i];
5020
+ writeIndex++;
5021
+ }
5022
+ }
5023
+ this.entries.length = writeIndex;
5024
+ return initialLength - writeIndex;
5025
+ }
5026
+ filterEntries(params) {
5027
+ return this.entries.filter((e) => {
5028
+ if (params.actorId !== void 0 && e.actorId !== params.actorId) return false;
5029
+ if (params.targetId !== void 0 && e.targetId !== params.targetId) return false;
5030
+ if (params.actions !== void 0 && !params.actions.includes(e.action)) return false;
5031
+ if (params.success !== void 0 && e.success !== params.success) return false;
5032
+ if (params.startTime !== void 0 && e.timestamp < params.startTime) return false;
5033
+ if (params.endTime !== void 0 && e.timestamp > params.endTime) return false;
5034
+ return true;
5035
+ });
5036
+ }
5037
+ };
5038
+ var AuditLogger = class {
5039
+ store;
5040
+ retentionMs;
5041
+ constructor(config) {
5042
+ this.store = config.store;
5043
+ this.retentionMs = config.retentionDays ? config.retentionDays * 24 * 60 * 60 * 1e3 : null;
5044
+ }
5045
+ /**
5046
+ * Log an audit event.
5047
+ */
5048
+ async log(params) {
5049
+ const entry = {
5050
+ id: generateAuditId(),
5051
+ timestamp: Date.now(),
5052
+ action: params.action,
5053
+ actorId: params.actorId,
5054
+ actorType: params.actorType ?? "user",
5055
+ targetId: params.targetId ?? null,
5056
+ targetType: params.targetType ?? null,
5057
+ ipAddress: params.ipAddress ?? null,
5058
+ userAgent: params.userAgent ?? null,
5059
+ success: params.success ?? true,
5060
+ errorMessage: params.errorMessage ?? null,
5061
+ metadata: params.metadata
5062
+ };
5063
+ await this.store.append(entry);
5064
+ return entry;
5065
+ }
5066
+ /**
5067
+ * Query audit log entries.
5068
+ */
5069
+ async query(params) {
5070
+ return this.store.query(params);
5071
+ }
5072
+ /**
5073
+ * Count matching audit entries.
5074
+ */
5075
+ async count(params) {
5076
+ return this.store.count(params);
5077
+ }
5078
+ /**
5079
+ * Purge old entries based on retention policy.
5080
+ * Returns the number of entries purged.
5081
+ */
5082
+ async purge() {
5083
+ if (this.retentionMs === null) return 0;
5084
+ const cutoff = Date.now() - this.retentionMs;
5085
+ return this.store.purgeOlderThan(cutoff);
5086
+ }
5087
+ /**
5088
+ * Get recent activity for a user.
5089
+ */
5090
+ async getUserActivity(userId, limit = 50) {
5091
+ return this.store.query({ actorId: userId, limit });
5092
+ }
5093
+ /**
5094
+ * Get failed login attempts for a user within a time window.
5095
+ */
5096
+ async getFailedLogins(userId, windowMs) {
5097
+ return this.store.query({
5098
+ targetId: userId,
5099
+ actions: ["user.signin"],
5100
+ success: false,
5101
+ startTime: Date.now() - windowMs
5102
+ });
5103
+ }
5104
+ };
5105
+ function generateAuditId() {
5106
+ const bytes = new Uint8Array(16);
5107
+ globalThis.crypto.getRandomValues(bytes);
5108
+ let hex = "";
5109
+ for (let i = 0; i < bytes.length; i++) {
5110
+ hex += bytes[i].toString(16).padStart(2, "0");
5111
+ }
5112
+ return hex;
5113
+ }
5114
+
5115
+ // src/admin/webhooks.ts
5116
+ import { KoraError as KoraError14 } from "@korajs/core";
5117
+ var WebhookError = class extends KoraError14 {
5118
+ constructor(message, code, context) {
5119
+ super(message, code, context);
5120
+ this.name = "WebhookError";
5121
+ }
5122
+ };
5123
+ var WebhookEndpointNotFoundError = class extends WebhookError {
5124
+ constructor(id) {
5125
+ super(`Webhook endpoint "${id}" not found.`, "WEBHOOK_ENDPOINT_NOT_FOUND", { id });
5126
+ }
5127
+ };
5128
+ var InMemoryWebhookStore = class {
5129
+ endpoints = /* @__PURE__ */ new Map();
5130
+ deliveries = [];
5131
+ async saveEndpoint(endpoint) {
5132
+ this.endpoints.set(endpoint.id, { ...endpoint });
5133
+ }
5134
+ async getEndpoint(id) {
5135
+ const ep = this.endpoints.get(id);
5136
+ return ep ? { ...ep } : null;
5137
+ }
5138
+ async listEndpoints() {
5139
+ return [...this.endpoints.values()].map((e) => ({ ...e }));
5140
+ }
5141
+ async deleteEndpoint(id) {
5142
+ this.endpoints.delete(id);
5143
+ }
5144
+ async saveDelivery(delivery) {
5145
+ const existing = this.deliveries.findIndex((d) => d.id === delivery.id);
5146
+ if (existing >= 0) {
5147
+ this.deliveries[existing] = { ...delivery };
5148
+ } else {
5149
+ this.deliveries.push({ ...delivery });
5150
+ }
5151
+ }
5152
+ async listDeliveries(endpointId, limit = 50) {
5153
+ return this.deliveries.filter((d) => d.endpointId === endpointId).sort((a, b) => b.createdAt - a.createdAt).slice(0, limit).map((d) => ({ ...d }));
5154
+ }
5155
+ };
5156
+ var MAX_RETRIES = 3;
5157
+ var RETRY_DELAYS_MS = [1e3, 5e3, 3e4];
5158
+ var WebhookManager = class {
5159
+ store;
5160
+ fetchFn;
5161
+ constructor(config) {
5162
+ this.store = config.store;
5163
+ this.fetchFn = config.fetch ?? globalThis.fetch.bind(globalThis);
5164
+ }
5165
+ /**
5166
+ * Register a new webhook endpoint.
5167
+ */
5168
+ async register(params) {
5169
+ const endpoint = {
5170
+ id: generateId2(),
5171
+ url: params.url,
5172
+ events: [...params.events],
5173
+ secret: generateSecret2(),
5174
+ active: true,
5175
+ createdAt: Date.now(),
5176
+ metadata: params.metadata
5177
+ };
5178
+ await this.store.saveEndpoint(endpoint);
5179
+ return endpoint;
5180
+ }
5181
+ /**
5182
+ * Update a webhook endpoint.
5183
+ */
5184
+ async update(id, updates) {
5185
+ const endpoint = await this.store.getEndpoint(id);
5186
+ if (!endpoint) {
5187
+ throw new WebhookEndpointNotFoundError(id);
5188
+ }
5189
+ if (updates.url !== void 0) endpoint.url = updates.url;
5190
+ if (updates.events !== void 0) endpoint.events = [...updates.events];
5191
+ if (updates.active !== void 0) endpoint.active = updates.active;
5192
+ await this.store.saveEndpoint(endpoint);
5193
+ return endpoint;
5194
+ }
5195
+ /**
5196
+ * Delete a webhook endpoint.
5197
+ */
5198
+ async remove(id) {
5199
+ await this.store.deleteEndpoint(id);
5200
+ }
5201
+ /**
5202
+ * List all webhook endpoints.
5203
+ */
5204
+ async list() {
5205
+ return this.store.listEndpoints();
5206
+ }
5207
+ /**
5208
+ * Get a specific endpoint.
5209
+ */
5210
+ async get(id) {
5211
+ const endpoint = await this.store.getEndpoint(id);
5212
+ if (!endpoint) {
5213
+ throw new WebhookEndpointNotFoundError(id);
5214
+ }
5215
+ return endpoint;
5216
+ }
5217
+ /**
5218
+ * Get recent deliveries for an endpoint.
5219
+ */
5220
+ async getDeliveries(endpointId, limit) {
5221
+ return this.store.listDeliveries(endpointId, limit);
5222
+ }
5223
+ /**
5224
+ * Dispatch an event to all matching webhook endpoints.
5225
+ * Delivery is best-effort with retries.
5226
+ */
5227
+ async dispatch(event, data) {
5228
+ const endpoints = await this.store.listEndpoints();
5229
+ const matching = endpoints.filter(
5230
+ (ep) => ep.active && ep.events.includes(event)
5231
+ );
5232
+ const payload = {
5233
+ id: generateId2(),
5234
+ event,
5235
+ timestamp: Date.now(),
5236
+ data
5237
+ };
5238
+ const payloadJson = JSON.stringify(payload);
5239
+ await Promise.allSettled(
5240
+ matching.map((ep) => this.deliverToEndpoint(ep, payloadJson, event))
5241
+ );
5242
+ }
5243
+ // --- Private ---
5244
+ async deliverToEndpoint(endpoint, payloadJson, event) {
5245
+ const signature = await signPayload(payloadJson, endpoint.secret);
5246
+ const delivery = {
5247
+ id: generateId2(),
5248
+ endpointId: endpoint.id,
5249
+ event,
5250
+ payload: payloadJson,
5251
+ responseStatus: null,
5252
+ success: false,
5253
+ error: null,
5254
+ attempts: 0,
5255
+ createdAt: Date.now(),
5256
+ lastAttemptAt: Date.now()
5257
+ };
5258
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
5259
+ delivery.attempts = attempt + 1;
5260
+ delivery.lastAttemptAt = Date.now();
5261
+ try {
5262
+ const response = await this.fetchFn(endpoint.url, {
5263
+ method: "POST",
5264
+ headers: {
5265
+ "Content-Type": "application/json",
5266
+ "X-Webhook-Signature": signature,
5267
+ "X-Webhook-Event": event,
5268
+ "X-Webhook-Delivery": delivery.id
5269
+ },
5270
+ body: payloadJson
5271
+ });
5272
+ delivery.responseStatus = response.status;
5273
+ delivery.success = response.ok;
5274
+ if (response.ok) {
5275
+ await this.store.saveDelivery(delivery);
5276
+ return;
5277
+ }
5278
+ delivery.error = `HTTP ${response.status}`;
5279
+ } catch (err) {
5280
+ delivery.error = err instanceof Error ? err.message : "Network error";
5281
+ }
5282
+ if (attempt < MAX_RETRIES - 1) {
5283
+ await delay(RETRY_DELAYS_MS[attempt] ?? 1e3);
5284
+ }
5285
+ }
5286
+ await this.store.saveDelivery(delivery);
5287
+ }
5288
+ };
5289
+ function generateId2() {
5290
+ const bytes = new Uint8Array(16);
5291
+ globalThis.crypto.getRandomValues(bytes);
5292
+ let hex = "";
5293
+ for (let i = 0; i < bytes.length; i++) {
5294
+ hex += bytes[i].toString(16).padStart(2, "0");
5295
+ }
5296
+ return hex;
5297
+ }
5298
+ function generateSecret2() {
5299
+ const bytes = new Uint8Array(32);
5300
+ globalThis.crypto.getRandomValues(bytes);
5301
+ let hex = "";
5302
+ for (let i = 0; i < bytes.length; i++) {
5303
+ hex += bytes[i].toString(16).padStart(2, "0");
5304
+ }
5305
+ return `whsec_${hex}`;
5306
+ }
5307
+ async function signPayload(payload, secret) {
5308
+ const encoder = new TextEncoder();
5309
+ const key = await globalThis.crypto.subtle.importKey(
5310
+ "raw",
5311
+ encoder.encode(secret),
5312
+ { name: "HMAC", hash: "SHA-256" },
5313
+ false,
5314
+ ["sign"]
5315
+ );
5316
+ const signature = await globalThis.crypto.subtle.sign(
5317
+ "HMAC",
5318
+ key,
5319
+ encoder.encode(payload)
5320
+ );
5321
+ const bytes = new Uint8Array(signature);
5322
+ let hex = "";
5323
+ for (let i = 0; i < bytes.length; i++) {
5324
+ hex += bytes[i].toString(16).padStart(2, "0");
5325
+ }
5326
+ return `sha256=${hex}`;
5327
+ }
5328
+ async function verifyWebhookSignature(payload, signature, secret) {
5329
+ const expected = await signPayload(payload, secret);
5330
+ if (expected.length !== signature.length) return false;
5331
+ let result = 0;
5332
+ for (let i = 0; i < expected.length; i++) {
5333
+ result |= expected.charCodeAt(i) ^ signature.charCodeAt(i);
5334
+ }
5335
+ return result === 0;
5336
+ }
5337
+ function delay(ms) {
5338
+ return new Promise((resolve) => setTimeout(resolve, ms));
5339
+ }
5340
+ export {
5341
+ AdminApi,
5342
+ AdminApiError,
5343
+ AdminUnauthorizedError,
5344
+ AdminUserNotFoundError,
5345
+ AuditLogError,
5346
+ AuditLogger,
5347
+ AuthProviderError,
5348
+ BUILT_IN_ROLES,
5349
+ BuiltInAuthRoutes,
5350
+ BuiltInProvider,
5351
+ CannotRemoveOwnerError,
5352
+ CircularInheritanceError,
5353
+ DuplicateEmailError,
5354
+ EmailVerificationError,
5355
+ EmailVerificationManager,
5356
+ ExternalAuthOperationNotSupportedError,
5357
+ ExternalJwtProvider,
5358
+ ExternalTokenValidationError,
5359
+ INVITATION_STATUSES,
5360
+ InMemoryAuditLogStore,
5361
+ InMemoryChallengeStore,
5362
+ InMemoryEmailVerificationStore,
5363
+ InMemoryOAuthStateStore,
5364
+ InMemoryOrgStore,
5365
+ InMemoryPasswordResetStore,
5366
+ InMemoryRateLimiter,
5367
+ InMemorySessionStore,
5368
+ InMemoryTokenRevocationStore,
5369
+ InMemoryTotpStore,
5370
+ InMemoryUserStore,
5371
+ InMemoryWebhookStore,
5372
+ InsufficientRoleError,
5373
+ InvalidPermissionError,
5374
+ InvitationExpiredError,
5375
+ InvitationNotFoundError,
5376
+ MemberAlreadyExistsError,
5377
+ MembershipNotFoundError,
5378
+ OAuthCodeExchangeError,
5379
+ OAuthError,
5380
+ OAuthManager,
5381
+ OAuthProviderNotFoundError,
5382
+ OAuthStateMismatchError,
5383
+ OAuthUserInfoError,
5384
+ ORG_ROLES,
5385
+ OperationEncryptionError,
5386
+ OperationEncryptor,
5387
+ OrgError,
5388
+ OrgNotFoundError,
5389
+ OrgRoutes,
5390
+ OrgScopeResolver,
5391
+ OrgSlugTakenError,
5392
+ PasskeyVerificationError,
5393
+ PasswordResetError,
5394
+ PasswordResetManager,
5395
+ PostgresUserStore,
5396
+ ROLE_HIERARCHY,
5397
+ RbacEngine,
5398
+ RbacError,
5399
+ ResetRateLimitedError,
5400
+ ResetTokenExpiredError,
5401
+ ResetTokenNotFoundError,
5402
+ RoleNotFoundError,
5403
+ SessionError,
5404
+ SessionExpiredError,
5405
+ SessionLimitExceededError,
5406
+ SessionManager,
5407
+ SessionMfaRequiredError,
5408
+ SessionNotFoundError,
5409
+ SqliteUserStore,
5410
+ TokenManager,
5411
+ TotpAlreadyEnabledError,
5412
+ TotpError,
5413
+ TotpInvalidCodeError,
5414
+ TotpManager,
5415
+ TotpNotEnabledError,
5416
+ TotpNotVerifiedError,
5417
+ TotpRecoveryExhaustedError,
5418
+ VerificationTokenExpiredError,
5419
+ VerificationTokenNotFoundError,
5420
+ WebhookEndpointNotFoundError,
5421
+ WebhookError,
5422
+ WebhookManager,
5423
+ base32Decode,
5424
+ base32Encode,
5425
+ computePublicKeyThumbprint,
5426
+ createClerkAdapter,
5427
+ createPostgresUserStore,
5428
+ createSqliteUserStore,
5429
+ createSupabaseAdapter,
5430
+ decodeJwt,
5431
+ defineRoles,
5432
+ encodeJwt,
5433
+ generateAuthenticationOptions,
5434
+ generateRegistrationOptions,
5435
+ githubProvider,
5436
+ googleProvider,
5437
+ hasRoleLevel,
5438
+ hashPassword,
5439
+ isEncryptedField,
5440
+ isExpired,
5441
+ microsoftProvider,
5442
+ parsePermission,
5443
+ permissionCovers,
5444
+ verifyAuthenticationResponse,
5445
+ verifyChallenge,
5446
+ verifyJwt,
5447
+ verifyPassword,
5448
+ verifyRegistrationResponse,
5449
+ verifyWebhookSignature
889
5450
  };
890
5451
  //# sourceMappingURL=server.js.map