@korajs/auth 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.cjs CHANGED
@@ -47,14 +47,8 @@ __export(password_hash_exports, {
47
47
  verifyPassword: () => verifyPassword
48
48
  });
49
49
  async function hashPassword(password) {
50
- const salt = (0, import_node_crypto3.randomBytes)(SALT_LENGTH);
51
- const derivedKey = await pbkdf2Async(
52
- password,
53
- salt,
54
- PBKDF2_ITERATIONS,
55
- KEY_LENGTH,
56
- PBKDF2_DIGEST
57
- );
50
+ const salt = (0, import_node_crypto.randomBytes)(SALT_LENGTH);
51
+ const derivedKey = await pbkdf2Async(password, salt, PBKDF2_ITERATIONS, KEY_LENGTH, PBKDF2_DIGEST);
58
52
  return {
59
53
  hash: derivedKey.toString("hex"),
60
54
  salt: salt.toString("hex")
@@ -73,11 +67,11 @@ async function verifyPassword(password, hash, salt) {
73
67
  if (derivedKey.length !== hashBuffer.length) {
74
68
  return false;
75
69
  }
76
- return (0, import_node_crypto3.timingSafeEqual)(derivedKey, hashBuffer);
70
+ return (0, import_node_crypto.timingSafeEqual)(derivedKey, hashBuffer);
77
71
  }
78
72
  function pbkdf2Async(password, salt, iterations, keyLength, digest) {
79
73
  return new Promise((resolve, reject) => {
80
- (0, import_node_crypto3.pbkdf2)(password, salt, iterations, keyLength, digest, (err, derivedKey) => {
74
+ (0, import_node_crypto.pbkdf2)(password, salt, iterations, keyLength, digest, (err, derivedKey) => {
81
75
  if (err) {
82
76
  reject(err);
83
77
  } else {
@@ -86,12 +80,12 @@ function pbkdf2Async(password, salt, iterations, keyLength, digest) {
86
80
  });
87
81
  });
88
82
  }
89
- var import_node_crypto3, PBKDF2_ITERATIONS, PBKDF2_DIGEST, KEY_LENGTH, SALT_LENGTH;
83
+ var import_node_crypto, PBKDF2_ITERATIONS, PBKDF2_DIGEST, KEY_LENGTH, SALT_LENGTH;
90
84
  var init_password_hash = __esm({
91
85
  "src/provider/built-in/password-hash.ts"() {
92
86
  "use strict";
93
87
  init_cjs_shims();
94
- import_node_crypto3 = require("crypto");
88
+ import_node_crypto = require("crypto");
95
89
  PBKDF2_ITERATIONS = 6e5;
96
90
  PBKDF2_DIGEST = "sha512";
97
91
  KEY_LENGTH = 64;
@@ -217,1403 +211,1156 @@ init_cjs_shims();
217
211
 
218
212
  // src/provider/built-in/auth-routes.ts
219
213
  init_cjs_shims();
220
- var import_node_crypto5 = require("crypto");
221
-
222
- // src/tokens/token-manager.ts
223
- init_cjs_shims();
224
214
  var import_node_crypto2 = require("crypto");
225
215
 
226
- // src/types.ts
216
+ // src/device/device-identity.ts
227
217
  init_cjs_shims();
228
218
  var import_core = require("@korajs/core");
229
- var DEFAULT_ACCESS_TOKEN_LIFETIME = 15 * 60 * 1e3;
230
- var DEFAULT_REFRESH_TOKEN_LIFETIME = 90 * 24 * 60 * 60 * 1e3;
231
- var DEFAULT_DEVICE_CREDENTIAL_LIFETIME = 90 * 24 * 60 * 60 * 1e3;
232
- var DEFAULT_MAX_OFFLINE_DURATION = 30 * 24 * 60 * 60 * 1e3;
233
- var DEFAULT_AUTO_LOCK_TIMEOUT = 15 * 60 * 1e3;
234
-
235
- // src/tokens/jwt.ts
236
- init_cjs_shims();
237
- var import_node_crypto = require("crypto");
238
- function base64urlEncode(input) {
239
- return Buffer.from(input, "utf-8").toString("base64url");
240
- }
241
- function base64urlDecode(input) {
242
- return Buffer.from(input, "base64url").toString("utf-8");
243
- }
244
- function hmacSha256Base64url(data, secret) {
245
- return (0, import_node_crypto.createHmac)("sha256", secret).update(data).digest("base64url");
246
- }
247
- var ENCODED_HEADER = base64urlEncode(JSON.stringify({ alg: "HS256", typ: "JWT" }));
248
- function encodeJwt(payload, secret) {
249
- const encodedPayload = base64urlEncode(JSON.stringify(payload));
250
- const signingInput = `${ENCODED_HEADER}.${encodedPayload}`;
251
- const signature = hmacSha256Base64url(signingInput, secret);
252
- return `${signingInput}.${signature}`;
253
- }
254
- function decodeJwt(token) {
255
- const parts = token.split(".");
256
- if (parts.length !== 3) {
257
- return null;
219
+ var CryptoUnavailableError = class extends import_core.KoraError {
220
+ constructor() {
221
+ super(
222
+ "Web Crypto API (crypto.subtle) is not available in this environment. Device identity requires crypto.subtle, which is available in modern browsers and Node.js 20+. If running in SSR, ensure your runtime provides the Web Crypto API.",
223
+ "CRYPTO_UNAVAILABLE"
224
+ );
225
+ this.name = "CryptoUnavailableError";
258
226
  }
259
- const payloadSegment = parts[1];
260
- if (payloadSegment === void 0) {
261
- return null;
227
+ };
228
+ var DeviceIdentityError = class extends import_core.KoraError {
229
+ constructor(message, context) {
230
+ super(message, "DEVICE_IDENTITY_ERROR", context);
231
+ this.name = "DeviceIdentityError";
262
232
  }
263
- try {
264
- const decoded = base64urlDecode(payloadSegment);
265
- const parsed = JSON.parse(decoded);
266
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
267
- return null;
268
- }
269
- return parsed;
270
- } catch {
271
- return null;
233
+ };
234
+ function toBase64Url(buffer) {
235
+ const bytes = new Uint8Array(buffer);
236
+ let binary = "";
237
+ for (let i = 0; i < bytes.length; i++) {
238
+ binary += String.fromCharCode(bytes[i]);
272
239
  }
240
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
273
241
  }
274
- function verifyJwt(token, secret) {
275
- const parts = token.split(".");
276
- if (parts.length !== 3) {
277
- return null;
278
- }
279
- const headerSegment = parts[0];
280
- const payloadSegment = parts[1];
281
- const signatureSegment = parts[2];
282
- if (headerSegment === void 0 || payloadSegment === void 0 || signatureSegment === void 0) {
283
- return null;
242
+ function fromBase64Url(str) {
243
+ let base64 = str.replace(/-/g, "+").replace(/_/g, "/");
244
+ const paddingNeeded = (4 - base64.length % 4) % 4;
245
+ base64 += "=".repeat(paddingNeeded);
246
+ const binary = atob(base64);
247
+ const bytes = new Uint8Array(binary.length);
248
+ for (let i = 0; i < binary.length; i++) {
249
+ bytes[i] = binary.charCodeAt(i);
284
250
  }
285
- if (headerSegment !== ENCODED_HEADER) {
286
- return null;
251
+ return bytes;
252
+ }
253
+ function assertCryptoAvailable() {
254
+ if (typeof globalThis.crypto === "undefined" || typeof globalThis.crypto.subtle === "undefined") {
255
+ throw new CryptoUnavailableError();
287
256
  }
288
- const signingInput = `${headerSegment}.${payloadSegment}`;
289
- const expectedSignature = hmacSha256Base64url(signingInput, secret);
290
- if (expectedSignature.length !== signatureSegment.length) {
291
- return null;
257
+ }
258
+ var ECDSA_ALGORITHM = {
259
+ name: "ECDSA",
260
+ namedCurve: "P-256"
261
+ };
262
+ var ECDSA_SIGN_ALGORITHM = {
263
+ name: "ECDSA",
264
+ hash: { name: "SHA-256" }
265
+ };
266
+ async function verifyChallenge(publicKeyJwk, challenge, signature) {
267
+ assertCryptoAvailable();
268
+ try {
269
+ const publicKey = await globalThis.crypto.subtle.importKey(
270
+ "jwk",
271
+ publicKeyJwk,
272
+ ECDSA_ALGORITHM,
273
+ true,
274
+ ["verify"]
275
+ );
276
+ const encoded = new TextEncoder().encode(challenge);
277
+ const signatureBytes = fromBase64Url(signature);
278
+ const isValid = await globalThis.crypto.subtle.verify(
279
+ ECDSA_SIGN_ALGORITHM,
280
+ publicKey,
281
+ signatureBytes,
282
+ encoded
283
+ );
284
+ return isValid;
285
+ } catch (cause) {
286
+ throw new DeviceIdentityError(
287
+ "Failed to verify challenge signature. The public key JWK or signature format may be invalid.",
288
+ {
289
+ cause: cause instanceof Error ? cause.message : String(cause),
290
+ publicKeyKty: publicKeyJwk.kty,
291
+ publicKeyCrv: publicKeyJwk.crv
292
+ }
293
+ );
292
294
  }
293
- let mismatch = 0;
294
- for (let i = 0; i < expectedSignature.length; i++) {
295
- mismatch |= expectedSignature.charCodeAt(i) ^ signatureSegment.charCodeAt(i);
295
+ }
296
+ async function computePublicKeyThumbprint(publicKeyJwk) {
297
+ assertCryptoAvailable();
298
+ if (publicKeyJwk.kty !== "EC") {
299
+ throw new DeviceIdentityError(
300
+ `Expected JWK key type "EC" but received "${publicKeyJwk.kty ?? "undefined"}". Only ECDSA public keys are supported for device identity.`,
301
+ { kty: publicKeyJwk.kty }
302
+ );
296
303
  }
297
- if (mismatch !== 0) {
298
- return null;
304
+ if (!publicKeyJwk.crv || !publicKeyJwk.x || !publicKeyJwk.y) {
305
+ throw new DeviceIdentityError(
306
+ 'JWK is missing required EC fields. An EC public key JWK must include "crv", "x", and "y" members.',
307
+ {
308
+ hasCrv: Boolean(publicKeyJwk.crv),
309
+ hasX: Boolean(publicKeyJwk.x),
310
+ hasY: Boolean(publicKeyJwk.y)
311
+ }
312
+ );
299
313
  }
314
+ const canonicalJson = JSON.stringify({
315
+ crv: publicKeyJwk.crv,
316
+ kty: publicKeyJwk.kty,
317
+ x: publicKeyJwk.x,
318
+ y: publicKeyJwk.y
319
+ });
300
320
  try {
301
- const decoded = base64urlDecode(payloadSegment);
302
- const parsed = JSON.parse(decoded);
303
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
304
- return null;
305
- }
306
- return parsed;
307
- } catch {
308
- return null;
309
- }
310
- }
311
- var CLOCK_SKEW_TOLERANCE_SECONDS = 5;
312
- function isExpired(payload) {
313
- if (typeof payload.exp !== "number") {
314
- return false;
321
+ const encoded = new TextEncoder().encode(canonicalJson);
322
+ const hashBuffer = await globalThis.crypto.subtle.digest("SHA-256", encoded);
323
+ return toBase64Url(hashBuffer);
324
+ } catch (cause) {
325
+ throw new DeviceIdentityError("Failed to compute SHA-256 thumbprint of the public key JWK.", {
326
+ cause: cause instanceof Error ? cause.message : String(cause)
327
+ });
315
328
  }
316
- const nowSeconds = Math.floor(Date.now() / 1e3);
317
- return nowSeconds >= payload.exp + CLOCK_SKEW_TOLERANCE_SECONDS;
318
329
  }
319
330
 
320
- // src/tokens/token-manager.ts
321
- var MIN_SECRET_LENGTH = 32;
322
- var InMemoryTokenRevocationStore = class {
323
- revokedTokens = /* @__PURE__ */ new Map();
324
- revokedDevices = /* @__PURE__ */ new Set();
325
- async isRevoked(jti) {
326
- return this.revokedTokens.has(jti);
327
- }
328
- async revoke(jti, expiresAt) {
329
- this.revokedTokens.set(jti, expiresAt);
330
- }
331
- async revokeAllForDevice(deviceId) {
332
- this.revokedDevices.add(deviceId);
331
+ // src/provider/built-in/auth-routes.ts
332
+ init_password_hash();
333
+ var InMemoryChallengeStore = class {
334
+ challenges = /* @__PURE__ */ new Map();
335
+ async store(challenge, deviceId, expiresAt) {
336
+ this.challenges.set(challenge, { deviceId, expiresAt });
333
337
  }
334
- /**
335
- * Check if a device has been revoked.
336
- */
337
- isDeviceRevoked(deviceId) {
338
- return this.revokedDevices.has(deviceId);
338
+ async consume(challenge) {
339
+ const entry = this.challenges.get(challenge);
340
+ if (entry === void 0) {
341
+ return null;
342
+ }
343
+ this.challenges.delete(challenge);
344
+ if (Date.now() > entry.expiresAt) {
345
+ return null;
346
+ }
347
+ return { deviceId: entry.deviceId };
339
348
  }
340
349
  /**
341
- * Remove expired revocations to prevent unbounded memory growth.
342
- * Call periodically (e.g., every hour) in long-running servers.
350
+ * Remove expired challenges to prevent unbounded memory growth.
343
351
  */
344
352
  cleanup() {
345
- const nowSeconds = Math.floor(Date.now() / 1e3);
346
- for (const [jti, expiresAt] of this.revokedTokens) {
347
- if (nowSeconds > expiresAt) {
348
- this.revokedTokens.delete(jti);
353
+ const now = Date.now();
354
+ for (const [challenge, entry] of this.challenges) {
355
+ if (now > entry.expiresAt) {
356
+ this.challenges.delete(challenge);
349
357
  }
350
358
  }
351
359
  }
352
360
  };
353
- var TokenManager = class {
354
- /** All signing/verification secrets (index 0 = current signing key) */
355
- secrets;
356
- accessTokenLifetime;
357
- refreshTokenLifetime;
358
- deviceCredentialLifetime;
359
- revocationStore;
360
- constructor(config) {
361
- const secrets = Array.isArray(config.secret) ? config.secret : [config.secret];
362
- if (secrets.length === 0) {
363
- throw new Error("TokenManager requires at least one secret.");
364
- }
365
- for (const secret of secrets) {
366
- if (secret.length < MIN_SECRET_LENGTH) {
367
- throw new Error(
368
- `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.`
369
- );
370
- }
371
- }
372
- this.secrets = secrets;
373
- this.accessTokenLifetime = config.accessTokenLifetime ?? DEFAULT_ACCESS_TOKEN_LIFETIME;
374
- this.refreshTokenLifetime = config.refreshTokenLifetime ?? DEFAULT_REFRESH_TOKEN_LIFETIME;
375
- this.deviceCredentialLifetime = config.deviceCredentialLifetime ?? DEFAULT_DEVICE_CREDENTIAL_LIFETIME;
376
- this.revocationStore = config.revocationStore;
377
- }
378
- /**
379
- * Generate a cryptographically random secret suitable for HMAC-SHA256 signing.
380
- *
381
- * Returns a 64-character hex string (32 bytes / 256 bits of entropy).
382
- * Store this securely (environment variable, secrets manager) — never in source code.
383
- *
384
- * @returns A random 256-bit hex-encoded secret
385
- */
386
- static generateSecret() {
387
- return (0, import_node_crypto2.randomBytes)(32).toString("hex");
388
- }
361
+ var InMemoryRateLimiter = class {
362
+ attempts = /* @__PURE__ */ new Map();
363
+ maxAttempts;
364
+ windowMs;
389
365
  /**
390
- * Issue a signed JWT access token.
391
- *
392
- * Access tokens are short-lived (default 15 minutes) and used to authorize
393
- * API requests. When expired, use {@link refreshAccessToken} with a valid
394
- * refresh token to obtain a new one.
395
- *
396
- * @param userId - The subject (user ID) to encode in the token
397
- * @param deviceId - The device ID of the requesting device
398
- * @returns A signed JWT string with type 'access'
366
+ * @param maxAttempts - Maximum number of attempts within the time window (default: 10)
367
+ * @param windowMs - Time window in milliseconds (default: 60,000 = 1 minute)
399
368
  */
400
- issueAccessToken(userId, deviceId) {
401
- const nowSeconds = Math.floor(Date.now() / 1e3);
402
- const payload = {
403
- jti: (0, import_node_crypto2.randomUUID)(),
404
- sub: userId,
405
- dev: deviceId,
406
- type: "access",
407
- iat: nowSeconds,
408
- exp: nowSeconds + Math.floor(this.accessTokenLifetime / 1e3)
409
- };
410
- return encodeJwt(payload, this.secrets[0]);
369
+ constructor(maxAttempts = 10, windowMs = 6e4) {
370
+ this.maxAttempts = maxAttempts;
371
+ this.windowMs = windowMs;
411
372
  }
412
- /**
413
- * Issue a signed JWT refresh token.
414
- *
415
- * Refresh tokens are longer-lived (default 90 days) and used exclusively
416
- * to obtain new access tokens via {@link refreshAccessToken}. They should
417
- * be stored securely and never sent to resource APIs.
418
- *
419
- * @param userId - The subject (user ID) to encode in the token
420
- * @param deviceId - The device ID of the requesting device
421
- * @returns A signed JWT string with type 'refresh'
422
- */
423
- issueRefreshToken(userId, deviceId) {
424
- const nowSeconds = Math.floor(Date.now() / 1e3);
425
- const payload = {
426
- jti: (0, import_node_crypto2.randomUUID)(),
427
- sub: userId,
428
- dev: deviceId,
429
- type: "refresh",
430
- iat: nowSeconds,
431
- exp: nowSeconds + Math.floor(this.refreshTokenLifetime / 1e3)
432
- };
433
- return encodeJwt(payload, this.secrets[0]);
373
+ async isAllowed(key) {
374
+ const now = Date.now();
375
+ const attempts = this.attempts.get(key) ?? [];
376
+ const recentAttempts = attempts.filter((t) => now - t < this.windowMs);
377
+ return recentAttempts.length < this.maxAttempts;
434
378
  }
435
- /**
436
- * Issue a signed device credential token.
437
- *
438
- * Device credentials are long-lived tokens bound to a device's public key.
439
- * They include a `mustCheckinBy` deadline; if the device does not check in
440
- * before this deadline, the credential should be treated as revoked.
441
- *
442
- * @param userId - The subject (user ID) to encode in the token
443
- * @param deviceId - The device ID of the requesting device
444
- * @param publicKeyThumbprint - SHA-256 thumbprint of the device's public key
445
- * @returns A signed JWT string with type 'device_credential'
446
- */
447
- issueDeviceCredential(userId, deviceId, publicKeyThumbprint) {
448
- const nowSeconds = Math.floor(Date.now() / 1e3);
449
- const lifetimeSeconds = Math.floor(this.deviceCredentialLifetime / 1e3);
450
- const payload = {
451
- jti: (0, import_node_crypto2.randomUUID)(),
452
- sub: userId,
453
- dev: deviceId,
454
- type: "device_credential",
455
- iat: nowSeconds,
456
- exp: nowSeconds + lifetimeSeconds,
457
- dpk: publicKeyThumbprint,
458
- mustCheckinBy: nowSeconds + lifetimeSeconds
459
- };
460
- return encodeJwt(payload, this.secrets[0]);
379
+ async record(key) {
380
+ const now = Date.now();
381
+ const attempts = this.attempts.get(key) ?? [];
382
+ const recentAttempts = attempts.filter((t) => now - t < this.windowMs);
383
+ recentAttempts.push(now);
384
+ this.attempts.set(key, recentAttempts);
461
385
  }
462
- /**
463
- * Issue a complete set of authentication tokens.
464
- *
465
- * Always issues an access token and refresh token. If a `publicKeyThumbprint`
466
- * is provided, also issues a device credential.
467
- *
468
- * @param userId - The subject (user ID) to encode in the tokens
469
- * @param deviceId - The device ID of the requesting device
470
- * @param publicKeyThumbprint - Optional SHA-256 thumbprint of the device's public key.
471
- * When provided, a device credential is included in the returned tokens.
472
- * @returns An {@link AuthTokens} object containing the issued tokens
473
- */
474
- issueTokens(userId, deviceId, publicKeyThumbprint) {
475
- const tokens = {
476
- accessToken: this.issueAccessToken(userId, deviceId),
477
- refreshToken: this.issueRefreshToken(userId, deviceId)
478
- };
479
- if (publicKeyThumbprint !== void 0) {
480
- tokens.deviceCredential = this.issueDeviceCredential(
481
- userId,
482
- deviceId,
483
- publicKeyThumbprint
484
- );
485
- }
486
- return tokens;
386
+ async reset(key) {
387
+ this.attempts.delete(key);
388
+ }
389
+ };
390
+ var MIN_PASSWORD_LENGTH = 8;
391
+ var MAX_PASSWORD_LENGTH = 128;
392
+ var MAX_NAME_LENGTH = 200;
393
+ var CHALLENGE_TTL_MS = 6e4;
394
+ function isValidEmail(email) {
395
+ if (email.length === 0 || email.length > 254) {
396
+ return false;
397
+ }
398
+ const atIndex = email.indexOf("@");
399
+ if (atIndex < 1) {
400
+ return false;
401
+ }
402
+ const domain = email.slice(atIndex + 1);
403
+ if (domain.length === 0 || !domain.includes(".")) {
404
+ return false;
405
+ }
406
+ if (email.indexOf("@", atIndex + 1) !== -1) {
407
+ return false;
408
+ }
409
+ if (email.includes(" ")) {
410
+ return false;
411
+ }
412
+ return true;
413
+ }
414
+ function sanitizeName(name) {
415
+ const cleaned = name.replace(/[\x00-\x1f\x7f]/g, "");
416
+ const trimmed = cleaned.trim();
417
+ if (trimmed.length > MAX_NAME_LENGTH) {
418
+ return trimmed.slice(0, MAX_NAME_LENGTH);
419
+ }
420
+ return trimmed;
421
+ }
422
+ var BuiltInAuthRoutes = class {
423
+ userStore;
424
+ tokenManager;
425
+ challengeStore;
426
+ rateLimiter;
427
+ constructor(config) {
428
+ this.userStore = config.userStore;
429
+ this.tokenManager = config.tokenManager;
430
+ this.challengeStore = config.challengeStore ?? new InMemoryChallengeStore();
431
+ this.rateLimiter = config.rateLimiter ?? new InMemoryRateLimiter();
487
432
  }
488
433
  /**
489
- * Validate and decode a token.
434
+ * Handle user sign-up (POST /auth/signup).
490
435
  *
491
- * Verifies the HMAC-SHA256 signature (trying all configured secrets for key rotation),
492
- * checks that the token has not expired, and validates all required claims.
493
- * Returns null (rather than throwing) for invalid or expired tokens, so callers
494
- * can handle authentication failure without try/catch.
436
+ * Validates email format and password length, hashes the password,
437
+ * creates the user, optionally registers a device, and issues tokens.
495
438
  *
496
- * @param token - The JWT string to validate
497
- * @returns The decoded {@link TokenPayload} if valid, or null if the token is
498
- * invalid, expired, or missing required claims
439
+ * @param body - Sign-up request body
440
+ * @param body.email - The user's email address
441
+ * @param body.password - The plaintext password (8-128 characters)
442
+ * @param body.name - Optional display name (defaults to email local part)
443
+ * @param body.deviceId - Optional device ID to register
444
+ * @param body.devicePublicKey - Optional device public key (base64url)
445
+ * @param clientIp - Optional client IP for rate limiting
446
+ * @returns Auth response with the created user and tokens, or an error
499
447
  */
500
- validateToken(token) {
501
- let decoded = null;
502
- for (const secret of this.secrets) {
503
- decoded = verifyJwt(token, secret);
504
- if (decoded !== null) {
505
- break;
506
- }
448
+ async handleSignUp(body, clientIp) {
449
+ const rateLimitKey = clientIp ?? "global";
450
+ if (!await this.rateLimiter.isAllowed(rateLimitKey)) {
451
+ return {
452
+ status: 429,
453
+ body: { error: "Too many requests. Please try again later." }
454
+ };
507
455
  }
508
- if (decoded === null) {
509
- return null;
456
+ await this.rateLimiter.record(rateLimitKey);
457
+ if (!isValidEmail(body.email)) {
458
+ return {
459
+ status: 400,
460
+ body: {
461
+ error: "Invalid email address. Please provide a valid email in the format user@domain.com."
462
+ }
463
+ };
510
464
  }
511
- if (isExpired(decoded)) {
512
- return null;
465
+ if (body.password.length < MIN_PASSWORD_LENGTH) {
466
+ return {
467
+ status: 400,
468
+ body: {
469
+ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long.`
470
+ }
471
+ };
513
472
  }
514
- 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") {
515
- return null;
473
+ if (body.password.length > MAX_PASSWORD_LENGTH) {
474
+ return {
475
+ status: 400,
476
+ body: {
477
+ error: `Password must be at most ${MAX_PASSWORD_LENGTH} characters long.`
478
+ }
479
+ };
516
480
  }
517
- const type = decoded["type"];
518
- if (type !== "access" && type !== "refresh" && type !== "device_credential") {
519
- return null;
481
+ const { hash, salt } = await hashPassword(body.password);
482
+ const rawName = body.name ?? body.email.split("@")[0] ?? body.email;
483
+ const name = sanitizeName(rawName);
484
+ let user;
485
+ try {
486
+ user = await this.userStore.createUser({
487
+ email: body.email,
488
+ passwordHash: hash,
489
+ salt,
490
+ name
491
+ });
492
+ } catch (err) {
493
+ if (err instanceof Error && err.name === "DuplicateEmailError") {
494
+ return {
495
+ status: 409,
496
+ body: { error: "An account with this email already exists." }
497
+ };
498
+ }
499
+ throw err;
520
500
  }
501
+ const deviceId = body.deviceId ?? `device-${user.id}`;
502
+ await this.userStore.registerDevice({
503
+ id: deviceId,
504
+ userId: user.id,
505
+ publicKey: body.devicePublicKey ?? "",
506
+ name: body.deviceId ? "Primary Device" : "Browser"
507
+ });
508
+ const tokens = this.tokenManager.issueTokens(user.id, deviceId);
521
509
  return {
522
- jti: decoded["jti"],
523
- sub: decoded["sub"],
524
- dev: decoded["dev"],
525
- type,
526
- iat: decoded["iat"],
527
- exp: decoded["exp"]
510
+ status: 201,
511
+ body: { data: { user, tokens } }
528
512
  };
529
513
  }
530
514
  /**
531
- * Validate a token and check it against the revocation store.
515
+ * Handle user sign-in (POST /auth/signin).
532
516
  *
533
- * Like {@link validateToken}, but also checks whether the token's `jti` has been
534
- * revoked. Requires a revocation store to be configured.
517
+ * Looks up the user by email, verifies the password, optionally registers
518
+ * a new device, and issues tokens.
535
519
  *
536
- * @param token - The JWT string to validate
537
- * @returns The decoded {@link TokenPayload} if valid and not revoked, or null otherwise
520
+ * @param body - Sign-in request body
521
+ * @param body.email - The user's email address
522
+ * @param body.password - The plaintext password
523
+ * @param body.deviceId - Optional device ID to register
524
+ * @param body.devicePublicKey - Optional device public key (base64url)
525
+ * @param clientIp - Optional client IP for rate limiting
526
+ * @returns Auth response with the user and tokens, or an error
538
527
  */
539
- async validateTokenWithRevocation(token) {
540
- const payload = this.validateToken(token);
541
- if (payload === null) {
542
- return null;
528
+ async handleSignIn(body, clientIp) {
529
+ const rateLimitKey = clientIp ? `signin:${body.email.toLowerCase()}:${clientIp}` : `signin:${body.email.toLowerCase()}`;
530
+ if (!await this.rateLimiter.isAllowed(rateLimitKey)) {
531
+ return {
532
+ status: 429,
533
+ body: { error: "Too many sign-in attempts. Please try again later." }
534
+ };
543
535
  }
544
- if (this.revocationStore) {
545
- const revoked = await this.revocationStore.isRevoked(payload.jti);
546
- if (revoked) {
547
- return null;
548
- }
536
+ await this.rateLimiter.record(rateLimitKey);
537
+ const storedUser = await this.userStore.findByEmail(body.email);
538
+ if (storedUser === null) {
539
+ return {
540
+ status: 401,
541
+ body: { error: "Invalid email or password." }
542
+ };
549
543
  }
550
- return payload;
544
+ const passwordValid = await verifyPassword(
545
+ body.password,
546
+ storedUser.passwordHash,
547
+ storedUser.salt
548
+ );
549
+ if (!passwordValid) {
550
+ return {
551
+ status: 401,
552
+ body: { error: "Invalid email or password." }
553
+ };
554
+ }
555
+ await this.rateLimiter.reset(rateLimitKey);
556
+ const deviceId = body.deviceId ?? `device-${storedUser.id}`;
557
+ await this.userStore.registerDevice({
558
+ id: deviceId,
559
+ userId: storedUser.id,
560
+ publicKey: body.devicePublicKey ?? "",
561
+ name: body.deviceId ? "Device" : "Browser"
562
+ });
563
+ const tokens = this.tokenManager.issueTokens(storedUser.id, deviceId);
564
+ const user = {
565
+ id: storedUser.id,
566
+ email: storedUser.email,
567
+ name: storedUser.name,
568
+ emailVerified: storedUser.emailVerified,
569
+ createdAt: storedUser.createdAt
570
+ };
571
+ return {
572
+ status: 200,
573
+ body: { data: { user, tokens } }
574
+ };
551
575
  }
552
576
  /**
553
- * Revoke a specific token by its JWT ID.
577
+ * Handle token refresh (POST /auth/refresh).
554
578
  *
555
- * Requires a revocation store to be configured. After revocation, the token
556
- * will be rejected by {@link validateTokenWithRevocation}.
579
+ * Validates the provided refresh token and issues a new token pair
580
+ * (refresh token rotation with reuse detection). The old refresh token
581
+ * is marked as consumed in the revocation store.
557
582
  *
558
- * @param jti - The JWT ID of the token to revoke
559
- * @param expiresAt - The token's expiration time (seconds since epoch)
583
+ * @param body - Refresh request body
584
+ * @param body.refreshToken - The current refresh token
585
+ * @returns Auth response with new tokens, or an error
560
586
  */
561
- async revokeToken(jti, expiresAt) {
562
- if (this.revocationStore) {
563
- await this.revocationStore.revoke(jti, expiresAt);
587
+ async handleRefresh(body) {
588
+ const result = await this.tokenManager.refreshAccessToken(body.refreshToken);
589
+ if (result === null) {
590
+ return {
591
+ status: 401,
592
+ body: { error: "Invalid or expired refresh token." }
593
+ };
564
594
  }
595
+ return {
596
+ status: 200,
597
+ body: { data: result }
598
+ };
565
599
  }
566
600
  /**
567
- * Revoke all tokens for a specific device.
601
+ * Handle sign-out (POST /auth/signout).
568
602
  *
569
- * Called when a device is revoked to ensure all its existing tokens
570
- * (access, refresh, and device credentials) are invalidated.
603
+ * Validates the access token and revokes the current refresh token
604
+ * (if a revocation store is configured). This ensures that stolen
605
+ * refresh tokens cannot be used after the user signs out.
571
606
  *
572
- * @param deviceId - The device ID whose tokens should be revoked
607
+ * @param accessToken - The JWT access token (without "Bearer " prefix)
608
+ * @param body - Sign-out request body
609
+ * @param body.refreshToken - The current refresh token to revoke
610
+ * @returns Auth response with success flag, or an error
573
611
  */
574
- async revokeDeviceTokens(deviceId) {
575
- if (this.revocationStore) {
576
- await this.revocationStore.revokeAllForDevice(deviceId);
612
+ async handleSignOut(accessToken, body) {
613
+ const payload = this.tokenManager.validateToken(accessToken);
614
+ if (payload === null || payload.type !== "access") {
615
+ return {
616
+ status: 401,
617
+ body: { error: "Invalid or expired access token." }
618
+ };
619
+ }
620
+ await this.tokenManager.revokeToken(payload.jti, payload.exp);
621
+ if (body.refreshToken) {
622
+ const refreshPayload = this.tokenManager.validateToken(body.refreshToken);
623
+ if (refreshPayload !== null && refreshPayload.type === "refresh") {
624
+ await this.tokenManager.revokeToken(refreshPayload.jti, refreshPayload.exp);
625
+ }
577
626
  }
627
+ return {
628
+ status: 200,
629
+ body: { data: { success: true } }
630
+ };
578
631
  }
579
632
  /**
580
- * Refresh an access token using a valid refresh token.
581
- *
582
- * Implements **refresh token rotation with reuse detection**: a new refresh token
583
- * is issued alongside the new access token. The old refresh token's `jti` is
584
- * recorded in the revocation store (if configured). If a previously consumed
585
- * refresh token is presented again, it indicates potential token theft.
633
+ * Handle get-current-user (GET /auth/me).
586
634
  *
587
- * Returns null if the provided token is invalid, expired, or not a refresh token.
635
+ * Validates the access token and returns the authenticated user's profile.
588
636
  *
589
- * @param refreshToken - The refresh token JWT string
590
- * @returns A new access/refresh token pair, or null if the refresh token is invalid
637
+ * @param accessToken - The JWT access token (without "Bearer " prefix)
638
+ * @returns Auth response with the user profile, or an error
591
639
  */
592
- async refreshAccessToken(refreshToken) {
593
- const payload = this.validateToken(refreshToken);
594
- if (payload === null) {
595
- return null;
596
- }
597
- if (payload.type !== "refresh") {
598
- return null;
640
+ async handleGetMe(accessToken) {
641
+ const payload = this.tokenManager.validateToken(accessToken);
642
+ if (payload === null || payload.type !== "access") {
643
+ return {
644
+ status: 401,
645
+ body: { error: "Invalid or expired access token." }
646
+ };
599
647
  }
600
- if (this.revocationStore) {
601
- const wasRevoked = await this.revocationStore.isRevoked(payload.jti);
602
- if (wasRevoked) {
603
- await this.revocationStore.revokeAllForDevice(payload.dev);
604
- return null;
605
- }
606
- await this.revocationStore.revoke(payload.jti, payload.exp);
648
+ const storedUser = await this.userStore.findById(payload.sub);
649
+ if (storedUser === null) {
650
+ return {
651
+ status: 404,
652
+ body: { error: "User not found." }
653
+ };
607
654
  }
655
+ const user = {
656
+ id: storedUser.id,
657
+ email: storedUser.email,
658
+ name: storedUser.name,
659
+ emailVerified: storedUser.emailVerified,
660
+ createdAt: storedUser.createdAt
661
+ };
608
662
  return {
609
- accessToken: this.issueAccessToken(payload.sub, payload.dev),
610
- refreshToken: this.issueRefreshToken(payload.sub, payload.dev)
663
+ status: 200,
664
+ body: { data: user }
611
665
  };
612
666
  }
613
- };
614
-
615
- // src/device/device-identity.ts
616
- init_cjs_shims();
617
- var import_core2 = require("@korajs/core");
618
- var CryptoUnavailableError = class extends import_core2.KoraError {
619
- constructor() {
620
- super(
621
- "Web Crypto API (crypto.subtle) is not available in this environment. Device identity requires crypto.subtle, which is available in modern browsers and Node.js 20+. If running in SSR, ensure your runtime provides the Web Crypto API.",
622
- "CRYPTO_UNAVAILABLE"
623
- );
624
- this.name = "CryptoUnavailableError";
625
- }
626
- };
627
- var DeviceIdentityError = class extends import_core2.KoraError {
628
- constructor(message, context) {
629
- super(message, "DEVICE_IDENTITY_ERROR", context);
630
- this.name = "DeviceIdentityError";
667
+ /**
668
+ * Handle list-devices (GET /auth/devices).
669
+ *
670
+ * Validates the access token and returns all devices registered for the user.
671
+ *
672
+ * @param accessToken - The JWT access token (without "Bearer " prefix)
673
+ * @returns Auth response with the device list, or an error
674
+ */
675
+ async handleListDevices(accessToken) {
676
+ const payload = this.tokenManager.validateToken(accessToken);
677
+ if (payload === null || payload.type !== "access") {
678
+ return {
679
+ status: 401,
680
+ body: { error: "Invalid or expired access token." }
681
+ };
682
+ }
683
+ const devices = await this.userStore.listDevices(payload.sub);
684
+ return {
685
+ status: 200,
686
+ body: { data: devices }
687
+ };
631
688
  }
632
- };
633
- function toBase64Url(buffer) {
634
- const bytes = new Uint8Array(buffer);
635
- let binary = "";
636
- for (let i = 0; i < bytes.length; i++) {
637
- binary += String.fromCharCode(bytes[i]);
689
+ /**
690
+ * Handle device revocation (DELETE /auth/device/:id).
691
+ *
692
+ * Validates the access token, revokes the specified device, and invalidates
693
+ * all tokens issued to that device. Only the device's owner can revoke it.
694
+ *
695
+ * @param accessToken - The JWT access token (without "Bearer " prefix)
696
+ * @param deviceId - The ID of the device to revoke
697
+ * @returns Auth response with success flag, or an error
698
+ */
699
+ async handleRevokeDevice(accessToken, deviceId) {
700
+ const payload = this.tokenManager.validateToken(accessToken);
701
+ if (payload === null || payload.type !== "access") {
702
+ return {
703
+ status: 401,
704
+ body: { error: "Invalid or expired access token." }
705
+ };
706
+ }
707
+ const device = await this.userStore.findDevice(deviceId);
708
+ if (device === null) {
709
+ return {
710
+ status: 404,
711
+ body: { error: "Device not found." }
712
+ };
713
+ }
714
+ if (device.userId !== payload.sub) {
715
+ return {
716
+ status: 403,
717
+ body: { error: "You can only revoke your own devices." }
718
+ };
719
+ }
720
+ await this.userStore.revokeDevice(deviceId);
721
+ await this.tokenManager.revokeDeviceTokens(deviceId);
722
+ return {
723
+ status: 200,
724
+ body: { data: { success: true } }
725
+ };
638
726
  }
639
- return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
640
- }
641
- function fromBase64Url(str) {
642
- let base64 = str.replace(/-/g, "+").replace(/_/g, "/");
643
- const paddingNeeded = (4 - base64.length % 4) % 4;
644
- base64 += "=".repeat(paddingNeeded);
645
- const binary = atob(base64);
646
- const bytes = new Uint8Array(binary.length);
647
- for (let i = 0; i < binary.length; i++) {
648
- bytes[i] = binary.charCodeAt(i);
649
- }
650
- return bytes;
651
- }
652
- function assertCryptoAvailable() {
653
- if (typeof globalThis.crypto === "undefined" || typeof globalThis.crypto.subtle === "undefined") {
654
- throw new CryptoUnavailableError();
655
- }
656
- }
657
- var ECDSA_ALGORITHM = {
658
- name: "ECDSA",
659
- namedCurve: "P-256"
660
- };
661
- var ECDSA_SIGN_ALGORITHM = {
662
- name: "ECDSA",
663
- hash: { name: "SHA-256" }
664
- };
665
- async function verifyChallenge(publicKeyJwk, challenge, signature) {
666
- assertCryptoAvailable();
667
- try {
668
- const publicKey = await globalThis.crypto.subtle.importKey(
669
- "jwk",
670
- publicKeyJwk,
671
- ECDSA_ALGORITHM,
672
- true,
673
- ["verify"]
674
- );
675
- const encoded = new TextEncoder().encode(challenge);
676
- const signatureBytes = fromBase64Url(signature);
677
- const isValid = await globalThis.crypto.subtle.verify(
678
- ECDSA_SIGN_ALGORITHM,
679
- publicKey,
680
- signatureBytes,
681
- encoded
682
- );
683
- return isValid;
684
- } catch (cause) {
685
- throw new DeviceIdentityError(
686
- "Failed to verify challenge signature. The public key JWK or signature format may be invalid.",
687
- {
688
- cause: cause instanceof Error ? cause.message : String(cause),
689
- publicKeyKty: publicKeyJwk.kty,
690
- publicKeyCrv: publicKeyJwk.crv
691
- }
692
- );
693
- }
694
- }
695
- async function computePublicKeyThumbprint(publicKeyJwk) {
696
- assertCryptoAvailable();
697
- if (publicKeyJwk.kty !== "EC") {
698
- throw new DeviceIdentityError(
699
- `Expected JWK key type "EC" but received "${publicKeyJwk.kty ?? "undefined"}". Only ECDSA public keys are supported for device identity.`,
700
- { kty: publicKeyJwk.kty }
701
- );
702
- }
703
- if (!publicKeyJwk.crv || !publicKeyJwk.x || !publicKeyJwk.y) {
704
- throw new DeviceIdentityError(
705
- 'JWK is missing required EC fields. An EC public key JWK must include "crv", "x", and "y" members.',
706
- {
707
- hasCrv: Boolean(publicKeyJwk.crv),
708
- hasX: Boolean(publicKeyJwk.x),
709
- hasY: Boolean(publicKeyJwk.y)
710
- }
711
- );
712
- }
713
- const canonicalJson = JSON.stringify({
714
- crv: publicKeyJwk.crv,
715
- kty: publicKeyJwk.kty,
716
- x: publicKeyJwk.x,
717
- y: publicKeyJwk.y
718
- });
719
- try {
720
- const encoded = new TextEncoder().encode(canonicalJson);
721
- const hashBuffer = await globalThis.crypto.subtle.digest("SHA-256", encoded);
722
- return toBase64Url(hashBuffer);
723
- } catch (cause) {
724
- throw new DeviceIdentityError(
725
- "Failed to compute SHA-256 thumbprint of the public key JWK.",
726
- { cause: cause instanceof Error ? cause.message : String(cause) }
727
- );
728
- }
729
- }
730
-
731
- // src/provider/built-in/auth-routes.ts
732
- init_password_hash();
733
-
734
- // src/provider/built-in/user-store.ts
735
- init_cjs_shims();
736
- var import_core3 = require("@korajs/core");
737
- var import_node_crypto4 = require("crypto");
738
- var DuplicateEmailError = class extends import_core3.KoraError {
739
- constructor() {
740
- super(
741
- "A user with this email already exists.",
742
- "DUPLICATE_EMAIL"
743
- );
744
- this.name = "DuplicateEmailError";
745
- }
746
- };
747
- var InMemoryUserStore = class {
748
- /** Users indexed by ID */
749
- usersById = /* @__PURE__ */ new Map();
750
- /** Users indexed by email (lowercase) for fast lookup */
751
- usersByEmail = /* @__PURE__ */ new Map();
752
- /** Devices indexed by device ID */
753
- devicesById = /* @__PURE__ */ new Map();
754
- /** Device IDs indexed by user ID for fast listing */
755
- devicesByUserId = /* @__PURE__ */ new Map();
756
727
  /**
757
- * Create a new user account.
728
+ * Handle device registration (POST /auth/device/register).
758
729
  *
759
- * @param params - User creation parameters
760
- * @param params.email - The user's email address (must be unique, case-insensitive)
761
- * @param params.passwordHash - Hex-encoded PBKDF2 derived key
762
- * @param params.salt - Hex-encoded salt used during hashing
763
- * @param params.name - The user's display name
764
- * @returns The created user (without sensitive credential fields)
765
- * @throws {DuplicateEmailError} If a user with the same email already exists
730
+ * Requires a valid access token. Registers a new device for the authenticated
731
+ * user and issues a device credential token bound to the device's public key.
732
+ *
733
+ * @param accessToken - The JWT access token (without "Bearer " prefix)
734
+ * @param body - Device registration request body
735
+ * @param body.deviceId - Unique identifier for the device
736
+ * @param body.publicKey - The device's public key as a JWK JSON string
737
+ * @param body.name - Human-readable device name (e.g., "Chrome on MacBook")
738
+ * @returns Auth response with the registered device and device credential, or an error
766
739
  */
767
- async createUser(params) {
768
- const normalizedEmail = params.email.toLowerCase();
769
- if (this.usersByEmail.has(normalizedEmail)) {
770
- throw new DuplicateEmailError();
740
+ async handleDeviceRegister(accessToken, body) {
741
+ const payload = this.tokenManager.validateToken(accessToken);
742
+ if (payload === null || payload.type !== "access") {
743
+ return {
744
+ status: 401,
745
+ body: { error: "Invalid or expired access token." }
746
+ };
771
747
  }
772
- const now = Date.now();
773
- const id = (0, import_node_crypto4.randomUUID)();
774
- const storedUser = {
775
- id,
776
- email: normalizedEmail,
777
- name: params.name,
778
- emailVerified: false,
779
- createdAt: now,
780
- passwordHash: params.passwordHash,
781
- salt: params.salt
748
+ const deviceName = sanitizeName(body.name);
749
+ if (deviceName.length === 0) {
750
+ return {
751
+ status: 400,
752
+ body: { error: "Device name must not be empty." }
753
+ };
754
+ }
755
+ let publicKeyJwk;
756
+ try {
757
+ publicKeyJwk = JSON.parse(body.publicKey);
758
+ } catch {
759
+ return {
760
+ status: 400,
761
+ body: { error: "Invalid public key format. Expected a JSON-encoded JWK string." }
762
+ };
763
+ }
764
+ let thumbprint;
765
+ try {
766
+ thumbprint = await computePublicKeyThumbprint(publicKeyJwk);
767
+ } catch {
768
+ return {
769
+ status: 400,
770
+ body: {
771
+ error: "Failed to compute public key thumbprint. Ensure the key is a valid EC P-256 JWK."
772
+ }
773
+ };
774
+ }
775
+ const device = await this.userStore.registerDevice({
776
+ id: body.deviceId,
777
+ userId: payload.sub,
778
+ publicKey: body.publicKey,
779
+ name: deviceName
780
+ });
781
+ const deviceCredential = this.tokenManager.issueDeviceCredential(
782
+ payload.sub,
783
+ body.deviceId,
784
+ thumbprint
785
+ );
786
+ return {
787
+ status: 201,
788
+ body: { data: { device, deviceCredential } }
782
789
  };
783
- this.usersById.set(id, storedUser);
784
- this.usersByEmail.set(normalizedEmail, storedUser);
785
- return toAuthUser(storedUser);
786
790
  }
787
791
  /**
788
- * Find a user by email address.
792
+ * Generate a challenge for device proof-of-possession verification.
789
793
  *
790
- * @param email - The email to search for (case-insensitive)
791
- * @returns The stored user record including credentials, or null if not found
792
- */
793
- async findByEmail(email) {
794
- return this.usersByEmail.get(email.toLowerCase()) ?? null;
795
- }
796
- /**
797
- * Find a user by ID.
794
+ * Creates a cryptographically random challenge, stores it server-side with
795
+ * a 60-second TTL and the target device ID, and returns the challenge string.
796
+ * The client signs this challenge with its private key and submits it via
797
+ * {@link handleDeviceVerify}.
798
798
  *
799
- * @param id - The user ID to search for
800
- * @returns The stored user record including credentials, or null if not found
799
+ * @param accessToken - The JWT access token (without "Bearer " prefix)
800
+ * @param deviceId - The device this challenge is intended for
801
+ * @returns Auth response with the challenge string, or an error
801
802
  */
802
- async findById(id) {
803
- return this.usersById.get(id) ?? null;
803
+ async handleDeviceChallenge(accessToken, deviceId) {
804
+ const payload = this.tokenManager.validateToken(accessToken);
805
+ if (payload === null || payload.type !== "access") {
806
+ return {
807
+ status: 401,
808
+ body: { error: "Invalid or expired access token." }
809
+ };
810
+ }
811
+ const device = await this.userStore.findDevice(deviceId);
812
+ if (device === null || device.userId !== payload.sub) {
813
+ return {
814
+ status: 404,
815
+ body: { error: "Device not found." }
816
+ };
817
+ }
818
+ if (device.revoked) {
819
+ return {
820
+ status: 403,
821
+ body: { error: "Device has been revoked." }
822
+ };
823
+ }
824
+ const challenge = (0, import_node_crypto2.randomBytes)(32).toString("hex");
825
+ const expiresAt = Date.now() + CHALLENGE_TTL_MS;
826
+ await this.challengeStore.store(challenge, deviceId, expiresAt);
827
+ return {
828
+ status: 200,
829
+ body: { data: { challenge } }
830
+ };
804
831
  }
805
832
  /**
806
- * Register a device for a user.
833
+ * Handle device proof-of-possession verification (POST /auth/device/verify).
807
834
  *
808
- * If a device with the same ID already exists and is not revoked, it is
809
- * returned as-is (idempotent registration). If it was previously revoked,
810
- * it is re-activated with updated details.
835
+ * Verifies that the device holds the private key corresponding to its registered
836
+ * public key by checking a signed challenge. The challenge must have been previously
837
+ * issued via {@link handleDeviceChallenge} and is single-use.
811
838
  *
812
- * @param params - Device registration parameters
813
- * @param params.id - Unique device identifier
814
- * @param params.userId - ID of the user who owns the device
815
- * @param params.publicKey - Base64url-encoded device public key or thumbprint
816
- * @param params.name - Human-readable device name
817
- * @returns The registered device record
839
+ * On success, issues fresh tokens for the device.
840
+ *
841
+ * @param body - Device verification request body
842
+ * @param body.deviceId - The ID of the device to verify
843
+ * @param body.challenge - The challenge string (from handleDeviceChallenge)
844
+ * @param body.signature - The base64url-encoded ECDSA signature of the challenge
845
+ * @returns Auth response with fresh tokens on success, or an error
818
846
  */
819
- async registerDevice(params) {
820
- const existing = this.devicesById.get(params.id);
821
- if (existing !== void 0 && !existing.revoked) {
822
- return existing;
847
+ async handleDeviceVerify(body) {
848
+ const challengeEntry = await this.challengeStore.consume(body.challenge);
849
+ if (challengeEntry === null) {
850
+ return {
851
+ status: 401,
852
+ body: { error: "Invalid or expired challenge. Request a new challenge and try again." }
853
+ };
823
854
  }
824
- const now = Date.now();
825
- const device = {
826
- id: params.id,
827
- userId: params.userId,
828
- publicKey: params.publicKey,
829
- name: params.name,
830
- revoked: false,
831
- createdAt: now,
832
- lastSeenAt: now
833
- };
834
- this.devicesById.set(params.id, device);
835
- let userDevices = this.devicesByUserId.get(params.userId);
836
- if (userDevices === void 0) {
837
- userDevices = /* @__PURE__ */ new Set();
838
- this.devicesByUserId.set(params.userId, userDevices);
855
+ if (challengeEntry.deviceId !== body.deviceId) {
856
+ return {
857
+ status: 401,
858
+ body: { error: "Challenge was not issued for this device." }
859
+ };
860
+ }
861
+ const device = await this.userStore.findDevice(body.deviceId);
862
+ if (device === null) {
863
+ return {
864
+ status: 404,
865
+ body: { error: "Device not found." }
866
+ };
867
+ }
868
+ if (device.revoked) {
869
+ return {
870
+ status: 403,
871
+ body: { error: "Device has been revoked and cannot authenticate." }
872
+ };
873
+ }
874
+ let publicKeyJwk;
875
+ try {
876
+ publicKeyJwk = JSON.parse(device.publicKey);
877
+ } catch {
878
+ return {
879
+ status: 500,
880
+ body: { error: "Device has an invalid stored public key." }
881
+ };
839
882
  }
840
- userDevices.add(params.id);
841
- return device;
842
- }
843
- /**
844
- * Find a device by its ID.
845
- *
846
- * @param deviceId - The device ID to search for
847
- * @returns The device record, or null if not found
848
- */
849
- async findDevice(deviceId) {
850
- return this.devicesById.get(deviceId) ?? null;
851
- }
852
- /**
853
- * List all devices registered for a user.
854
- *
855
- * @param userId - The user ID whose devices to list
856
- * @returns Array of device records (includes revoked devices)
857
- */
858
- async listDevices(userId) {
859
- const deviceIds = this.devicesByUserId.get(userId);
860
- if (deviceIds === void 0) {
861
- return [];
883
+ let isValid;
884
+ try {
885
+ isValid = await verifyChallenge(publicKeyJwk, body.challenge, body.signature);
886
+ } catch {
887
+ return {
888
+ status: 400,
889
+ body: {
890
+ error: "Signature verification failed. The signature or public key format may be invalid."
891
+ }
892
+ };
862
893
  }
863
- const devices = [];
864
- for (const deviceId of deviceIds) {
865
- const device = this.devicesById.get(deviceId);
866
- if (device !== void 0) {
867
- devices.push(device);
868
- }
894
+ if (!isValid) {
895
+ return {
896
+ status: 401,
897
+ body: { error: "Invalid signature. Proof-of-possession verification failed." }
898
+ };
869
899
  }
870
- return devices;
900
+ let thumbprint;
901
+ try {
902
+ thumbprint = await computePublicKeyThumbprint(publicKeyJwk);
903
+ } catch {
904
+ return {
905
+ status: 500,
906
+ body: { error: "Failed to compute public key thumbprint." }
907
+ };
908
+ }
909
+ const tokens = this.tokenManager.issueTokens(device.userId, device.id, thumbprint);
910
+ return {
911
+ status: 200,
912
+ body: { data: { tokens } }
913
+ };
871
914
  }
872
915
  /**
873
- * Revoke a device, preventing it from being used for authentication.
916
+ * Generates a random challenge string for proof-of-possession verification.
874
917
  *
875
- * This is a soft revoke the device record remains but is marked as revoked.
876
- * If the device does not exist, this is a no-op.
918
+ * **Deprecated:** Use {@link handleDeviceChallenge} instead, which stores
919
+ * the challenge server-side with expiry and single-use semantics.
877
920
  *
878
- * @param deviceId - The ID of the device to revoke
921
+ * @returns A 64-character hex string (32 random bytes)
879
922
  */
880
- async revokeDevice(deviceId) {
881
- const device = this.devicesById.get(deviceId);
882
- if (device !== void 0) {
883
- device.revoked = true;
884
- }
923
+ static generateChallenge() {
924
+ return (0, import_node_crypto2.randomBytes)(32).toString("hex");
885
925
  }
886
926
  /**
887
- * Set a user's email verification status.
927
+ * Creates a sync server auth provider compatible with `@korajs/server`.
888
928
  *
889
- * @param userId - The user whose email to verify
890
- * @param verified - Whether the email is verified
891
- */
892
- async setEmailVerified(userId, verified) {
893
- const user = this.usersById.get(userId);
894
- if (!user) return;
895
- const updated = { ...user, emailVerified: verified };
896
- this.usersById.set(userId, updated);
897
- this.usersByEmail.set(user.email, updated);
898
- }
899
- /**
900
- * Update a user's password hash and salt.
929
+ * The returned object implements the `AuthProvider` interface from
930
+ * `@korajs/server`, validating access tokens and returning an auth
931
+ * context containing the user ID and device metadata. This bridges
932
+ * the built-in auth system with the sync server's authentication layer.
901
933
  *
902
- * @param userId - The user whose password to update
903
- * @param passwordHash - New hex-encoded PBKDF2 derived key
904
- * @param salt - New hex-encoded salt
905
- */
906
- async updatePassword(userId, passwordHash, salt) {
907
- const user = this.usersById.get(userId);
908
- if (!user) return;
909
- const updated = { ...user, passwordHash, salt };
910
- this.usersById.set(userId, updated);
911
- this.usersByEmail.set(user.email, updated);
912
- }
913
- /**
914
- * List all users. For admin/development use.
915
- */
916
- async listAll() {
917
- return [...this.usersById.values()];
918
- }
919
- /**
920
- * Update a stored user record.
921
- */
922
- async update(user) {
923
- const existing = this.usersById.get(user.id);
924
- if (!existing) return;
925
- if (existing.email !== user.email) {
926
- this.usersByEmail.delete(existing.email);
927
- this.usersByEmail.set(user.email, user);
928
- } else {
929
- this.usersByEmail.set(user.email, user);
930
- }
931
- this.usersById.set(user.id, user);
932
- }
933
- /**
934
- * Delete a user and all associated devices.
935
- */
936
- async delete(userId) {
937
- const user = this.usersById.get(userId);
938
- if (!user) return;
939
- this.usersById.delete(userId);
940
- this.usersByEmail.delete(user.email);
941
- const deviceIds = this.devicesByUserId.get(userId);
942
- if (deviceIds) {
943
- for (const deviceId of deviceIds) {
944
- this.devicesById.delete(deviceId);
945
- }
946
- this.devicesByUserId.delete(userId);
947
- }
948
- }
949
- /**
950
- * Update the last-seen timestamp for a device.
934
+ * Also checks device revocation status during authentication, ensuring
935
+ * that revoked devices are rejected even if their tokens haven't expired.
951
936
  *
952
- * Called when a device authenticates or syncs to track activity.
953
- * If the device does not exist, this is a no-op.
937
+ * @returns An object with an `authenticate` method suitable for KoraSyncServer's `auth` config
954
938
  *
955
- * @param deviceId - The ID of the device to update
939
+ * @example
940
+ * ```typescript
941
+ * const routes = new BuiltInAuthRoutes({ userStore, tokenManager })
942
+ * const syncServer = new KoraSyncServer({
943
+ * store,
944
+ * auth: routes.toSyncAuthProvider(),
945
+ * })
946
+ * ```
956
947
  */
957
- async touchDevice(deviceId) {
958
- const device = this.devicesById.get(deviceId);
959
- if (device !== void 0) {
960
- device.lastSeenAt = Date.now();
961
- }
948
+ toSyncAuthProvider() {
949
+ const tokenManager = this.tokenManager;
950
+ const userStore = this.userStore;
951
+ return {
952
+ async authenticate(token) {
953
+ const payload = tokenManager.validateToken(token);
954
+ if (payload === null || payload.type !== "access") {
955
+ return null;
956
+ }
957
+ const user = await userStore.findById(payload.sub);
958
+ if (user === null) {
959
+ return null;
960
+ }
961
+ const device = await userStore.findDevice(payload.dev);
962
+ if (device?.revoked) {
963
+ return null;
964
+ }
965
+ await userStore.touchDevice(payload.dev);
966
+ return {
967
+ userId: payload.sub,
968
+ metadata: {
969
+ deviceId: payload.dev,
970
+ email: user.email,
971
+ name: user.name
972
+ }
973
+ };
974
+ }
975
+ };
962
976
  }
963
977
  };
964
- function toAuthUser(stored) {
965
- return {
966
- id: stored.id,
967
- email: stored.email,
968
- name: stored.name,
969
- emailVerified: stored.emailVerified,
970
- createdAt: stored.createdAt
971
- };
972
- }
973
978
 
974
- // src/provider/built-in/auth-routes.ts
975
- var InMemoryChallengeStore = class {
976
- challenges = /* @__PURE__ */ new Map();
977
- async store(challenge, deviceId, expiresAt) {
978
- this.challenges.set(challenge, { deviceId, expiresAt });
979
+ // src/tokens/token-manager.ts
980
+ init_cjs_shims();
981
+ var import_node_crypto4 = require("crypto");
982
+
983
+ // src/types.ts
984
+ init_cjs_shims();
985
+ var import_core2 = require("@korajs/core");
986
+ var DEFAULT_ACCESS_TOKEN_LIFETIME = 15 * 60 * 1e3;
987
+ var DEFAULT_REFRESH_TOKEN_LIFETIME = 90 * 24 * 60 * 60 * 1e3;
988
+ var DEFAULT_DEVICE_CREDENTIAL_LIFETIME = 90 * 24 * 60 * 60 * 1e3;
989
+ var DEFAULT_MAX_OFFLINE_DURATION = 30 * 24 * 60 * 60 * 1e3;
990
+ var DEFAULT_AUTO_LOCK_TIMEOUT = 15 * 60 * 1e3;
991
+
992
+ // src/tokens/jwt.ts
993
+ init_cjs_shims();
994
+ var import_node_crypto3 = require("crypto");
995
+ function base64urlEncode(input) {
996
+ return Buffer.from(input, "utf-8").toString("base64url");
997
+ }
998
+ function base64urlDecode(input) {
999
+ return Buffer.from(input, "base64url").toString("utf-8");
1000
+ }
1001
+ function hmacSha256Base64url(data, secret) {
1002
+ return (0, import_node_crypto3.createHmac)("sha256", secret).update(data).digest("base64url");
1003
+ }
1004
+ var ENCODED_HEADER = base64urlEncode(JSON.stringify({ alg: "HS256", typ: "JWT" }));
1005
+ function encodeJwt(payload, secret) {
1006
+ const encodedPayload = base64urlEncode(JSON.stringify(payload));
1007
+ const signingInput = `${ENCODED_HEADER}.${encodedPayload}`;
1008
+ const signature = hmacSha256Base64url(signingInput, secret);
1009
+ return `${signingInput}.${signature}`;
1010
+ }
1011
+ function decodeJwt(token) {
1012
+ const parts = token.split(".");
1013
+ if (parts.length !== 3) {
1014
+ return null;
979
1015
  }
980
- async consume(challenge) {
981
- const entry = this.challenges.get(challenge);
982
- if (entry === void 0) {
983
- return null;
984
- }
985
- this.challenges.delete(challenge);
986
- if (Date.now() > entry.expiresAt) {
1016
+ const payloadSegment = parts[1];
1017
+ if (payloadSegment === void 0) {
1018
+ return null;
1019
+ }
1020
+ try {
1021
+ const decoded = base64urlDecode(payloadSegment);
1022
+ const parsed = JSON.parse(decoded);
1023
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
987
1024
  return null;
988
1025
  }
989
- return { deviceId: entry.deviceId };
990
- }
991
- /**
992
- * Remove expired challenges to prevent unbounded memory growth.
993
- */
994
- cleanup() {
995
- const now = Date.now();
996
- for (const [challenge, entry] of this.challenges) {
997
- if (now > entry.expiresAt) {
998
- this.challenges.delete(challenge);
999
- }
1000
- }
1001
- }
1002
- };
1003
- var InMemoryRateLimiter = class {
1004
- attempts = /* @__PURE__ */ new Map();
1005
- maxAttempts;
1006
- windowMs;
1007
- /**
1008
- * @param maxAttempts - Maximum number of attempts within the time window (default: 10)
1009
- * @param windowMs - Time window in milliseconds (default: 60,000 = 1 minute)
1010
- */
1011
- constructor(maxAttempts = 10, windowMs = 6e4) {
1012
- this.maxAttempts = maxAttempts;
1013
- this.windowMs = windowMs;
1026
+ return parsed;
1027
+ } catch {
1028
+ return null;
1014
1029
  }
1015
- async isAllowed(key) {
1016
- const now = Date.now();
1017
- const attempts = this.attempts.get(key) ?? [];
1018
- const recentAttempts = attempts.filter((t) => now - t < this.windowMs);
1019
- return recentAttempts.length < this.maxAttempts;
1030
+ }
1031
+ function verifyJwt(token, secret) {
1032
+ const parts = token.split(".");
1033
+ if (parts.length !== 3) {
1034
+ return null;
1020
1035
  }
1021
- async record(key) {
1022
- const now = Date.now();
1023
- const attempts = this.attempts.get(key) ?? [];
1024
- const recentAttempts = attempts.filter((t) => now - t < this.windowMs);
1025
- recentAttempts.push(now);
1026
- this.attempts.set(key, recentAttempts);
1036
+ const headerSegment = parts[0];
1037
+ const payloadSegment = parts[1];
1038
+ const signatureSegment = parts[2];
1039
+ if (headerSegment === void 0 || payloadSegment === void 0 || signatureSegment === void 0) {
1040
+ return null;
1027
1041
  }
1028
- async reset(key) {
1029
- this.attempts.delete(key);
1042
+ if (headerSegment !== ENCODED_HEADER) {
1043
+ return null;
1030
1044
  }
1031
- };
1032
- var MIN_PASSWORD_LENGTH = 8;
1033
- var MAX_PASSWORD_LENGTH = 128;
1034
- var MAX_NAME_LENGTH = 200;
1035
- var CHALLENGE_TTL_MS = 6e4;
1036
- function isValidEmail(email) {
1037
- if (email.length === 0 || email.length > 254) {
1038
- return false;
1045
+ const signingInput = `${headerSegment}.${payloadSegment}`;
1046
+ const expectedSignature = hmacSha256Base64url(signingInput, secret);
1047
+ if (expectedSignature.length !== signatureSegment.length) {
1048
+ return null;
1039
1049
  }
1040
- const atIndex = email.indexOf("@");
1041
- if (atIndex < 1) {
1042
- return false;
1050
+ let mismatch = 0;
1051
+ for (let i = 0; i < expectedSignature.length; i++) {
1052
+ mismatch |= expectedSignature.charCodeAt(i) ^ signatureSegment.charCodeAt(i);
1043
1053
  }
1044
- const domain = email.slice(atIndex + 1);
1045
- if (domain.length === 0 || !domain.includes(".")) {
1046
- return false;
1054
+ if (mismatch !== 0) {
1055
+ return null;
1047
1056
  }
1048
- if (email.indexOf("@", atIndex + 1) !== -1) {
1049
- return false;
1057
+ try {
1058
+ const decoded = base64urlDecode(payloadSegment);
1059
+ const parsed = JSON.parse(decoded);
1060
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1061
+ return null;
1062
+ }
1063
+ return parsed;
1064
+ } catch {
1065
+ return null;
1050
1066
  }
1051
- if (email.includes(" ")) {
1067
+ }
1068
+ var CLOCK_SKEW_TOLERANCE_SECONDS = 5;
1069
+ function isExpired(payload) {
1070
+ if (typeof payload.exp !== "number") {
1052
1071
  return false;
1053
1072
  }
1054
- return true;
1073
+ const nowSeconds = Math.floor(Date.now() / 1e3);
1074
+ return nowSeconds >= payload.exp + CLOCK_SKEW_TOLERANCE_SECONDS;
1055
1075
  }
1056
- function sanitizeName(name) {
1057
- const cleaned = name.replace(/[\x00-\x1f\x7f]/g, "");
1058
- const trimmed = cleaned.trim();
1059
- if (trimmed.length > MAX_NAME_LENGTH) {
1060
- return trimmed.slice(0, MAX_NAME_LENGTH);
1076
+
1077
+ // src/tokens/token-manager.ts
1078
+ var MIN_SECRET_LENGTH = 32;
1079
+ var InMemoryTokenRevocationStore = class {
1080
+ revokedTokens = /* @__PURE__ */ new Map();
1081
+ revokedDevices = /* @__PURE__ */ new Set();
1082
+ async isRevoked(jti) {
1083
+ return this.revokedTokens.has(jti);
1061
1084
  }
1062
- return trimmed;
1063
- }
1064
- var BuiltInAuthRoutes = class {
1065
- userStore;
1066
- tokenManager;
1067
- challengeStore;
1068
- rateLimiter;
1069
- constructor(config) {
1070
- this.userStore = config.userStore;
1071
- this.tokenManager = config.tokenManager;
1072
- this.challengeStore = config.challengeStore ?? new InMemoryChallengeStore();
1073
- this.rateLimiter = config.rateLimiter ?? new InMemoryRateLimiter();
1085
+ async revoke(jti, expiresAt) {
1086
+ this.revokedTokens.set(jti, expiresAt);
1074
1087
  }
1075
- /**
1076
- * Handle user sign-up (POST /auth/signup).
1077
- *
1078
- * Validates email format and password length, hashes the password,
1079
- * creates the user, optionally registers a device, and issues tokens.
1080
- *
1081
- * @param body - Sign-up request body
1082
- * @param body.email - The user's email address
1083
- * @param body.password - The plaintext password (8-128 characters)
1084
- * @param body.name - Optional display name (defaults to email local part)
1085
- * @param body.deviceId - Optional device ID to register
1086
- * @param body.devicePublicKey - Optional device public key (base64url)
1087
- * @param clientIp - Optional client IP for rate limiting
1088
- * @returns Auth response with the created user and tokens, or an error
1089
- */
1090
- async handleSignUp(body, clientIp) {
1091
- const rateLimitKey = clientIp ?? "global";
1092
- if (!await this.rateLimiter.isAllowed(rateLimitKey)) {
1093
- return {
1094
- status: 429,
1095
- body: { error: "Too many requests. Please try again later." }
1096
- };
1097
- }
1098
- await this.rateLimiter.record(rateLimitKey);
1099
- if (!isValidEmail(body.email)) {
1100
- return {
1101
- status: 400,
1102
- body: {
1103
- error: "Invalid email address. Please provide a valid email in the format user@domain.com."
1104
- }
1105
- };
1106
- }
1107
- if (body.password.length < MIN_PASSWORD_LENGTH) {
1108
- return {
1109
- status: 400,
1110
- body: {
1111
- error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long.`
1112
- }
1113
- };
1114
- }
1115
- if (body.password.length > MAX_PASSWORD_LENGTH) {
1116
- return {
1117
- status: 400,
1118
- body: {
1119
- error: `Password must be at most ${MAX_PASSWORD_LENGTH} characters long.`
1120
- }
1121
- };
1122
- }
1123
- const { hash, salt } = await hashPassword(body.password);
1124
- const rawName = body.name ?? body.email.split("@")[0] ?? body.email;
1125
- const name = sanitizeName(rawName);
1126
- let user;
1127
- try {
1128
- user = await this.userStore.createUser({
1129
- email: body.email,
1130
- passwordHash: hash,
1131
- salt,
1132
- name
1133
- });
1134
- } catch (err) {
1135
- if (err instanceof Error && err.name === "DuplicateEmailError") {
1136
- return {
1137
- status: 409,
1138
- body: { error: "An account with this email already exists." }
1139
- };
1140
- }
1141
- throw err;
1142
- }
1143
- const deviceId = body.deviceId ?? `device-${user.id}`;
1144
- if (body.deviceId !== void 0 && body.devicePublicKey !== void 0) {
1145
- await this.userStore.registerDevice({
1146
- id: body.deviceId,
1147
- userId: user.id,
1148
- publicKey: body.devicePublicKey,
1149
- name: "Primary Device"
1150
- });
1151
- }
1152
- const tokens = this.tokenManager.issueTokens(user.id, deviceId);
1153
- return {
1154
- status: 201,
1155
- body: { data: { user, tokens } }
1156
- };
1088
+ async revokeAllForDevice(deviceId) {
1089
+ this.revokedDevices.add(deviceId);
1157
1090
  }
1158
1091
  /**
1159
- * Handle user sign-in (POST /auth/signin).
1160
- *
1161
- * Looks up the user by email, verifies the password, optionally registers
1162
- * a new device, and issues tokens.
1163
- *
1164
- * @param body - Sign-in request body
1165
- * @param body.email - The user's email address
1166
- * @param body.password - The plaintext password
1167
- * @param body.deviceId - Optional device ID to register
1168
- * @param body.devicePublicKey - Optional device public key (base64url)
1169
- * @param clientIp - Optional client IP for rate limiting
1170
- * @returns Auth response with the user and tokens, or an error
1092
+ * Check if a device has been revoked.
1171
1093
  */
1172
- async handleSignIn(body, clientIp) {
1173
- const rateLimitKey = clientIp ? `signin:${body.email.toLowerCase()}:${clientIp}` : `signin:${body.email.toLowerCase()}`;
1174
- if (!await this.rateLimiter.isAllowed(rateLimitKey)) {
1175
- return {
1176
- status: 429,
1177
- body: { error: "Too many sign-in attempts. Please try again later." }
1178
- };
1179
- }
1180
- await this.rateLimiter.record(rateLimitKey);
1181
- const storedUser = await this.userStore.findByEmail(body.email);
1182
- if (storedUser === null) {
1183
- return {
1184
- status: 401,
1185
- body: { error: "Invalid email or password." }
1186
- };
1187
- }
1188
- const passwordValid = await verifyPassword(
1189
- body.password,
1190
- storedUser.passwordHash,
1191
- storedUser.salt
1192
- );
1193
- if (!passwordValid) {
1194
- return {
1195
- status: 401,
1196
- body: { error: "Invalid email or password." }
1197
- };
1198
- }
1199
- await this.rateLimiter.reset(rateLimitKey);
1200
- const deviceId = body.deviceId ?? `device-${storedUser.id}`;
1201
- if (body.deviceId !== void 0 && body.devicePublicKey !== void 0) {
1202
- await this.userStore.registerDevice({
1203
- id: body.deviceId,
1204
- userId: storedUser.id,
1205
- publicKey: body.devicePublicKey,
1206
- name: "Device"
1207
- });
1208
- }
1209
- const tokens = this.tokenManager.issueTokens(storedUser.id, deviceId);
1210
- const user = {
1211
- id: storedUser.id,
1212
- email: storedUser.email,
1213
- name: storedUser.name,
1214
- emailVerified: storedUser.emailVerified,
1215
- createdAt: storedUser.createdAt
1216
- };
1217
- return {
1218
- status: 200,
1219
- body: { data: { user, tokens } }
1220
- };
1094
+ isDeviceRevoked(deviceId) {
1095
+ return this.revokedDevices.has(deviceId);
1221
1096
  }
1222
1097
  /**
1223
- * Handle token refresh (POST /auth/refresh).
1224
- *
1225
- * Validates the provided refresh token and issues a new token pair
1226
- * (refresh token rotation with reuse detection). The old refresh token
1227
- * is marked as consumed in the revocation store.
1228
- *
1229
- * @param body - Refresh request body
1230
- * @param body.refreshToken - The current refresh token
1231
- * @returns Auth response with new tokens, or an error
1098
+ * Remove expired revocations to prevent unbounded memory growth.
1099
+ * Call periodically (e.g., every hour) in long-running servers.
1232
1100
  */
1233
- async handleRefresh(body) {
1234
- const result = await this.tokenManager.refreshAccessToken(body.refreshToken);
1235
- if (result === null) {
1236
- return {
1237
- status: 401,
1238
- body: { error: "Invalid or expired refresh token." }
1239
- };
1101
+ cleanup() {
1102
+ const nowSeconds = Math.floor(Date.now() / 1e3);
1103
+ for (const [jti, expiresAt] of this.revokedTokens) {
1104
+ if (nowSeconds > expiresAt) {
1105
+ this.revokedTokens.delete(jti);
1106
+ }
1240
1107
  }
1241
- return {
1242
- status: 200,
1243
- body: { data: result }
1244
- };
1245
1108
  }
1246
- /**
1247
- * Handle sign-out (POST /auth/signout).
1248
- *
1249
- * Validates the access token and revokes the current refresh token
1250
- * (if a revocation store is configured). This ensures that stolen
1251
- * refresh tokens cannot be used after the user signs out.
1252
- *
1253
- * @param accessToken - The JWT access token (without "Bearer " prefix)
1254
- * @param body - Sign-out request body
1255
- * @param body.refreshToken - The current refresh token to revoke
1256
- * @returns Auth response with success flag, or an error
1257
- */
1258
- async handleSignOut(accessToken, body) {
1259
- const payload = this.tokenManager.validateToken(accessToken);
1260
- if (payload === null || payload.type !== "access") {
1261
- return {
1262
- status: 401,
1263
- body: { error: "Invalid or expired access token." }
1264
- };
1109
+ };
1110
+ var TokenManager = class {
1111
+ /** All signing/verification secrets (index 0 = current signing key) */
1112
+ secrets;
1113
+ accessTokenLifetime;
1114
+ refreshTokenLifetime;
1115
+ deviceCredentialLifetime;
1116
+ revocationStore;
1117
+ constructor(config) {
1118
+ const secrets = Array.isArray(config.secret) ? config.secret : [config.secret];
1119
+ if (secrets.length === 0) {
1120
+ throw new Error("TokenManager requires at least one secret.");
1265
1121
  }
1266
- await this.tokenManager.revokeToken(payload.jti, payload.exp);
1267
- if (body.refreshToken) {
1268
- const refreshPayload = this.tokenManager.validateToken(body.refreshToken);
1269
- if (refreshPayload !== null && refreshPayload.type === "refresh") {
1270
- await this.tokenManager.revokeToken(refreshPayload.jti, refreshPayload.exp);
1122
+ for (const secret of secrets) {
1123
+ if (secret.length < MIN_SECRET_LENGTH) {
1124
+ throw new Error(
1125
+ `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.`
1126
+ );
1271
1127
  }
1272
1128
  }
1273
- return {
1274
- status: 200,
1275
- body: { data: { success: true } }
1276
- };
1129
+ this.secrets = secrets;
1130
+ this.accessTokenLifetime = config.accessTokenLifetime ?? DEFAULT_ACCESS_TOKEN_LIFETIME;
1131
+ this.refreshTokenLifetime = config.refreshTokenLifetime ?? DEFAULT_REFRESH_TOKEN_LIFETIME;
1132
+ this.deviceCredentialLifetime = config.deviceCredentialLifetime ?? DEFAULT_DEVICE_CREDENTIAL_LIFETIME;
1133
+ this.revocationStore = config.revocationStore;
1277
1134
  }
1278
1135
  /**
1279
- * Handle get-current-user (GET /auth/me).
1136
+ * Generate a cryptographically random secret suitable for HMAC-SHA256 signing.
1280
1137
  *
1281
- * Validates the access token and returns the authenticated user's profile.
1138
+ * Returns a 64-character hex string (32 bytes / 256 bits of entropy).
1139
+ * Store this securely (environment variable, secrets manager) — never in source code.
1282
1140
  *
1283
- * @param accessToken - The JWT access token (without "Bearer " prefix)
1284
- * @returns Auth response with the user profile, or an error
1141
+ * @returns A random 256-bit hex-encoded secret
1285
1142
  */
1286
- async handleGetMe(accessToken) {
1287
- const payload = this.tokenManager.validateToken(accessToken);
1288
- if (payload === null || payload.type !== "access") {
1289
- return {
1290
- status: 401,
1291
- body: { error: "Invalid or expired access token." }
1292
- };
1293
- }
1294
- const storedUser = await this.userStore.findById(payload.sub);
1295
- if (storedUser === null) {
1296
- return {
1297
- status: 404,
1298
- body: { error: "User not found." }
1299
- };
1300
- }
1301
- const user = {
1302
- id: storedUser.id,
1303
- email: storedUser.email,
1304
- name: storedUser.name,
1305
- emailVerified: storedUser.emailVerified,
1306
- createdAt: storedUser.createdAt
1307
- };
1308
- return {
1309
- status: 200,
1310
- body: { data: user }
1311
- };
1143
+ static generateSecret() {
1144
+ return (0, import_node_crypto4.randomBytes)(32).toString("hex");
1312
1145
  }
1313
1146
  /**
1314
- * Handle list-devices (GET /auth/devices).
1147
+ * Issue a signed JWT access token.
1315
1148
  *
1316
- * Validates the access token and returns all devices registered for the user.
1149
+ * Access tokens are short-lived (default 15 minutes) and used to authorize
1150
+ * API requests. When expired, use {@link refreshAccessToken} with a valid
1151
+ * refresh token to obtain a new one.
1317
1152
  *
1318
- * @param accessToken - The JWT access token (without "Bearer " prefix)
1319
- * @returns Auth response with the device list, or an error
1153
+ * @param userId - The subject (user ID) to encode in the token
1154
+ * @param deviceId - The device ID of the requesting device
1155
+ * @returns A signed JWT string with type 'access'
1320
1156
  */
1321
- async handleListDevices(accessToken) {
1322
- const payload = this.tokenManager.validateToken(accessToken);
1323
- if (payload === null || payload.type !== "access") {
1324
- return {
1325
- status: 401,
1326
- body: { error: "Invalid or expired access token." }
1327
- };
1328
- }
1329
- const devices = await this.userStore.listDevices(payload.sub);
1330
- return {
1331
- status: 200,
1332
- body: { data: devices }
1157
+ issueAccessToken(userId, deviceId) {
1158
+ const nowSeconds = Math.floor(Date.now() / 1e3);
1159
+ const payload = {
1160
+ jti: (0, import_node_crypto4.randomUUID)(),
1161
+ sub: userId,
1162
+ dev: deviceId,
1163
+ type: "access",
1164
+ iat: nowSeconds,
1165
+ exp: nowSeconds + Math.floor(this.accessTokenLifetime / 1e3)
1333
1166
  };
1167
+ return encodeJwt(payload, this.secrets[0]);
1334
1168
  }
1335
1169
  /**
1336
- * Handle device revocation (DELETE /auth/device/:id).
1170
+ * Issue a signed JWT refresh token.
1337
1171
  *
1338
- * Validates the access token, revokes the specified device, and invalidates
1339
- * all tokens issued to that device. Only the device's owner can revoke it.
1172
+ * Refresh tokens are longer-lived (default 90 days) and used exclusively
1173
+ * to obtain new access tokens via {@link refreshAccessToken}. They should
1174
+ * be stored securely and never sent to resource APIs.
1340
1175
  *
1341
- * @param accessToken - The JWT access token (without "Bearer " prefix)
1342
- * @param deviceId - The ID of the device to revoke
1343
- * @returns Auth response with success flag, or an error
1176
+ * @param userId - The subject (user ID) to encode in the token
1177
+ * @param deviceId - The device ID of the requesting device
1178
+ * @returns A signed JWT string with type 'refresh'
1344
1179
  */
1345
- async handleRevokeDevice(accessToken, deviceId) {
1346
- const payload = this.tokenManager.validateToken(accessToken);
1347
- if (payload === null || payload.type !== "access") {
1348
- return {
1349
- status: 401,
1350
- body: { error: "Invalid or expired access token." }
1351
- };
1352
- }
1353
- const device = await this.userStore.findDevice(deviceId);
1354
- if (device === null) {
1355
- return {
1356
- status: 404,
1357
- body: { error: "Device not found." }
1358
- };
1359
- }
1360
- if (device.userId !== payload.sub) {
1361
- return {
1362
- status: 403,
1363
- body: { error: "You can only revoke your own devices." }
1364
- };
1365
- }
1366
- await this.userStore.revokeDevice(deviceId);
1367
- await this.tokenManager.revokeDeviceTokens(deviceId);
1368
- return {
1369
- status: 200,
1370
- body: { data: { success: true } }
1180
+ issueRefreshToken(userId, deviceId) {
1181
+ const nowSeconds = Math.floor(Date.now() / 1e3);
1182
+ const payload = {
1183
+ jti: (0, import_node_crypto4.randomUUID)(),
1184
+ sub: userId,
1185
+ dev: deviceId,
1186
+ type: "refresh",
1187
+ iat: nowSeconds,
1188
+ exp: nowSeconds + Math.floor(this.refreshTokenLifetime / 1e3)
1371
1189
  };
1190
+ return encodeJwt(payload, this.secrets[0]);
1372
1191
  }
1373
1192
  /**
1374
- * Handle device registration (POST /auth/device/register).
1193
+ * Issue a signed device credential token.
1375
1194
  *
1376
- * Requires a valid access token. Registers a new device for the authenticated
1377
- * user and issues a device credential token bound to the device's public key.
1195
+ * Device credentials are long-lived tokens bound to a device's public key.
1196
+ * They include a `mustCheckinBy` deadline; if the device does not check in
1197
+ * before this deadline, the credential should be treated as revoked.
1378
1198
  *
1379
- * @param accessToken - The JWT access token (without "Bearer " prefix)
1380
- * @param body - Device registration request body
1381
- * @param body.deviceId - Unique identifier for the device
1382
- * @param body.publicKey - The device's public key as a JWK JSON string
1383
- * @param body.name - Human-readable device name (e.g., "Chrome on MacBook")
1384
- * @returns Auth response with the registered device and device credential, or an error
1385
- */
1386
- async handleDeviceRegister(accessToken, body) {
1387
- const payload = this.tokenManager.validateToken(accessToken);
1388
- if (payload === null || payload.type !== "access") {
1389
- return {
1390
- status: 401,
1391
- body: { error: "Invalid or expired access token." }
1392
- };
1393
- }
1394
- const deviceName = sanitizeName(body.name);
1395
- if (deviceName.length === 0) {
1396
- return {
1397
- status: 400,
1398
- body: { error: "Device name must not be empty." }
1399
- };
1400
- }
1401
- let publicKeyJwk;
1402
- try {
1403
- publicKeyJwk = JSON.parse(body.publicKey);
1404
- } catch {
1405
- return {
1406
- status: 400,
1407
- body: { error: "Invalid public key format. Expected a JSON-encoded JWK string." }
1408
- };
1409
- }
1410
- let thumbprint;
1411
- try {
1412
- thumbprint = await computePublicKeyThumbprint(publicKeyJwk);
1413
- } catch {
1414
- return {
1415
- status: 400,
1416
- body: { error: "Failed to compute public key thumbprint. Ensure the key is a valid EC P-256 JWK." }
1417
- };
1418
- }
1419
- const device = await this.userStore.registerDevice({
1420
- id: body.deviceId,
1421
- userId: payload.sub,
1422
- publicKey: body.publicKey,
1423
- name: deviceName
1424
- });
1425
- const deviceCredential = this.tokenManager.issueDeviceCredential(
1426
- payload.sub,
1427
- body.deviceId,
1428
- thumbprint
1429
- );
1430
- return {
1431
- status: 201,
1432
- body: { data: { device, deviceCredential } }
1199
+ * @param userId - The subject (user ID) to encode in the token
1200
+ * @param deviceId - The device ID of the requesting device
1201
+ * @param publicKeyThumbprint - SHA-256 thumbprint of the device's public key
1202
+ * @returns A signed JWT string with type 'device_credential'
1203
+ */
1204
+ issueDeviceCredential(userId, deviceId, publicKeyThumbprint) {
1205
+ const nowSeconds = Math.floor(Date.now() / 1e3);
1206
+ const lifetimeSeconds = Math.floor(this.deviceCredentialLifetime / 1e3);
1207
+ const payload = {
1208
+ jti: (0, import_node_crypto4.randomUUID)(),
1209
+ sub: userId,
1210
+ dev: deviceId,
1211
+ type: "device_credential",
1212
+ iat: nowSeconds,
1213
+ exp: nowSeconds + lifetimeSeconds,
1214
+ dpk: publicKeyThumbprint,
1215
+ mustCheckinBy: nowSeconds + lifetimeSeconds
1433
1216
  };
1217
+ return encodeJwt(payload, this.secrets[0]);
1434
1218
  }
1435
1219
  /**
1436
- * Generate a challenge for device proof-of-possession verification.
1220
+ * Issue a complete set of authentication tokens.
1437
1221
  *
1438
- * Creates a cryptographically random challenge, stores it server-side with
1439
- * a 60-second TTL and the target device ID, and returns the challenge string.
1440
- * The client signs this challenge with its private key and submits it via
1441
- * {@link handleDeviceVerify}.
1222
+ * Always issues an access token and refresh token. If a `publicKeyThumbprint`
1223
+ * is provided, also issues a device credential.
1442
1224
  *
1443
- * @param accessToken - The JWT access token (without "Bearer " prefix)
1444
- * @param deviceId - The device this challenge is intended for
1445
- * @returns Auth response with the challenge string, or an error
1225
+ * @param userId - The subject (user ID) to encode in the tokens
1226
+ * @param deviceId - The device ID of the requesting device
1227
+ * @param publicKeyThumbprint - Optional SHA-256 thumbprint of the device's public key.
1228
+ * When provided, a device credential is included in the returned tokens.
1229
+ * @returns An {@link AuthTokens} object containing the issued tokens
1446
1230
  */
1447
- async handleDeviceChallenge(accessToken, deviceId) {
1448
- const payload = this.tokenManager.validateToken(accessToken);
1449
- if (payload === null || payload.type !== "access") {
1450
- return {
1451
- status: 401,
1452
- body: { error: "Invalid or expired access token." }
1453
- };
1454
- }
1455
- const device = await this.userStore.findDevice(deviceId);
1456
- if (device === null || device.userId !== payload.sub) {
1457
- return {
1458
- status: 404,
1459
- body: { error: "Device not found." }
1460
- };
1461
- }
1462
- if (device.revoked) {
1463
- return {
1464
- status: 403,
1465
- body: { error: "Device has been revoked." }
1466
- };
1467
- }
1468
- const challenge = (0, import_node_crypto5.randomBytes)(32).toString("hex");
1469
- const expiresAt = Date.now() + CHALLENGE_TTL_MS;
1470
- await this.challengeStore.store(challenge, deviceId, expiresAt);
1471
- return {
1472
- status: 200,
1473
- body: { data: { challenge } }
1231
+ issueTokens(userId, deviceId, publicKeyThumbprint) {
1232
+ const tokens = {
1233
+ accessToken: this.issueAccessToken(userId, deviceId),
1234
+ refreshToken: this.issueRefreshToken(userId, deviceId)
1474
1235
  };
1236
+ if (publicKeyThumbprint !== void 0) {
1237
+ tokens.deviceCredential = this.issueDeviceCredential(userId, deviceId, publicKeyThumbprint);
1238
+ }
1239
+ return tokens;
1475
1240
  }
1476
1241
  /**
1477
- * Handle device proof-of-possession verification (POST /auth/device/verify).
1478
- *
1479
- * Verifies that the device holds the private key corresponding to its registered
1480
- * public key by checking a signed challenge. The challenge must have been previously
1481
- * issued via {@link handleDeviceChallenge} and is single-use.
1242
+ * Validate and decode a token.
1482
1243
  *
1483
- * On success, issues fresh tokens for the device.
1244
+ * Verifies the HMAC-SHA256 signature (trying all configured secrets for key rotation),
1245
+ * checks that the token has not expired, and validates all required claims.
1246
+ * Returns null (rather than throwing) for invalid or expired tokens, so callers
1247
+ * can handle authentication failure without try/catch.
1484
1248
  *
1485
- * @param body - Device verification request body
1486
- * @param body.deviceId - The ID of the device to verify
1487
- * @param body.challenge - The challenge string (from handleDeviceChallenge)
1488
- * @param body.signature - The base64url-encoded ECDSA signature of the challenge
1489
- * @returns Auth response with fresh tokens on success, or an error
1249
+ * @param token - The JWT string to validate
1250
+ * @returns The decoded {@link TokenPayload} if valid, or null if the token is
1251
+ * invalid, expired, or missing required claims
1490
1252
  */
1491
- async handleDeviceVerify(body) {
1492
- const challengeEntry = await this.challengeStore.consume(body.challenge);
1493
- if (challengeEntry === null) {
1494
- return {
1495
- status: 401,
1496
- body: { error: "Invalid or expired challenge. Request a new challenge and try again." }
1497
- };
1498
- }
1499
- if (challengeEntry.deviceId !== body.deviceId) {
1500
- return {
1501
- status: 401,
1502
- body: { error: "Challenge was not issued for this device." }
1503
- };
1504
- }
1505
- const device = await this.userStore.findDevice(body.deviceId);
1506
- if (device === null) {
1507
- return {
1508
- status: 404,
1509
- body: { error: "Device not found." }
1510
- };
1511
- }
1512
- if (device.revoked) {
1513
- return {
1514
- status: 403,
1515
- body: { error: "Device has been revoked and cannot authenticate." }
1516
- };
1253
+ validateToken(token) {
1254
+ let decoded = null;
1255
+ for (const secret of this.secrets) {
1256
+ decoded = verifyJwt(token, secret);
1257
+ if (decoded !== null) {
1258
+ break;
1259
+ }
1517
1260
  }
1518
- let publicKeyJwk;
1519
- try {
1520
- publicKeyJwk = JSON.parse(device.publicKey);
1521
- } catch {
1522
- return {
1523
- status: 500,
1524
- body: { error: "Device has an invalid stored public key." }
1525
- };
1261
+ if (decoded === null) {
1262
+ return null;
1526
1263
  }
1527
- let isValid;
1528
- try {
1529
- isValid = await verifyChallenge(publicKeyJwk, body.challenge, body.signature);
1530
- } catch {
1531
- return {
1532
- status: 400,
1533
- body: { error: "Signature verification failed. The signature or public key format may be invalid." }
1534
- };
1264
+ if (isExpired(decoded)) {
1265
+ return null;
1535
1266
  }
1536
- if (!isValid) {
1537
- return {
1538
- status: 401,
1539
- body: { error: "Invalid signature. Proof-of-possession verification failed." }
1540
- };
1267
+ 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") {
1268
+ return null;
1541
1269
  }
1542
- let thumbprint;
1543
- try {
1544
- thumbprint = await computePublicKeyThumbprint(publicKeyJwk);
1545
- } catch {
1546
- return {
1547
- status: 500,
1548
- body: { error: "Failed to compute public key thumbprint." }
1549
- };
1270
+ const type = decoded.type;
1271
+ if (type !== "access" && type !== "refresh" && type !== "device_credential") {
1272
+ return null;
1550
1273
  }
1551
- const tokens = this.tokenManager.issueTokens(device.userId, device.id, thumbprint);
1552
1274
  return {
1553
- status: 200,
1554
- body: { data: { tokens } }
1275
+ jti: decoded.jti,
1276
+ sub: decoded.sub,
1277
+ dev: decoded.dev,
1278
+ type,
1279
+ iat: decoded.iat,
1280
+ exp: decoded.exp
1555
1281
  };
1556
1282
  }
1557
1283
  /**
1558
- * Generates a random challenge string for proof-of-possession verification.
1284
+ * Validate a token and check it against the revocation store.
1559
1285
  *
1560
- * **Deprecated:** Use {@link handleDeviceChallenge} instead, which stores
1561
- * the challenge server-side with expiry and single-use semantics.
1286
+ * Like {@link validateToken}, but also checks whether the token's `jti` has been
1287
+ * revoked. Requires a revocation store to be configured.
1562
1288
  *
1563
- * @returns A 64-character hex string (32 random bytes)
1289
+ * @param token - The JWT string to validate
1290
+ * @returns The decoded {@link TokenPayload} if valid and not revoked, or null otherwise
1564
1291
  */
1565
- static generateChallenge() {
1566
- return (0, import_node_crypto5.randomBytes)(32).toString("hex");
1292
+ async validateTokenWithRevocation(token) {
1293
+ const payload = this.validateToken(token);
1294
+ if (payload === null) {
1295
+ return null;
1296
+ }
1297
+ if (this.revocationStore) {
1298
+ const revoked = await this.revocationStore.isRevoked(payload.jti);
1299
+ if (revoked) {
1300
+ return null;
1301
+ }
1302
+ }
1303
+ return payload;
1567
1304
  }
1568
1305
  /**
1569
- * Creates a sync server auth provider compatible with `@korajs/server`.
1306
+ * Revoke a specific token by its JWT ID.
1570
1307
  *
1571
- * The returned object implements the `AuthProvider` interface from
1572
- * `@korajs/server`, validating access tokens and returning an auth
1573
- * context containing the user ID and device metadata. This bridges
1574
- * the built-in auth system with the sync server's authentication layer.
1308
+ * Requires a revocation store to be configured. After revocation, the token
1309
+ * will be rejected by {@link validateTokenWithRevocation}.
1575
1310
  *
1576
- * Also checks device revocation status during authentication, ensuring
1577
- * that revoked devices are rejected even if their tokens haven't expired.
1311
+ * @param jti - The JWT ID of the token to revoke
1312
+ * @param expiresAt - The token's expiration time (seconds since epoch)
1313
+ */
1314
+ async revokeToken(jti, expiresAt) {
1315
+ if (this.revocationStore) {
1316
+ await this.revocationStore.revoke(jti, expiresAt);
1317
+ }
1318
+ }
1319
+ /**
1320
+ * Revoke all tokens for a specific device.
1578
1321
  *
1579
- * @returns An object with an `authenticate` method suitable for KoraSyncServer's `auth` config
1322
+ * Called when a device is revoked to ensure all its existing tokens
1323
+ * (access, refresh, and device credentials) are invalidated.
1580
1324
  *
1581
- * @example
1582
- * ```typescript
1583
- * const routes = new BuiltInAuthRoutes({ userStore, tokenManager })
1584
- * const syncServer = new KoraSyncServer({
1585
- * store,
1586
- * auth: routes.toSyncAuthProvider(),
1587
- * })
1588
- * ```
1325
+ * @param deviceId - The device ID whose tokens should be revoked
1589
1326
  */
1590
- toSyncAuthProvider() {
1591
- const tokenManager = this.tokenManager;
1592
- const userStore = this.userStore;
1593
- return {
1594
- async authenticate(token) {
1595
- const payload = tokenManager.validateToken(token);
1596
- if (payload === null || payload.type !== "access") {
1597
- return null;
1598
- }
1599
- const user = await userStore.findById(payload.sub);
1600
- if (user === null) {
1601
- return null;
1602
- }
1603
- const device = await userStore.findDevice(payload.dev);
1604
- if (device !== null && device.revoked) {
1605
- return null;
1606
- }
1607
- await userStore.touchDevice(payload.dev);
1608
- return {
1609
- userId: payload.sub,
1610
- metadata: {
1611
- deviceId: payload.dev,
1612
- email: user.email,
1613
- name: user.name
1614
- }
1615
- };
1327
+ async revokeDeviceTokens(deviceId) {
1328
+ if (this.revocationStore) {
1329
+ await this.revocationStore.revokeAllForDevice(deviceId);
1330
+ }
1331
+ }
1332
+ /**
1333
+ * Refresh an access token using a valid refresh token.
1334
+ *
1335
+ * Implements **refresh token rotation with reuse detection**: a new refresh token
1336
+ * is issued alongside the new access token. The old refresh token's `jti` is
1337
+ * recorded in the revocation store (if configured). If a previously consumed
1338
+ * refresh token is presented again, it indicates potential token theft.
1339
+ *
1340
+ * Returns null if the provided token is invalid, expired, or not a refresh token.
1341
+ *
1342
+ * @param refreshToken - The refresh token JWT string
1343
+ * @returns A new access/refresh token pair, or null if the refresh token is invalid
1344
+ */
1345
+ async refreshAccessToken(refreshToken) {
1346
+ const payload = this.validateToken(refreshToken);
1347
+ if (payload === null) {
1348
+ return null;
1349
+ }
1350
+ if (payload.type !== "refresh") {
1351
+ return null;
1352
+ }
1353
+ if (this.revocationStore) {
1354
+ const wasRevoked = await this.revocationStore.isRevoked(payload.jti);
1355
+ if (wasRevoked) {
1356
+ await this.revocationStore.revokeAllForDevice(payload.dev);
1357
+ return null;
1616
1358
  }
1359
+ await this.revocationStore.revoke(payload.jti, payload.exp);
1360
+ }
1361
+ return {
1362
+ accessToken: this.issueAccessToken(payload.sub, payload.dev),
1363
+ refreshToken: this.issueRefreshToken(payload.sub, payload.dev)
1617
1364
  };
1618
1365
  }
1619
1366
  };
@@ -1623,9 +1370,9 @@ init_password_hash();
1623
1370
 
1624
1371
  // src/provider/built-in/password-reset.ts
1625
1372
  init_cjs_shims();
1626
- var import_core4 = require("@korajs/core");
1373
+ var import_core3 = require("@korajs/core");
1627
1374
  init_password_hash();
1628
- var PasswordResetError = class extends import_core4.KoraError {
1375
+ var PasswordResetError = class extends import_core3.KoraError {
1629
1376
  constructor(message, code, context) {
1630
1377
  super(message, code, context);
1631
1378
  this.name = "PasswordResetError";
@@ -1710,7 +1457,11 @@ var PasswordResetManager = class {
1710
1457
  const normalizedEmail = email.toLowerCase().trim();
1711
1458
  const successResponse = {
1712
1459
  status: 200,
1713
- body: { data: { message: "If an account with that email exists, a password reset link has been sent." } }
1460
+ body: {
1461
+ data: {
1462
+ message: "If an account with that email exists, a password reset link has been sent."
1463
+ }
1464
+ }
1714
1465
  };
1715
1466
  const user = await this.userStore.findByEmail(normalizedEmail);
1716
1467
  if (!user) {
@@ -1813,8 +1564,8 @@ function generateSecureToken() {
1813
1564
 
1814
1565
  // src/provider/built-in/email-verification.ts
1815
1566
  init_cjs_shims();
1816
- var import_core5 = require("@korajs/core");
1817
- var EmailVerificationError = class extends import_core5.KoraError {
1567
+ var import_core4 = require("@korajs/core");
1568
+ var EmailVerificationError = class extends import_core4.KoraError {
1818
1569
  constructor(message, code, context) {
1819
1570
  super(message, code, context);
1820
1571
  this.name = "EmailVerificationError";
@@ -1852,115 +1603,355 @@ var InMemoryEmailVerificationStore = class {
1852
1603
  count++;
1853
1604
  }
1854
1605
  }
1855
- return count;
1606
+ return count;
1607
+ }
1608
+ async cleanExpired() {
1609
+ const now = Date.now();
1610
+ let count = 0;
1611
+ for (const [key, token] of this.tokens) {
1612
+ if (now > token.expiresAt) {
1613
+ this.tokens.delete(key);
1614
+ count++;
1615
+ }
1616
+ }
1617
+ return count;
1618
+ }
1619
+ };
1620
+ var DEFAULT_TOKEN_TTL_MS2 = 24 * 60 * 60 * 1e3;
1621
+ var DEFAULT_MAX_REQUESTS2 = 3;
1622
+ var EmailVerificationManager = class {
1623
+ userStore;
1624
+ verificationStore;
1625
+ tokenTtlMs;
1626
+ maxRequestsPerUser;
1627
+ onVerificationRequired;
1628
+ constructor(config) {
1629
+ this.userStore = config.userStore;
1630
+ this.verificationStore = config.verificationStore ?? new InMemoryEmailVerificationStore();
1631
+ this.tokenTtlMs = config.tokenTtlMs ?? DEFAULT_TOKEN_TTL_MS2;
1632
+ this.maxRequestsPerUser = config.maxRequestsPerUser ?? DEFAULT_MAX_REQUESTS2;
1633
+ this.onVerificationRequired = config.onVerificationRequired;
1634
+ }
1635
+ /**
1636
+ * Send a verification email for a user.
1637
+ * Rate-limited to prevent abuse.
1638
+ */
1639
+ async sendVerification(userId, email) {
1640
+ const normalizedEmail = email.toLowerCase().trim();
1641
+ const activeCount = await this.verificationStore.countActiveForUser(userId);
1642
+ if (activeCount >= this.maxRequestsPerUser) {
1643
+ return {
1644
+ status: 429,
1645
+ body: { error: "Too many verification requests. Please try again later." }
1646
+ };
1647
+ }
1648
+ const token = generateSecureToken2();
1649
+ const now = Date.now();
1650
+ const verificationToken = {
1651
+ token,
1652
+ userId,
1653
+ email: normalizedEmail,
1654
+ createdAt: now,
1655
+ expiresAt: now + this.tokenTtlMs,
1656
+ consumed: false
1657
+ };
1658
+ await this.verificationStore.store(verificationToken);
1659
+ if (this.onVerificationRequired) {
1660
+ try {
1661
+ await this.onVerificationRequired(normalizedEmail, token, verificationToken.expiresAt);
1662
+ } catch {
1663
+ }
1664
+ }
1665
+ const responseData = {
1666
+ message: "Verification email sent."
1667
+ };
1668
+ if (!this.onVerificationRequired) {
1669
+ responseData.token = token;
1670
+ }
1671
+ return { status: 200, body: { data: responseData } };
1672
+ }
1673
+ /**
1674
+ * Verify an email using a verification token.
1675
+ */
1676
+ async verifyEmail(token) {
1677
+ const verificationToken = await this.verificationStore.get(token);
1678
+ if (!verificationToken || verificationToken.consumed) {
1679
+ return { status: 404, body: { error: "Verification token not found or already used." } };
1680
+ }
1681
+ if (Date.now() > verificationToken.expiresAt) {
1682
+ await this.verificationStore.consume(token);
1683
+ return { status: 410, body: { error: "Verification token has expired." } };
1684
+ }
1685
+ await this.verificationStore.consume(token);
1686
+ await this.userStore.setEmailVerified(verificationToken.userId, true);
1687
+ return {
1688
+ status: 200,
1689
+ body: {
1690
+ data: {
1691
+ message: "Email verified successfully.",
1692
+ userId: verificationToken.userId,
1693
+ email: verificationToken.email
1694
+ }
1695
+ }
1696
+ };
1697
+ }
1698
+ /**
1699
+ * Resend verification email for a user.
1700
+ * Delegates to sendVerification with rate limiting.
1701
+ */
1702
+ async resendVerification(userId) {
1703
+ const user = await this.userStore.findById(userId);
1704
+ if (!user) {
1705
+ return { status: 404, body: { error: "User not found." } };
1706
+ }
1707
+ return this.sendVerification(userId, user.email);
1708
+ }
1709
+ };
1710
+ function generateSecureToken2() {
1711
+ const bytes = new Uint8Array(32);
1712
+ globalThis.crypto.getRandomValues(bytes);
1713
+ let binary = "";
1714
+ for (let i = 0; i < bytes.length; i++) {
1715
+ binary += String.fromCharCode(bytes[i]);
1716
+ }
1717
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1718
+ }
1719
+
1720
+ // src/provider/built-in/user-store.ts
1721
+ init_cjs_shims();
1722
+ var import_node_crypto5 = require("crypto");
1723
+ var import_core5 = require("@korajs/core");
1724
+ var DuplicateEmailError = class extends import_core5.KoraError {
1725
+ constructor() {
1726
+ super("A user with this email already exists.", "DUPLICATE_EMAIL");
1727
+ this.name = "DuplicateEmailError";
1728
+ }
1729
+ };
1730
+ var InMemoryUserStore = class {
1731
+ /** Users indexed by ID */
1732
+ usersById = /* @__PURE__ */ new Map();
1733
+ /** Users indexed by email (lowercase) for fast lookup */
1734
+ usersByEmail = /* @__PURE__ */ new Map();
1735
+ /** Devices indexed by device ID */
1736
+ devicesById = /* @__PURE__ */ new Map();
1737
+ /** Device IDs indexed by user ID for fast listing */
1738
+ devicesByUserId = /* @__PURE__ */ new Map();
1739
+ /**
1740
+ * Create a new user account.
1741
+ *
1742
+ * @param params - User creation parameters
1743
+ * @param params.email - The user's email address (must be unique, case-insensitive)
1744
+ * @param params.passwordHash - Hex-encoded PBKDF2 derived key
1745
+ * @param params.salt - Hex-encoded salt used during hashing
1746
+ * @param params.name - The user's display name
1747
+ * @returns The created user (without sensitive credential fields)
1748
+ * @throws {DuplicateEmailError} If a user with the same email already exists
1749
+ */
1750
+ async createUser(params) {
1751
+ const normalizedEmail = params.email.toLowerCase();
1752
+ if (this.usersByEmail.has(normalizedEmail)) {
1753
+ throw new DuplicateEmailError();
1754
+ }
1755
+ const now = Date.now();
1756
+ const id = (0, import_node_crypto5.randomUUID)();
1757
+ const storedUser = {
1758
+ id,
1759
+ email: normalizedEmail,
1760
+ name: params.name,
1761
+ emailVerified: false,
1762
+ createdAt: now,
1763
+ passwordHash: params.passwordHash,
1764
+ salt: params.salt
1765
+ };
1766
+ this.usersById.set(id, storedUser);
1767
+ this.usersByEmail.set(normalizedEmail, storedUser);
1768
+ return toAuthUser(storedUser);
1769
+ }
1770
+ /**
1771
+ * Find a user by email address.
1772
+ *
1773
+ * @param email - The email to search for (case-insensitive)
1774
+ * @returns The stored user record including credentials, or null if not found
1775
+ */
1776
+ async findByEmail(email) {
1777
+ return this.usersByEmail.get(email.toLowerCase()) ?? null;
1778
+ }
1779
+ /**
1780
+ * Find a user by ID.
1781
+ *
1782
+ * @param id - The user ID to search for
1783
+ * @returns The stored user record including credentials, or null if not found
1784
+ */
1785
+ async findById(id) {
1786
+ return this.usersById.get(id) ?? null;
1787
+ }
1788
+ /**
1789
+ * Register a device for a user.
1790
+ *
1791
+ * If a device with the same ID already exists and is not revoked, it is
1792
+ * returned as-is (idempotent registration). If it was previously revoked,
1793
+ * it is re-activated with updated details.
1794
+ *
1795
+ * @param params - Device registration parameters
1796
+ * @param params.id - Unique device identifier
1797
+ * @param params.userId - ID of the user who owns the device
1798
+ * @param params.publicKey - Base64url-encoded device public key or thumbprint
1799
+ * @param params.name - Human-readable device name
1800
+ * @returns The registered device record
1801
+ */
1802
+ async registerDevice(params) {
1803
+ const existing = this.devicesById.get(params.id);
1804
+ if (existing !== void 0 && !existing.revoked) {
1805
+ return existing;
1806
+ }
1807
+ const now = Date.now();
1808
+ const device = {
1809
+ id: params.id,
1810
+ userId: params.userId,
1811
+ publicKey: params.publicKey,
1812
+ name: params.name,
1813
+ revoked: false,
1814
+ createdAt: now,
1815
+ lastSeenAt: now
1816
+ };
1817
+ this.devicesById.set(params.id, device);
1818
+ let userDevices = this.devicesByUserId.get(params.userId);
1819
+ if (userDevices === void 0) {
1820
+ userDevices = /* @__PURE__ */ new Set();
1821
+ this.devicesByUserId.set(params.userId, userDevices);
1822
+ }
1823
+ userDevices.add(params.id);
1824
+ return device;
1825
+ }
1826
+ /**
1827
+ * Find a device by its ID.
1828
+ *
1829
+ * @param deviceId - The device ID to search for
1830
+ * @returns The device record, or null if not found
1831
+ */
1832
+ async findDevice(deviceId) {
1833
+ return this.devicesById.get(deviceId) ?? null;
1834
+ }
1835
+ /**
1836
+ * List all devices registered for a user.
1837
+ *
1838
+ * @param userId - The user ID whose devices to list
1839
+ * @returns Array of device records (includes revoked devices)
1840
+ */
1841
+ async listDevices(userId) {
1842
+ const deviceIds = this.devicesByUserId.get(userId);
1843
+ if (deviceIds === void 0) {
1844
+ return [];
1845
+ }
1846
+ const devices = [];
1847
+ for (const deviceId of deviceIds) {
1848
+ const device = this.devicesById.get(deviceId);
1849
+ if (device !== void 0) {
1850
+ devices.push(device);
1851
+ }
1852
+ }
1853
+ return devices;
1856
1854
  }
1857
- async cleanExpired() {
1858
- const now = Date.now();
1859
- let count = 0;
1860
- for (const [key, token] of this.tokens) {
1861
- if (now > token.expiresAt) {
1862
- this.tokens.delete(key);
1863
- count++;
1864
- }
1855
+ /**
1856
+ * Revoke a device, preventing it from being used for authentication.
1857
+ *
1858
+ * This is a soft revoke — the device record remains but is marked as revoked.
1859
+ * If the device does not exist, this is a no-op.
1860
+ *
1861
+ * @param deviceId - The ID of the device to revoke
1862
+ */
1863
+ async revokeDevice(deviceId) {
1864
+ const device = this.devicesById.get(deviceId);
1865
+ if (device !== void 0) {
1866
+ device.revoked = true;
1865
1867
  }
1866
- return count;
1867
1868
  }
1868
- };
1869
- var DEFAULT_TOKEN_TTL_MS2 = 24 * 60 * 60 * 1e3;
1870
- var DEFAULT_MAX_REQUESTS2 = 3;
1871
- var EmailVerificationManager = class {
1872
- userStore;
1873
- verificationStore;
1874
- tokenTtlMs;
1875
- maxRequestsPerUser;
1876
- onVerificationRequired;
1877
- constructor(config) {
1878
- this.userStore = config.userStore;
1879
- this.verificationStore = config.verificationStore ?? new InMemoryEmailVerificationStore();
1880
- this.tokenTtlMs = config.tokenTtlMs ?? DEFAULT_TOKEN_TTL_MS2;
1881
- this.maxRequestsPerUser = config.maxRequestsPerUser ?? DEFAULT_MAX_REQUESTS2;
1882
- this.onVerificationRequired = config.onVerificationRequired;
1869
+ /**
1870
+ * Set a user's email verification status.
1871
+ *
1872
+ * @param userId - The user whose email to verify
1873
+ * @param verified - Whether the email is verified
1874
+ */
1875
+ async setEmailVerified(userId, verified) {
1876
+ const user = this.usersById.get(userId);
1877
+ if (!user) return;
1878
+ const updated = { ...user, emailVerified: verified };
1879
+ this.usersById.set(userId, updated);
1880
+ this.usersByEmail.set(user.email, updated);
1883
1881
  }
1884
1882
  /**
1885
- * Send a verification email for a user.
1886
- * Rate-limited to prevent abuse.
1883
+ * Update a user's password hash and salt.
1884
+ *
1885
+ * @param userId - The user whose password to update
1886
+ * @param passwordHash - New hex-encoded PBKDF2 derived key
1887
+ * @param salt - New hex-encoded salt
1887
1888
  */
1888
- async sendVerification(userId, email) {
1889
- const normalizedEmail = email.toLowerCase().trim();
1890
- const activeCount = await this.verificationStore.countActiveForUser(userId);
1891
- if (activeCount >= this.maxRequestsPerUser) {
1892
- return { status: 429, body: { error: "Too many verification requests. Please try again later." } };
1893
- }
1894
- const token = generateSecureToken2();
1895
- const now = Date.now();
1896
- const verificationToken = {
1897
- token,
1898
- userId,
1899
- email: normalizedEmail,
1900
- createdAt: now,
1901
- expiresAt: now + this.tokenTtlMs,
1902
- consumed: false
1903
- };
1904
- await this.verificationStore.store(verificationToken);
1905
- if (this.onVerificationRequired) {
1906
- try {
1907
- await this.onVerificationRequired(normalizedEmail, token, verificationToken.expiresAt);
1908
- } catch {
1909
- }
1910
- }
1911
- const responseData = {
1912
- message: "Verification email sent."
1913
- };
1914
- if (!this.onVerificationRequired) {
1915
- responseData.token = token;
1916
- }
1917
- return { status: 200, body: { data: responseData } };
1889
+ async updatePassword(userId, passwordHash, salt) {
1890
+ const user = this.usersById.get(userId);
1891
+ if (!user) return;
1892
+ const updated = { ...user, passwordHash, salt };
1893
+ this.usersById.set(userId, updated);
1894
+ this.usersByEmail.set(user.email, updated);
1918
1895
  }
1919
1896
  /**
1920
- * Verify an email using a verification token.
1897
+ * List all users. For admin/development use.
1921
1898
  */
1922
- async verifyEmail(token) {
1923
- const verificationToken = await this.verificationStore.get(token);
1924
- if (!verificationToken || verificationToken.consumed) {
1925
- return { status: 404, body: { error: "Verification token not found or already used." } };
1926
- }
1927
- if (Date.now() > verificationToken.expiresAt) {
1928
- await this.verificationStore.consume(token);
1929
- return { status: 410, body: { error: "Verification token has expired." } };
1899
+ async listAll() {
1900
+ return [...this.usersById.values()];
1901
+ }
1902
+ /**
1903
+ * Update a stored user record.
1904
+ */
1905
+ async update(user) {
1906
+ const existing = this.usersById.get(user.id);
1907
+ if (!existing) return;
1908
+ if (existing.email !== user.email) {
1909
+ this.usersByEmail.delete(existing.email);
1910
+ this.usersByEmail.set(user.email, user);
1911
+ } else {
1912
+ this.usersByEmail.set(user.email, user);
1930
1913
  }
1931
- await this.verificationStore.consume(token);
1932
- await this.userStore.setEmailVerified(verificationToken.userId, true);
1933
- return {
1934
- status: 200,
1935
- body: {
1936
- data: {
1937
- message: "Email verified successfully.",
1938
- userId: verificationToken.userId,
1939
- email: verificationToken.email
1940
- }
1914
+ this.usersById.set(user.id, user);
1915
+ }
1916
+ /**
1917
+ * Delete a user and all associated devices.
1918
+ */
1919
+ async delete(userId) {
1920
+ const user = this.usersById.get(userId);
1921
+ if (!user) return;
1922
+ this.usersById.delete(userId);
1923
+ this.usersByEmail.delete(user.email);
1924
+ const deviceIds = this.devicesByUserId.get(userId);
1925
+ if (deviceIds) {
1926
+ for (const deviceId of deviceIds) {
1927
+ this.devicesById.delete(deviceId);
1941
1928
  }
1942
- };
1929
+ this.devicesByUserId.delete(userId);
1930
+ }
1943
1931
  }
1944
1932
  /**
1945
- * Resend verification email for a user.
1946
- * Delegates to sendVerification with rate limiting.
1933
+ * Update the last-seen timestamp for a device.
1934
+ *
1935
+ * Called when a device authenticates or syncs to track activity.
1936
+ * If the device does not exist, this is a no-op.
1937
+ *
1938
+ * @param deviceId - The ID of the device to update
1947
1939
  */
1948
- async resendVerification(userId) {
1949
- const user = await this.userStore.findById(userId);
1950
- if (!user) {
1951
- return { status: 404, body: { error: "User not found." } };
1940
+ async touchDevice(deviceId) {
1941
+ const device = this.devicesById.get(deviceId);
1942
+ if (device !== void 0) {
1943
+ device.lastSeenAt = Date.now();
1952
1944
  }
1953
- return this.sendVerification(userId, user.email);
1954
1945
  }
1955
1946
  };
1956
- function generateSecureToken2() {
1957
- const bytes = new Uint8Array(32);
1958
- globalThis.crypto.getRandomValues(bytes);
1959
- let binary = "";
1960
- for (let i = 0; i < bytes.length; i++) {
1961
- binary += String.fromCharCode(bytes[i]);
1962
- }
1963
- return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1947
+ function toAuthUser(stored) {
1948
+ return {
1949
+ id: stored.id,
1950
+ email: stored.email,
1951
+ name: stored.name,
1952
+ emailVerified: stored.emailVerified,
1953
+ createdAt: stored.createdAt
1954
+ };
1964
1955
  }
1965
1956
 
1966
1957
  // src/provider/built-in/sqlite-user-store.ts
@@ -2017,21 +2008,15 @@ var SqliteUserStore = class {
2017
2008
  return { id, email: normalizedEmail, name: params.name, emailVerified: false, createdAt: now };
2018
2009
  }
2019
2010
  async findByEmail(email) {
2020
- const row = this.db.prepare(
2021
- "SELECT * FROM auth_users WHERE email = ?"
2022
- ).get(email.toLowerCase());
2011
+ const row = this.db.prepare("SELECT * FROM auth_users WHERE email = ?").get(email.toLowerCase());
2023
2012
  return row ? rowToStoredUser(row) : null;
2024
2013
  }
2025
2014
  async findById(id) {
2026
- const row = this.db.prepare(
2027
- "SELECT * FROM auth_users WHERE id = ?"
2028
- ).get(id);
2015
+ const row = this.db.prepare("SELECT * FROM auth_users WHERE id = ?").get(id);
2029
2016
  return row ? rowToStoredUser(row) : null;
2030
2017
  }
2031
2018
  async registerDevice(params) {
2032
- const existing = this.db.prepare(
2033
- "SELECT * FROM auth_devices WHERE id = ?"
2034
- ).get(params.id);
2019
+ const existing = this.db.prepare("SELECT * FROM auth_devices WHERE id = ?").get(params.id);
2035
2020
  if (existing && !existing.revoked) {
2036
2021
  return rowToDevice(existing);
2037
2022
  }
@@ -2058,29 +2043,21 @@ var SqliteUserStore = class {
2058
2043
  };
2059
2044
  }
2060
2045
  async findDevice(deviceId) {
2061
- const row = this.db.prepare(
2062
- "SELECT * FROM auth_devices WHERE id = ?"
2063
- ).get(deviceId);
2046
+ const row = this.db.prepare("SELECT * FROM auth_devices WHERE id = ?").get(deviceId);
2064
2047
  return row ? rowToDevice(row) : null;
2065
2048
  }
2066
2049
  async listDevices(userId) {
2067
- const rows = this.db.prepare(
2068
- "SELECT * FROM auth_devices WHERE user_id = ?"
2069
- ).all(userId);
2050
+ const rows = this.db.prepare("SELECT * FROM auth_devices WHERE user_id = ?").all(userId);
2070
2051
  return rows.map(rowToDevice);
2071
2052
  }
2072
2053
  async revokeDevice(deviceId) {
2073
2054
  this.db.prepare("UPDATE auth_devices SET revoked = 1 WHERE id = ?").run(deviceId);
2074
2055
  }
2075
2056
  async setEmailVerified(userId, verified) {
2076
- this.db.prepare(
2077
- "UPDATE auth_users SET email_verified = ? WHERE id = ?"
2078
- ).run(verified ? 1 : 0, userId);
2057
+ this.db.prepare("UPDATE auth_users SET email_verified = ? WHERE id = ?").run(verified ? 1 : 0, userId);
2079
2058
  }
2080
2059
  async updatePassword(userId, passwordHash, salt) {
2081
- this.db.prepare(
2082
- "UPDATE auth_users SET password_hash = ?, salt = ? WHERE id = ?"
2083
- ).run(passwordHash, salt, userId);
2060
+ this.db.prepare("UPDATE auth_users SET password_hash = ?, salt = ? WHERE id = ?").run(passwordHash, salt, userId);
2084
2061
  }
2085
2062
  async listAll() {
2086
2063
  const rows = this.db.prepare("SELECT * FROM auth_users").all();
@@ -2101,9 +2078,7 @@ var SqliteUserStore = class {
2101
2078
  deleteInTransaction();
2102
2079
  }
2103
2080
  async touchDevice(deviceId) {
2104
- this.db.prepare(
2105
- "UPDATE auth_devices SET last_seen_at = ? WHERE id = ?"
2106
- ).run(Date.now(), deviceId);
2081
+ this.db.prepare("UPDATE auth_devices SET last_seen_at = ? WHERE id = ?").run(Date.now(), deviceId);
2107
2082
  }
2108
2083
  };
2109
2084
  async function createSqliteUserStore(options) {
@@ -2222,7 +2197,7 @@ var PostgresUserStore = class {
2222
2197
  const existingRows = await this.sql`
2223
2198
  SELECT * FROM auth_devices WHERE id = ${params.id}
2224
2199
  `;
2225
- if (existingRows.length > 0 && !existingRows[0].revoked) {
2200
+ if (existingRows.length > 0 && !existingRows[0]?.revoked) {
2226
2201
  return rowToDevice2(existingRows[0]);
2227
2202
  }
2228
2203
  const now = Date.now();
@@ -2243,7 +2218,7 @@ var PostgresUserStore = class {
2243
2218
  publicKey: params.publicKey,
2244
2219
  name: params.name,
2245
2220
  revoked: false,
2246
- createdAt: existingRows.length > 0 ? Number(existingRows[0].created_at) : now,
2221
+ createdAt: existingRows.length > 0 ? Number(existingRows[0]?.created_at) : now,
2247
2222
  lastSeenAt: now
2248
2223
  };
2249
2224
  }
@@ -2440,16 +2415,12 @@ var ExternalAuthOperationNotSupportedError = class extends import_core6.KoraErro
2440
2415
  };
2441
2416
  var ExternalTokenValidationError = class extends import_core6.KoraError {
2442
2417
  constructor(reason, context) {
2443
- super(
2444
- `External token validation failed: ${reason}`,
2445
- "AUTH_EXTERNAL_TOKEN_INVALID",
2446
- context
2447
- );
2418
+ super(`External token validation failed: ${reason}`, "AUTH_EXTERNAL_TOKEN_INVALID", context);
2448
2419
  this.name = "ExternalTokenValidationError";
2449
2420
  }
2450
2421
  };
2451
2422
  function defaultMapClaims(claims) {
2452
- const sub = claims["sub"];
2423
+ const sub = claims.sub;
2453
2424
  if (typeof sub !== "string" || sub.length === 0) {
2454
2425
  throw new ExternalTokenValidationError(
2455
2426
  'JWT is missing a valid "sub" (subject) claim. The "sub" claim must be a non-empty string identifying the user.',
@@ -2458,8 +2429,8 @@ function defaultMapClaims(claims) {
2458
2429
  }
2459
2430
  return {
2460
2431
  userId: sub,
2461
- email: typeof claims["email"] === "string" ? claims["email"] : void 0,
2462
- name: typeof claims["name"] === "string" ? claims["name"] : void 0,
2432
+ email: typeof claims.email === "string" ? claims.email : void 0,
2433
+ name: typeof claims.name === "string" ? claims.name : void 0,
2463
2434
  metadata: void 0
2464
2435
  };
2465
2436
  }
@@ -2659,23 +2630,23 @@ var ExternalJwtProvider = class {
2659
2630
  // src/provider/external/clerk-adapter.ts
2660
2631
  init_cjs_shims();
2661
2632
  function defaultClerkClaimMapping(claims) {
2662
- const sub = claims["sub"];
2633
+ const sub = claims.sub;
2663
2634
  if (typeof sub !== "string" || sub.length === 0) {
2664
2635
  return { userId: "" };
2665
2636
  }
2666
- const firstName = typeof claims["first_name"] === "string" ? claims["first_name"] : "";
2667
- const lastName = typeof claims["last_name"] === "string" ? claims["last_name"] : "";
2637
+ const firstName = typeof claims.first_name === "string" ? claims.first_name : "";
2638
+ const lastName = typeof claims.last_name === "string" ? claims.last_name : "";
2668
2639
  const fullName = [firstName, lastName].filter(Boolean).join(" ");
2669
- const email = typeof claims["email"] === "string" ? claims["email"] : void 0;
2640
+ const email = typeof claims.email === "string" ? claims.email : void 0;
2670
2641
  const metadata = {};
2671
- if (typeof claims["org_id"] === "string") {
2672
- metadata["orgId"] = claims["org_id"];
2642
+ if (typeof claims.org_id === "string") {
2643
+ metadata.orgId = claims.org_id;
2673
2644
  }
2674
- if (typeof claims["org_slug"] === "string") {
2675
- metadata["orgSlug"] = claims["org_slug"];
2645
+ if (typeof claims.org_slug === "string") {
2646
+ metadata.orgSlug = claims.org_slug;
2676
2647
  }
2677
- if (typeof claims["org_role"] === "string") {
2678
- metadata["orgRole"] = claims["org_role"];
2648
+ if (typeof claims.org_role === "string") {
2649
+ metadata.orgRole = claims.org_role;
2679
2650
  }
2680
2651
  return {
2681
2652
  userId: sub,
@@ -2695,30 +2666,30 @@ function createClerkAdapter(config) {
2695
2666
  // src/provider/external/supabase-adapter.ts
2696
2667
  init_cjs_shims();
2697
2668
  function defaultSupabaseClaimMapping(claims) {
2698
- const sub = claims["sub"];
2669
+ const sub = claims.sub;
2699
2670
  if (typeof sub !== "string" || sub.length === 0) {
2700
2671
  return { userId: "" };
2701
2672
  }
2702
- const email = typeof claims["email"] === "string" ? claims["email"] : void 0;
2673
+ const email = typeof claims.email === "string" ? claims.email : void 0;
2703
2674
  let name;
2704
- const userMetadata = claims["user_metadata"];
2675
+ const userMetadata = claims.user_metadata;
2705
2676
  if (typeof userMetadata === "object" && userMetadata !== null && !Array.isArray(userMetadata)) {
2706
2677
  const meta = userMetadata;
2707
- if (typeof meta["full_name"] === "string" && meta["full_name"].length > 0) {
2708
- name = meta["full_name"];
2709
- } else if (typeof meta["name"] === "string" && meta["name"].length > 0) {
2710
- name = meta["name"];
2678
+ if (typeof meta.full_name === "string" && meta.full_name.length > 0) {
2679
+ name = meta.full_name;
2680
+ } else if (typeof meta.name === "string" && meta.name.length > 0) {
2681
+ name = meta.name;
2711
2682
  }
2712
2683
  }
2713
2684
  const metadata = {};
2714
- if (typeof claims["role"] === "string") {
2715
- metadata["role"] = claims["role"];
2685
+ if (typeof claims.role === "string") {
2686
+ metadata.role = claims.role;
2716
2687
  }
2717
- if (typeof claims["aud"] === "string") {
2718
- metadata["aud"] = claims["aud"];
2688
+ if (typeof claims.aud === "string") {
2689
+ metadata.aud = claims.aud;
2719
2690
  }
2720
- if (typeof claims["app_metadata"] === "object" && claims["app_metadata"] !== null && !Array.isArray(claims["app_metadata"])) {
2721
- metadata["appMetadata"] = claims["app_metadata"];
2691
+ if (typeof claims.app_metadata === "object" && claims.app_metadata !== null && !Array.isArray(claims.app_metadata)) {
2692
+ metadata.appMetadata = claims.app_metadata;
2722
2693
  }
2723
2694
  return {
2724
2695
  userId: sub,
@@ -2750,53 +2721,54 @@ var PasskeyError = class extends import_core7.KoraError {
2750
2721
  }
2751
2722
  };
2752
2723
  function decodeCbor(data, offset) {
2753
- if (offset >= data.length) {
2724
+ let pos = offset;
2725
+ if (pos >= data.length) {
2754
2726
  throw new PasskeyError("CBOR decode error: unexpected end of data.");
2755
2727
  }
2756
- const initialByte = data[offset];
2728
+ const initialByte = data[pos];
2757
2729
  const majorType = initialByte >> 5;
2758
2730
  const additionalInfo = initialByte & 31;
2759
- offset += 1;
2731
+ pos += 1;
2760
2732
  let argument;
2761
2733
  if (additionalInfo < 24) {
2762
2734
  argument = additionalInfo;
2763
2735
  } else if (additionalInfo === 24) {
2764
- argument = data[offset];
2765
- offset += 1;
2736
+ argument = data[pos];
2737
+ pos += 1;
2766
2738
  } else if (additionalInfo === 25) {
2767
- argument = data[offset] << 8 | data[offset + 1];
2768
- offset += 2;
2739
+ argument = data[pos] << 8 | data[pos + 1];
2740
+ pos += 2;
2769
2741
  } else if (additionalInfo === 26) {
2770
- argument = data[offset] << 24 | data[offset + 1] << 16 | data[offset + 2] << 8 | data[offset + 3];
2742
+ argument = data[pos] << 24 | data[pos + 1] << 16 | data[pos + 2] << 8 | data[pos + 3];
2771
2743
  argument = argument >>> 0;
2772
- offset += 4;
2744
+ pos += 4;
2773
2745
  } else {
2774
2746
  throw new PasskeyError(
2775
- `CBOR decode error: unsupported additional info ${additionalInfo} at byte ${offset - 1}. This CBOR decoder only supports definite-length encodings.`
2747
+ `CBOR decode error: unsupported additional info ${additionalInfo} at byte ${pos - 1}. This CBOR decoder only supports definite-length encodings.`
2776
2748
  );
2777
2749
  }
2778
2750
  switch (majorType) {
2779
2751
  // Major type 0: Unsigned integer
2780
2752
  case 0:
2781
- return { value: argument, offset };
2753
+ return { value: argument, offset: pos };
2782
2754
  // Major type 1: Negative integer (-1 - argument)
2783
2755
  case 1:
2784
- return { value: -1 - argument, offset };
2756
+ return { value: -1 - argument, offset: pos };
2785
2757
  // Major type 2: Byte string
2786
2758
  case 2: {
2787
- const bytes = data.slice(offset, offset + argument);
2788
- return { value: bytes, offset: offset + argument };
2759
+ const bytes = data.slice(pos, pos + argument);
2760
+ return { value: bytes, offset: pos + argument };
2789
2761
  }
2790
2762
  // Major type 3: Text string (UTF-8)
2791
2763
  case 3: {
2792
- const textBytes = data.slice(offset, offset + argument);
2764
+ const textBytes = data.slice(pos, pos + argument);
2793
2765
  const text = new TextDecoder().decode(textBytes);
2794
- return { value: text, offset: offset + argument };
2766
+ return { value: text, offset: pos + argument };
2795
2767
  }
2796
2768
  // Major type 4: Array
2797
2769
  case 4: {
2798
2770
  const arr = [];
2799
- let currentOffset = offset;
2771
+ let currentOffset = pos;
2800
2772
  for (let i = 0; i < argument; i++) {
2801
2773
  const item = decodeCbor(data, currentOffset);
2802
2774
  arr.push(item.value);
@@ -2807,7 +2779,7 @@ function decodeCbor(data, offset) {
2807
2779
  // Major type 5: Map
2808
2780
  case 5: {
2809
2781
  const map = /* @__PURE__ */ new Map();
2810
- let currentOffset = offset;
2782
+ let currentOffset = pos;
2811
2783
  for (let i = 0; i < argument; i++) {
2812
2784
  const keyResult = decodeCbor(data, currentOffset);
2813
2785
  const valResult = decodeCbor(data, keyResult.offset);
@@ -2818,7 +2790,7 @@ function decodeCbor(data, offset) {
2818
2790
  }
2819
2791
  default:
2820
2792
  throw new PasskeyError(
2821
- `CBOR decode error: unsupported major type ${majorType} at byte ${offset - 1}. This CBOR decoder only supports types 0-5 (integers, byte/text strings, arrays, maps).`
2793
+ `CBOR decode error: unsupported major type ${majorType} at byte ${pos - 1}. This CBOR decoder only supports types 0-5 (integers, byte/text strings, arrays, maps).`
2822
2794
  );
2823
2795
  }
2824
2796
  }
@@ -2832,10 +2804,12 @@ var PasskeyVerificationError = class extends import_core8.KoraError {
2832
2804
  };
2833
2805
  function generateRegistrationOptions(params) {
2834
2806
  const challengeBytes = (0, import_node_crypto8.randomBytes)(32);
2835
- const challenge = toBase64Url(challengeBytes.buffer.slice(
2836
- challengeBytes.byteOffset,
2837
- challengeBytes.byteOffset + challengeBytes.byteLength
2838
- ));
2807
+ const challenge = toBase64Url(
2808
+ challengeBytes.buffer.slice(
2809
+ challengeBytes.byteOffset,
2810
+ challengeBytes.byteOffset + challengeBytes.byteLength
2811
+ )
2812
+ );
2839
2813
  return {
2840
2814
  challenge,
2841
2815
  rpId: params.rpId,
@@ -2945,10 +2919,12 @@ async function verifyRegistrationResponse(params) {
2945
2919
  }
2946
2920
  function generateAuthenticationOptions(params) {
2947
2921
  const challengeBytes = (0, import_node_crypto8.randomBytes)(32);
2948
- const challenge = toBase64Url(challengeBytes.buffer.slice(
2949
- challengeBytes.byteOffset,
2950
- challengeBytes.byteOffset + challengeBytes.byteLength
2951
- ));
2922
+ const challenge = toBase64Url(
2923
+ challengeBytes.buffer.slice(
2924
+ challengeBytes.byteOffset,
2925
+ challengeBytes.byteOffset + challengeBytes.byteLength
2926
+ )
2927
+ );
2952
2928
  return {
2953
2929
  challenge,
2954
2930
  rpId: params.rpId,
@@ -3003,9 +2979,7 @@ async function verifyAuthenticationResponse(params) {
3003
2979
  }
3004
2980
  const flags = authDataBytes[32];
3005
2981
  if ((flags & 1) === 0) {
3006
- throw new PasskeyVerificationError(
3007
- "User Present flag is not set in authenticator data."
3008
- );
2982
+ throw new PasskeyVerificationError("User Present flag is not set in authenticator data.");
3009
2983
  }
3010
2984
  const signCount = (authDataBytes[33] << 24 | authDataBytes[34] << 16 | authDataBytes[35] << 8 | authDataBytes[36]) >>> 0;
3011
2985
  if (previousSignCount > 0 || signCount > 0) {
@@ -3020,9 +2994,7 @@ async function verifyAuthenticationResponse(params) {
3020
2994
  }
3021
2995
  }
3022
2996
  const clientDataHash = await sha256(clientDataBytes);
3023
- const signedData = new Uint8Array(
3024
- authDataBytes.length + clientDataHash.byteLength
3025
- );
2997
+ const signedData = new Uint8Array(authDataBytes.length + clientDataHash.byteLength);
3026
2998
  signedData.set(authDataBytes, 0);
3027
2999
  signedData.set(new Uint8Array(clientDataHash), authDataBytes.length);
3028
3000
  const coseKeyBytes = fromBase64Url(publicKey);
@@ -3079,10 +3051,9 @@ async function verifyAuthenticationResponse(params) {
3079
3051
  signedData.buffer
3080
3052
  );
3081
3053
  } catch (error) {
3082
- throw new PasskeyVerificationError(
3083
- "Signature verification operation failed.",
3084
- { cause: error instanceof Error ? error.message : String(error) }
3085
- );
3054
+ throw new PasskeyVerificationError("Signature verification operation failed.", {
3055
+ cause: error instanceof Error ? error.message : String(error)
3056
+ });
3086
3057
  }
3087
3058
  if (!verified) {
3088
3059
  return { verified: false, newSignCount: signCount };
@@ -3105,9 +3076,7 @@ function constantTimeEqual(a, b) {
3105
3076
  function derToP1363(derSignature, componentLength) {
3106
3077
  let offset = 0;
3107
3078
  if (derSignature[offset] !== 48) {
3108
- throw new PasskeyVerificationError(
3109
- "Invalid DER signature: expected SEQUENCE tag (0x30)."
3110
- );
3079
+ throw new PasskeyVerificationError("Invalid DER signature: expected SEQUENCE tag (0x30).");
3111
3080
  }
3112
3081
  offset += 1;
3113
3082
  if (derSignature[offset] & 128) {
@@ -3356,14 +3325,11 @@ var OperationEncryptor = class {
3356
3325
  if (cause instanceof OperationEncryptionError) {
3357
3326
  throw cause;
3358
3327
  }
3359
- throw new OperationEncryptionError(
3360
- `Failed to encrypt operation ${fieldName} field.`,
3361
- {
3362
- operationId,
3363
- fieldName,
3364
- cause: cause instanceof Error ? cause.message : String(cause)
3365
- }
3366
- );
3328
+ throw new OperationEncryptionError(`Failed to encrypt operation ${fieldName} field.`, {
3329
+ operationId,
3330
+ fieldName,
3331
+ cause: cause instanceof Error ? cause.message : String(cause)
3332
+ });
3367
3333
  }
3368
3334
  }
3369
3335
  async decryptField(field, operationId, fieldName) {
@@ -3387,10 +3353,10 @@ var OperationEncryptor = class {
3387
3353
  const json = new TextDecoder().decode(plaintextBytes);
3388
3354
  const parsed = JSON.parse(json);
3389
3355
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
3390
- throw new OperationEncryptionError(
3391
- `Decrypted ${fieldName} is not a valid record object.`,
3392
- { operationId, fieldName }
3393
- );
3356
+ throw new OperationEncryptionError(`Decrypted ${fieldName} is not a valid record object.`, {
3357
+ operationId,
3358
+ fieldName
3359
+ });
3394
3360
  }
3395
3361
  return parsed;
3396
3362
  } catch (cause) {
@@ -3415,7 +3381,7 @@ function isEncryptedEnvelope(field) {
3415
3381
  if (field === null || typeof field !== "object") {
3416
3382
  return false;
3417
3383
  }
3418
- return field[ENCRYPTED_MARKER] === true && typeof field["ciphertext"] === "string" && typeof field["iv"] === "string" && field["algorithm"] === "AES-256-GCM";
3384
+ return field[ENCRYPTED_MARKER] === true && typeof field.ciphertext === "string" && typeof field.iv === "string" && field.algorithm === "AES-256-GCM";
3419
3385
  }
3420
3386
 
3421
3387
  // src/org/org-routes.ts
@@ -3464,11 +3430,9 @@ var MemberAlreadyExistsError = class extends OrgError {
3464
3430
  };
3465
3431
  var InsufficientRoleError = class extends OrgError {
3466
3432
  constructor(required) {
3467
- super(
3468
- `This action requires at least the "${required}" role.`,
3469
- "INSUFFICIENT_ROLE",
3470
- { requiredRole: required }
3471
- );
3433
+ super(`This action requires at least the "${required}" role.`, "INSUFFICIENT_ROLE", {
3434
+ requiredRole: required
3435
+ });
3472
3436
  }
3473
3437
  };
3474
3438
  var CannotRemoveOwnerError = class extends OrgError {
@@ -3678,17 +3642,28 @@ var OrgRoutes = class {
3678
3642
  return { status: 400, body: { error: "Target user ID is required." } };
3679
3643
  }
3680
3644
  if (typeof params.role !== "string" || !isValidRole(params.role)) {
3681
- return { status: 400, body: { error: "A valid role is required (admin, member, viewer, billing)." } };
3645
+ return {
3646
+ status: 400,
3647
+ body: { error: "A valid role is required (admin, member, viewer, billing)." }
3648
+ };
3682
3649
  }
3683
3650
  if (params.role === "owner") {
3684
- return { status: 400, body: { error: "Cannot assign owner role directly. Use ownership transfer." } };
3651
+ return {
3652
+ status: 400,
3653
+ body: { error: "Cannot assign owner role directly. Use ownership transfer." }
3654
+ };
3685
3655
  }
3686
3656
  const callerMembership = await this.store.getMembership(orgId, userId);
3687
3657
  if (callerMembership && callerMembership.role !== "owner" && params.role === "admin") {
3688
3658
  return { status: 403, body: { error: "Only the owner can add admin members." } };
3689
3659
  }
3690
3660
  try {
3691
- const membership = await this.store.addMember(orgId, params.targetUserId, params.role, userId);
3661
+ const membership = await this.store.addMember(
3662
+ orgId,
3663
+ params.targetUserId,
3664
+ params.role,
3665
+ userId
3666
+ );
3692
3667
  return { status: 201, body: { data: membership } };
3693
3668
  } catch (err) {
3694
3669
  if (err instanceof OrgNotFoundError) {
@@ -3741,17 +3716,27 @@ var OrgRoutes = class {
3741
3716
  return { status: 400, body: { error: "Target user ID is required." } };
3742
3717
  }
3743
3718
  if (typeof params.role !== "string" || !isValidRole(params.role)) {
3744
- return { status: 400, body: { error: "A valid role is required (admin, member, viewer, billing)." } };
3719
+ return {
3720
+ status: 400,
3721
+ body: { error: "A valid role is required (admin, member, viewer, billing)." }
3722
+ };
3745
3723
  }
3746
3724
  if (params.role === "owner") {
3747
- return { status: 400, body: { error: "Cannot assign owner role directly. Use ownership transfer." } };
3725
+ return {
3726
+ status: 400,
3727
+ body: { error: "Cannot assign owner role directly. Use ownership transfer." }
3728
+ };
3748
3729
  }
3749
3730
  const callerMembership = await this.store.getMembership(orgId, userId);
3750
3731
  if (callerMembership && callerMembership.role !== "owner" && params.role === "admin") {
3751
3732
  return { status: 403, body: { error: "Only the owner can assign admin role." } };
3752
3733
  }
3753
3734
  try {
3754
- const membership = await this.store.updateMemberRole(orgId, params.targetUserId, params.role);
3735
+ const membership = await this.store.updateMemberRole(
3736
+ orgId,
3737
+ params.targetUserId,
3738
+ params.role
3739
+ );
3755
3740
  return { status: 200, body: { data: membership } };
3756
3741
  } catch (err) {
3757
3742
  if (err instanceof MembershipNotFoundError) {
@@ -3814,10 +3799,16 @@ var OrgRoutes = class {
3814
3799
  return { status: 400, body: { error: "A valid email address is required." } };
3815
3800
  }
3816
3801
  if (typeof params.role !== "string" || !isValidRole(params.role)) {
3817
- return { status: 400, body: { error: "A valid role is required (admin, member, viewer, billing)." } };
3802
+ return {
3803
+ status: 400,
3804
+ body: { error: "A valid role is required (admin, member, viewer, billing)." }
3805
+ };
3818
3806
  }
3819
3807
  if (params.role === "owner") {
3820
- return { status: 400, body: { error: "Cannot invite with owner role. Use ownership transfer." } };
3808
+ return {
3809
+ status: 400,
3810
+ body: { error: "Cannot invite with owner role. Use ownership transfer." }
3811
+ };
3821
3812
  }
3822
3813
  const callerMembership = await this.store.getMembership(orgId, userId);
3823
3814
  if (callerMembership && callerMembership.role !== "owner" && params.role === "admin") {
@@ -4269,20 +4260,14 @@ var InvalidPermissionError = class extends RbacError {
4269
4260
  };
4270
4261
  var RoleNotFoundError = class extends RbacError {
4271
4262
  constructor(role) {
4272
- super(
4273
- `Role "${role}" is not defined.`,
4274
- "ROLE_NOT_FOUND",
4275
- { role }
4276
- );
4263
+ super(`Role "${role}" is not defined.`, "ROLE_NOT_FOUND", { role });
4277
4264
  }
4278
4265
  };
4279
4266
  var CircularInheritanceError = class extends RbacError {
4280
4267
  constructor(chain) {
4281
- super(
4282
- `Circular role inheritance detected: ${chain.join(" \u2192 ")}.`,
4283
- "CIRCULAR_INHERITANCE",
4284
- { chain }
4285
- );
4268
+ super(`Circular role inheritance detected: ${chain.join(" \u2192 ")}.`, "CIRCULAR_INHERITANCE", {
4269
+ chain
4270
+ });
4286
4271
  }
4287
4272
  };
4288
4273
 
@@ -4383,15 +4368,11 @@ var RbacEngine = class {
4383
4368
  permissions
4384
4369
  };
4385
4370
  const scopes = {};
4386
- const hasAnyRead = permissions.some(
4387
- (p) => permissionCovers(p, "*:read")
4388
- );
4371
+ const hasAnyRead = permissions.some((p) => permissionCovers(p, "*:read"));
4389
4372
  if (!hasAnyRead) {
4390
4373
  return scopes;
4391
4374
  }
4392
- const isReadOnly = !permissions.some(
4393
- (p) => permissionCovers(p, "*:write")
4394
- );
4375
+ const isReadOnly = !permissions.some((p) => permissionCovers(p, "*:write"));
4395
4376
  const collectionsToResolve = collections ?? [...this.collectionResolvers.keys()];
4396
4377
  for (const collection of collectionsToResolve) {
4397
4378
  const customResolver = this.collectionResolvers.get(collection);
@@ -4553,21 +4534,13 @@ var OrgScopeResolver = class {
4553
4534
  * Check if a user can write to a specific collection in an org.
4554
4535
  */
4555
4536
  async canWrite(userId, orgId, collection) {
4556
- return this.rbac.hasPermission(
4557
- userId,
4558
- orgId,
4559
- `${collection}:write`
4560
- );
4537
+ return this.rbac.hasPermission(userId, orgId, `${collection}:write`);
4561
4538
  }
4562
4539
  /**
4563
4540
  * Check if a user can read from a specific collection in an org.
4564
4541
  */
4565
4542
  async canRead(userId, orgId, collection) {
4566
- return this.rbac.hasPermission(
4567
- userId,
4568
- orgId,
4569
- `${collection}:read`
4570
- );
4543
+ return this.rbac.hasPermission(userId, orgId, `${collection}:read`);
4571
4544
  }
4572
4545
  // --- Private ---
4573
4546
  resolveCollectionScope(ctx, collection) {
@@ -4627,7 +4600,9 @@ var OAuthUserInfoError = class extends OAuthError {
4627
4600
  };
4628
4601
  var OAuthProviderNotFoundError = class extends OAuthError {
4629
4602
  constructor(provider) {
4630
- super(`OAuth provider "${provider}" is not configured.`, "OAUTH_PROVIDER_NOT_FOUND", { provider });
4603
+ super(`OAuth provider "${provider}" is not configured.`, "OAUTH_PROVIDER_NOT_FOUND", {
4604
+ provider
4605
+ });
4631
4606
  }
4632
4607
  };
4633
4608
 
@@ -4753,9 +4728,7 @@ var OAuthManager = class {
4753
4728
  body: body.toString()
4754
4729
  });
4755
4730
  } catch (err) {
4756
- throw new OAuthCodeExchangeError(
4757
- err instanceof Error ? err.message : "Network error"
4758
- );
4731
+ throw new OAuthCodeExchangeError(err instanceof Error ? err.message : "Network error");
4759
4732
  }
4760
4733
  if (!response.ok) {
4761
4734
  let details = `HTTP ${response.status}`;
@@ -4768,12 +4741,12 @@ var OAuthManager = class {
4768
4741
  }
4769
4742
  const data = await response.json();
4770
4743
  return {
4771
- accessToken: data["access_token"],
4772
- tokenType: data["token_type"] ?? "Bearer",
4773
- expiresIn: data["expires_in"],
4774
- refreshToken: data["refresh_token"],
4775
- idToken: data["id_token"],
4776
- scope: data["scope"]
4744
+ accessToken: data.access_token,
4745
+ tokenType: data.token_type ?? "Bearer",
4746
+ expiresIn: data.expires_in,
4747
+ refreshToken: data.refresh_token,
4748
+ idToken: data.id_token,
4749
+ scope: data.scope
4777
4750
  };
4778
4751
  }
4779
4752
  async fetchUserInfo(provider, accessToken) {
@@ -4786,9 +4759,7 @@ var OAuthManager = class {
4786
4759
  }
4787
4760
  });
4788
4761
  } catch (err) {
4789
- throw new OAuthUserInfoError(
4790
- err instanceof Error ? err.message : "Network error"
4791
- );
4762
+ throw new OAuthUserInfoError(err instanceof Error ? err.message : "Network error");
4792
4763
  }
4793
4764
  if (!response.ok) {
4794
4765
  throw new OAuthUserInfoError(`HTTP ${response.status}`);
@@ -4801,43 +4772,43 @@ function normalizeUserInfo(providerId, profile) {
4801
4772
  switch (providerId) {
4802
4773
  case "google":
4803
4774
  return {
4804
- providerId: profile["sub"],
4775
+ providerId: profile.sub,
4805
4776
  provider: "google",
4806
- email: profile["email"] ?? null,
4807
- emailVerified: profile["email_verified"] ?? false,
4808
- name: profile["name"] ?? null,
4809
- avatarUrl: profile["picture"] ?? null,
4777
+ email: profile.email ?? null,
4778
+ emailVerified: profile.email_verified ?? false,
4779
+ name: profile.name ?? null,
4780
+ avatarUrl: profile.picture ?? null,
4810
4781
  rawProfile: profile
4811
4782
  };
4812
4783
  case "github":
4813
4784
  return {
4814
- providerId: String(profile["id"]),
4785
+ providerId: String(profile.id),
4815
4786
  provider: "github",
4816
- email: profile["email"] ?? null,
4787
+ email: profile.email ?? null,
4817
4788
  emailVerified: false,
4818
4789
  // GitHub doesn't confirm in the profile response
4819
- name: profile["name"] ?? profile["login"] ?? null,
4820
- avatarUrl: profile["avatar_url"] ?? null,
4790
+ name: profile.name ?? profile.login ?? null,
4791
+ avatarUrl: profile.avatar_url ?? null,
4821
4792
  rawProfile: profile
4822
4793
  };
4823
4794
  case "microsoft":
4824
4795
  return {
4825
- providerId: profile["id"],
4796
+ providerId: profile.id,
4826
4797
  provider: "microsoft",
4827
- email: profile["mail"] ?? profile["userPrincipalName"] ?? null,
4798
+ email: profile.mail ?? profile.userPrincipalName ?? null,
4828
4799
  emailVerified: false,
4829
- name: profile["displayName"] ?? null,
4800
+ name: profile.displayName ?? null,
4830
4801
  avatarUrl: null,
4831
4802
  rawProfile: profile
4832
4803
  };
4833
4804
  default:
4834
4805
  return {
4835
- providerId: String(profile["id"] ?? profile["sub"] ?? ""),
4806
+ providerId: String(profile.id ?? profile.sub ?? ""),
4836
4807
  provider: providerId,
4837
- email: profile["email"] ?? null,
4838
- emailVerified: profile["email_verified"] ?? false,
4839
- name: profile["name"] ?? null,
4840
- avatarUrl: profile["picture"] ?? profile["avatar_url"] ?? null,
4808
+ email: profile.email ?? null,
4809
+ emailVerified: profile.email_verified ?? false,
4810
+ name: profile.name ?? null,
4811
+ avatarUrl: profile.picture ?? profile.avatar_url ?? null,
4841
4812
  rawProfile: profile
4842
4813
  };
4843
4814
  }
@@ -5115,7 +5086,7 @@ function generateSessionId() {
5115
5086
  globalThis.crypto.getRandomValues(bytes);
5116
5087
  let hex = "";
5117
5088
  for (let i = 0; i < bytes.length; i++) {
5118
- hex += bytes[i].toString(16).padStart(2, "0");
5089
+ hex += bytes[i]?.toString(16).padStart(2, "0");
5119
5090
  }
5120
5091
  return hex;
5121
5092
  }
@@ -5331,7 +5302,7 @@ var TotpManager = class {
5331
5302
  */
5332
5303
  async isEnabled(userId) {
5333
5304
  const stored = await this.store.getByUserId(userId);
5334
- return stored !== null && stored.verified;
5305
+ return stored?.verified ?? false;
5335
5306
  }
5336
5307
  /**
5337
5308
  * Get the number of remaining recovery codes for a user.
@@ -5365,7 +5336,7 @@ function generateTotpCode(secret, counter, digits, algorithm) {
5365
5336
  const hash = hmacSha(algorithm, secret, counterBytes);
5366
5337
  const offset = hash[hash.length - 1] & 15;
5367
5338
  const binary = (hash[offset] & 127) << 24 | (hash[offset + 1] & 255) << 16 | (hash[offset + 2] & 255) << 8 | hash[offset + 3] & 255;
5368
- const otp = binary % Math.pow(10, digits);
5339
+ const otp = binary % 10 ** digits;
5369
5340
  return otp.toString().padStart(digits, "0");
5370
5341
  }
5371
5342
  function hmacSha(algorithm, key, message) {
@@ -5409,7 +5380,10 @@ function sha1(data) {
5409
5380
  w[i] = view.getInt32(offset + i * 4, false);
5410
5381
  }
5411
5382
  for (let i = 16; i < 80; i++) {
5412
- w[i] = rotl32(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16] | 0, 1);
5383
+ w[i] = rotl32(
5384
+ w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16] | 0,
5385
+ 1
5386
+ );
5413
5387
  }
5414
5388
  let a = h0;
5415
5389
  let b = h1;
@@ -5518,7 +5492,7 @@ async function hashRecoveryCode(code) {
5518
5492
  const bytes = new Uint8Array(hash);
5519
5493
  let hex = "";
5520
5494
  for (let i = 0; i < bytes.length; i++) {
5521
- hex += bytes[i].toString(16).padStart(2, "0");
5495
+ hex += bytes[i]?.toString(16).padStart(2, "0");
5522
5496
  }
5523
5497
  return hex;
5524
5498
  }
@@ -5723,8 +5697,9 @@ var InMemoryAuditLogStore = class {
5723
5697
  const initialLength = this.entries.length;
5724
5698
  let writeIndex = 0;
5725
5699
  for (let i = 0; i < this.entries.length; i++) {
5726
- if (this.entries[i].timestamp >= timestamp) {
5727
- this.entries[writeIndex] = this.entries[i];
5700
+ const entry = this.entries[i];
5701
+ if (entry.timestamp >= timestamp) {
5702
+ this.entries[writeIndex] = entry;
5728
5703
  writeIndex++;
5729
5704
  }
5730
5705
  }
@@ -5815,7 +5790,7 @@ function generateAuditId() {
5815
5790
  globalThis.crypto.getRandomValues(bytes);
5816
5791
  let hex = "";
5817
5792
  for (let i = 0; i < bytes.length; i++) {
5818
- hex += bytes[i].toString(16).padStart(2, "0");
5793
+ hex += bytes[i]?.toString(16).padStart(2, "0");
5819
5794
  }
5820
5795
  return hex;
5821
5796
  }
@@ -5935,9 +5910,7 @@ var WebhookManager = class {
5935
5910
  */
5936
5911
  async dispatch(event, data) {
5937
5912
  const endpoints = await this.store.listEndpoints();
5938
- const matching = endpoints.filter(
5939
- (ep) => ep.active && ep.events.includes(event)
5940
- );
5913
+ const matching = endpoints.filter((ep) => ep.active && ep.events.includes(event));
5941
5914
  const payload = {
5942
5915
  id: generateId2(),
5943
5916
  event,
@@ -5945,9 +5918,7 @@ var WebhookManager = class {
5945
5918
  data
5946
5919
  };
5947
5920
  const payloadJson = JSON.stringify(payload);
5948
- await Promise.allSettled(
5949
- matching.map((ep) => this.deliverToEndpoint(ep, payloadJson, event))
5950
- );
5921
+ await Promise.allSettled(matching.map((ep) => this.deliverToEndpoint(ep, payloadJson, event)));
5951
5922
  }
5952
5923
  // --- Private ---
5953
5924
  async deliverToEndpoint(endpoint, payloadJson, event) {
@@ -6000,7 +5971,7 @@ function generateId2() {
6000
5971
  globalThis.crypto.getRandomValues(bytes);
6001
5972
  let hex = "";
6002
5973
  for (let i = 0; i < bytes.length; i++) {
6003
- hex += bytes[i].toString(16).padStart(2, "0");
5974
+ hex += bytes[i]?.toString(16).padStart(2, "0");
6004
5975
  }
6005
5976
  return hex;
6006
5977
  }
@@ -6009,7 +5980,7 @@ function generateSecret2() {
6009
5980
  globalThis.crypto.getRandomValues(bytes);
6010
5981
  let hex = "";
6011
5982
  for (let i = 0; i < bytes.length; i++) {
6012
- hex += bytes[i].toString(16).padStart(2, "0");
5983
+ hex += bytes[i]?.toString(16).padStart(2, "0");
6013
5984
  }
6014
5985
  return `whsec_${hex}`;
6015
5986
  }
@@ -6022,15 +5993,11 @@ async function signPayload(payload, secret) {
6022
5993
  false,
6023
5994
  ["sign"]
6024
5995
  );
6025
- const signature = await globalThis.crypto.subtle.sign(
6026
- "HMAC",
6027
- key,
6028
- encoder.encode(payload)
6029
- );
5996
+ const signature = await globalThis.crypto.subtle.sign("HMAC", key, encoder.encode(payload));
6030
5997
  const bytes = new Uint8Array(signature);
6031
5998
  let hex = "";
6032
5999
  for (let i = 0; i < bytes.length; i++) {
6033
- hex += bytes[i].toString(16).padStart(2, "0");
6000
+ hex += bytes[i]?.toString(16).padStart(2, "0");
6034
6001
  }
6035
6002
  return `sha256=${hex}`;
6036
6003
  }