@supacloud/lite 0.32.0 → 0.33.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -8,7 +8,7 @@ import { dirname as dirname8, join as join10, resolve as resolve6 } from "path";
8
8
  // package.json
9
9
  var package_default = {
10
10
  name: "@supacloud/lite",
11
- version: "0.32.0",
11
+ version: "0.33.1",
12
12
  description: "Bun-native, single-project Supabase-compatible backend powered by PGlite or native PostgreSQL",
13
13
  type: "module",
14
14
  license: "Apache-2.0",
@@ -67,17 +67,17 @@ var package_default = {
67
67
  "@electric-sql/pglite-pgmq": "0.0.8",
68
68
  "@electric-sql/pglite-pgvector": "0.0.11",
69
69
  "@electric-sql/pglite-postgis": "0.2.10",
70
- "@supacloud/db": "0.19.0",
70
+ "@supacloud/db": "0.20.0",
71
71
  "exact-mirror": "1.2.6",
72
72
  tar: "^7.5.22",
73
73
  typebox: "1.3.37"
74
74
  },
75
75
  devDependencies: {
76
- "@supacloud/app": "0.29.0",
77
- "@supacloud/contracts": "0.12.0",
78
- "@supacloud/compiler": "0.48.0",
79
- "@supacloud/elysia": "0.34.0",
80
- "@supabase/supabase-js": "^2.117.3",
76
+ "@supacloud/app": "0.31.0",
77
+ "@supacloud/contracts": "0.13.0",
78
+ "@supacloud/compiler": "0.50.0",
79
+ "@supacloud/elysia": "0.35.1",
80
+ "@supabase/supabase-js": "^2.118.0",
81
81
  "@types/bun": "^1.4.3",
82
82
  effect: "4.0.5",
83
83
  elysia: "2.0.0-beta.32",
@@ -85,13 +85,13 @@ var package_default = {
85
85
  typescript: "^7.0.2"
86
86
  },
87
87
  overrides: {
88
- "@supacloud/db": "0.19.0",
89
- "@supacloud/app": "0.29.0",
90
- "@supacloud/compiler": "0.48.0",
91
- "@supacloud/elysia": "0.34.0",
92
- "@supacloud/contracts": "0.12.0",
93
- "@supacloud/commands": "0.10.0",
94
- "@supacloud/delivery": "0.20.0",
88
+ "@supacloud/db": "0.20.0",
89
+ "@supacloud/app": "0.31.0",
90
+ "@supacloud/compiler": "0.50.0",
91
+ "@supacloud/elysia": "0.35.1",
92
+ "@supacloud/contracts": "0.13.0",
93
+ "@supacloud/commands": "0.11.0",
94
+ "@supacloud/delivery": "0.21.0",
95
95
  "@graphql-tools/utils": "^12.0.4"
96
96
  },
97
97
  engines: {
@@ -241,6 +241,727 @@ function isAllowedRedirect(requested, siteUrl, allowList = []) {
241
241
  return allowList.some((entry) => globToRegExp(entry).test(requested));
242
242
  }
243
243
 
244
+ // node_modules/jose/dist/webapi/lib/buffer_utils.js
245
+ var encoder2 = new TextEncoder;
246
+ var decoder2 = new TextDecoder;
247
+ var strictDecoder = new TextDecoder("utf-8", { fatal: true });
248
+ var MAX_INT32 = 2 ** 32;
249
+ function concat(...buffers) {
250
+ const size = buffers.reduce((acc, { length }) => acc + length, 0), buf = new Uint8Array(size);
251
+ let i = 0;
252
+ for (const buffer of buffers)
253
+ buf.set(buffer, i), i += buffer.length;
254
+ return buf;
255
+ }
256
+ var NON_ASCII = /[^\x00-\x7f]/;
257
+ function encode(string) {
258
+ if (typeof string == "string" && string.length >= 128) {
259
+ if (NON_ASCII.test(string))
260
+ throw new TypeError("non-ASCII string encountered in encode()");
261
+ return encoder2.encode(string);
262
+ }
263
+ const bytes = new Uint8Array(string.length);
264
+ for (let i = 0;i < string.length; i++) {
265
+ const code = string.charCodeAt(i);
266
+ if (code > 127)
267
+ throw new TypeError("non-ASCII string encountered in encode()");
268
+ bytes[i] = code;
269
+ }
270
+ return bytes;
271
+ }
272
+ function decodeBase64(encoded, url = false) {
273
+ if (Uint8Array.fromBase64)
274
+ return Uint8Array.fromBase64(encoded, { alphabet: url ? "base64url" : "base64" });
275
+ if (url) {
276
+ if (encoded.includes("+") || encoded.includes("/"))
277
+ throw new TypeError("Invalid base64url");
278
+ encoded = encoded.replace(/-/g, "+").replace(/_/g, "/");
279
+ }
280
+ const binary = atob(encoded), bytes = new Uint8Array(binary.length);
281
+ for (let i = 0;i < binary.length; i++)
282
+ bytes[i] = binary.charCodeAt(i);
283
+ return bytes;
284
+ }
285
+
286
+ // node_modules/jose/dist/webapi/util/errors.js
287
+ class JOSEError extends Error {
288
+ static code = "ERR_JOSE_GENERIC";
289
+ code = "ERR_JOSE_GENERIC";
290
+ constructor(message, options) {
291
+ super(message, options), this.name = this.constructor.name, Error.captureStackTrace?.(this, this.constructor);
292
+ }
293
+ }
294
+
295
+ class JWTClaimValidationFailed extends JOSEError {
296
+ static code = "ERR_JWT_CLAIM_VALIDATION_FAILED";
297
+ code = "ERR_JWT_CLAIM_VALIDATION_FAILED";
298
+ claim;
299
+ reason;
300
+ payload;
301
+ constructor(message, payload, claim = "unspecified", reason = "unspecified") {
302
+ super(message, { cause: { claim, reason, payload } }), this.claim = claim, this.reason = reason, this.payload = payload;
303
+ }
304
+ }
305
+
306
+ class JWTExpired extends JOSEError {
307
+ static code = "ERR_JWT_EXPIRED";
308
+ code = "ERR_JWT_EXPIRED";
309
+ claim;
310
+ reason;
311
+ payload;
312
+ constructor(message, payload, claim = "unspecified", reason = "unspecified") {
313
+ super(message, { cause: { claim, reason, payload } }), this.claim = claim, this.reason = reason, this.payload = payload;
314
+ }
315
+ }
316
+
317
+ class JOSEAlgNotAllowed extends JOSEError {
318
+ static code = "ERR_JOSE_ALG_NOT_ALLOWED";
319
+ code = "ERR_JOSE_ALG_NOT_ALLOWED";
320
+ }
321
+
322
+ class JOSENotSupported extends JOSEError {
323
+ static code = "ERR_JOSE_NOT_SUPPORTED";
324
+ code = "ERR_JOSE_NOT_SUPPORTED";
325
+ }
326
+ class JWSInvalid extends JOSEError {
327
+ static code = "ERR_JWS_INVALID";
328
+ code = "ERR_JWS_INVALID";
329
+ }
330
+
331
+ class JWTInvalid extends JOSEError {
332
+ static code = "ERR_JWT_INVALID";
333
+ code = "ERR_JWT_INVALID";
334
+ }
335
+ class JWKSInvalid extends JOSEError {
336
+ static code = "ERR_JWKS_INVALID";
337
+ code = "ERR_JWKS_INVALID";
338
+ }
339
+
340
+ class JWKSNoMatchingKey extends JOSEError {
341
+ static code = "ERR_JWKS_NO_MATCHING_KEY";
342
+ code = "ERR_JWKS_NO_MATCHING_KEY";
343
+ constructor(message = "no applicable key found in the JSON Web Key Set", options) {
344
+ super(message, options);
345
+ }
346
+ }
347
+
348
+ class JWKSMultipleMatchingKeys extends JOSEError {
349
+ [Symbol.asyncIterator] = async function* () {};
350
+ static code = "ERR_JWKS_MULTIPLE_MATCHING_KEYS";
351
+ code = "ERR_JWKS_MULTIPLE_MATCHING_KEYS";
352
+ constructor(message = "multiple matching keys found in the JSON Web Key Set", options) {
353
+ super(message, options);
354
+ }
355
+ }
356
+
357
+ class JWKSTimeout extends JOSEError {
358
+ static code = "ERR_JWKS_TIMEOUT";
359
+ code = "ERR_JWKS_TIMEOUT";
360
+ constructor(message = "request timed out", options) {
361
+ super(message, options);
362
+ }
363
+ }
364
+
365
+ class JWSSignatureVerificationFailed extends JOSEError {
366
+ static code = "ERR_JWS_SIGNATURE_VERIFICATION_FAILED";
367
+ code = "ERR_JWS_SIGNATURE_VERIFICATION_FAILED";
368
+ constructor(message = "signature verification failed", options) {
369
+ super(message, options);
370
+ }
371
+ }
372
+
373
+ // node_modules/jose/dist/webapi/util/base64url.js
374
+ var invalid = "The input to be decoded is not correctly encoded.";
375
+ function decode(input) {
376
+ try {
377
+ return decodeBase64(typeof input == "string" ? input : decoder2.decode(input), true);
378
+ } catch (cause) {
379
+ throw new TypeError(invalid, { cause });
380
+ }
381
+ }
382
+
383
+ // node_modules/jose/dist/webapi/lib/validate.js
384
+ function isObject(input) {
385
+ if (typeof input != "object" || input === null || Object.prototype.toString.call(input) !== "[object Object]")
386
+ return false;
387
+ const prototype = Object.getPrototypeOf(input);
388
+ return prototype === null || Object.getPrototypeOf(prototype) === null;
389
+ }
390
+ function isJwkSet(input) {
391
+ return isObject(input) && Array.isArray(input.keys) && Array.from(input.keys).every(isObject);
392
+ }
393
+ function isDisjoint(...headers) {
394
+ const parameters = /* @__PURE__ */ new Set;
395
+ for (const header of headers)
396
+ if (header)
397
+ for (const parameter of Object.keys(header)) {
398
+ if (parameters.has(parameter))
399
+ return false;
400
+ parameters.add(parameter);
401
+ }
402
+ return true;
403
+ }
404
+ function decodeBase64url(value, label, ErrorClass) {
405
+ try {
406
+ return decode(value);
407
+ } catch {
408
+ throw new ErrorClass(`Failed to base64url decode the ${label}`);
409
+ }
410
+ }
411
+ function encodeBase64url(value, label, ErrorClass) {
412
+ try {
413
+ return encode(value);
414
+ } catch {
415
+ throw new ErrorClass(`The ${label} is not a valid base64url string`);
416
+ }
417
+ }
418
+ function parseJoseHeader(b64, ErrorClass, message) {
419
+ let parsed;
420
+ try {
421
+ parsed = JSON.parse(strictDecoder.decode(decode(b64)));
422
+ } catch {
423
+ throw new ErrorClass(message);
424
+ }
425
+ if (!isObject(parsed))
426
+ throw new ErrorClass(message);
427
+ return parsed;
428
+ }
429
+ var JWS_RECOGNIZED = { __proto__: null, b64: true };
430
+ function validateAlgorithms(option, algorithms) {
431
+ if (algorithms !== undefined && (!Array.isArray(algorithms) || algorithms.some((s) => typeof s != "string")))
432
+ throw new TypeError(`"${option}" option must be an array of strings`);
433
+ return algorithms === undefined ? undefined : new Set(algorithms);
434
+ }
435
+ function validateCrit(Err, recognizedDefault, recognizedOption, protectedHeader, joseHeader) {
436
+ if (joseHeader.crit !== undefined && protectedHeader?.crit === undefined)
437
+ throw new Err('"crit" (Critical) Header Parameter MUST be integrity protected');
438
+ if (!protectedHeader || protectedHeader.crit === undefined)
439
+ return [];
440
+ if (!Array.isArray(protectedHeader.crit) || protectedHeader.crit.length === 0 || protectedHeader.crit.some((input) => typeof input != "string" || input.length === 0))
441
+ throw new Err('"crit" (Critical) Header Parameter MUST be an array of non-empty strings when present');
442
+ const recognized = recognizedOption === undefined ? recognizedDefault : { __proto__: null, ...recognizedOption, ...recognizedDefault };
443
+ for (const parameter of protectedHeader.crit) {
444
+ if (!(parameter in recognized))
445
+ throw new JOSENotSupported(`Extension Header Parameter "${parameter}" is not recognized`);
446
+ if (!Object.hasOwn(joseHeader, parameter) || joseHeader[parameter] === undefined)
447
+ throw new Err(`Extension Header Parameter "${parameter}" is missing`);
448
+ if (recognized[parameter] && (!Object.hasOwn(protectedHeader, parameter) || protectedHeader[parameter] === undefined))
449
+ throw new Err(`Extension Header Parameter "${parameter}" MUST be integrity protected`);
450
+ }
451
+ return protectedHeader.crit;
452
+ }
453
+ function validateB64(protectedHeader, extensions) {
454
+ if (extensions.includes("b64")) {
455
+ const b64 = protectedHeader.b64;
456
+ if (typeof b64 != "boolean")
457
+ throw new JWSInvalid('The "b64" (base64url-encode payload) Header Parameter must be a boolean');
458
+ return b64;
459
+ }
460
+ return true;
461
+ }
462
+
463
+ // node_modules/jose/dist/webapi/lib/key.js
464
+ var tag = (key) => key[Symbol.toStringTag];
465
+ var jwkMatchesOp = (entry, key, usage) => {
466
+ const { alg } = entry;
467
+ if (key.use !== undefined) {
468
+ const expected = usage === "sign" || usage === "verify" ? "sig" : "enc";
469
+ if (key.use !== expected)
470
+ throw new TypeError(`Invalid key for this operation, its "use" must be "${expected}" when present`);
471
+ }
472
+ if (key.alg !== undefined && key.alg !== alg)
473
+ throw new TypeError(`Invalid key for this operation, its "alg" must be "${alg}" when present`);
474
+ if (Array.isArray(key.key_ops)) {
475
+ const expectedKeyOp = usage === "encrypt" || usage === "decrypt" ? entry.ops?.[usage === "encrypt" ? 0 : 1] : usage;
476
+ if (expectedKeyOp && !key.key_ops.includes(expectedKeyOp))
477
+ throw new TypeError(`Invalid key for this operation, its "key_ops" must include "${expectedKeyOp}" when present`);
478
+ }
479
+ };
480
+ async function prepareKey(entry, key, usage) {
481
+ const { alg, secret } = entry, privateKey = usage === "decrypt" || usage === "sign";
482
+ if (secret && key instanceof Uint8Array)
483
+ return key;
484
+ let normalized, keyObject;
485
+ if (isObject(key)) {
486
+ if (normalized = normalizeJwk(key), typeof normalized.kty != "string")
487
+ throw invalidKeyType(alg, key, secret);
488
+ if (!(secret ? normalized.kty === "oct" && typeof normalized.k == "string" : normalized.kty !== "oct" && (privateKey ? normalized.kty === "AKP" && typeof normalized.priv == "string" || typeof normalized.d == "string" : normalized.d === undefined && normalized.priv === undefined)))
489
+ throw new TypeError(secret ? 'JSON Web Key for symmetric algorithms must have JWK "kty" (Key Type) equal to "oct" and the JWK "k" (Key Value) present' : `JSON Web Key for this operation must be a ${privateKey ? "private" : "public"} JWK`);
490
+ if (jwkMatchesOp(entry, normalized, usage), normalized.kty === "oct")
491
+ return decode(normalized.k);
492
+ if (!Object.isFrozen(key)) {
493
+ const { key_ops } = key;
494
+ Array.isArray(key_ops) && Object.freeze(key_ops), Object.freeze(key);
495
+ }
496
+ } else {
497
+ if (!isKeyLike(key))
498
+ throw invalidKeyType(alg, key, secret);
499
+ const expectedType = secret ? "secret" : privateKey ? "private" : "public";
500
+ if (key.type !== expectedType && (secret || ["secret", "public", "private"].includes(key.type)))
501
+ throw new TypeError(`${tag(key)} instances must be of type "${expectedType}" for the ${alg} algorithm`);
502
+ if (isCryptoKey(key))
503
+ return key;
504
+ if (keyObject = key, keyObject.type === "secret")
505
+ return keyObject.export();
506
+ }
507
+ cache ||= /* @__PURE__ */ new WeakMap;
508
+ const cacheKey = key;
509
+ let cached = cache.get(cacheKey);
510
+ if (cached?.[alg])
511
+ return cached[alg];
512
+ if (cached || cache.set(cacheKey, cached = {}), keyObject && typeof keyObject.toCryptoKey == "function") {
513
+ const isPublic = keyObject.type === "public", crv = nist[keyObject.asymmetricKeyDetails?.namedCurve], params = entry.resolve?.({ crv, asymmetricKeyType: keyObject.asymmetricKeyType }) ?? entry.subtle;
514
+ return cached[alg] = keyObject.toCryptoKey(params, isPublic, entry.usages[isPublic ? 0 : 1]);
515
+ }
516
+ return normalized ??= keyObject.export({ format: "jwk" }), normalized.alg = alg, cached[alg] = await jwkToKey(entry, normalized);
517
+ }
518
+ var cache;
519
+ var nist = {
520
+ __proto__: null,
521
+ prime256v1: "P-256",
522
+ secp384r1: "P-384",
523
+ secp521r1: "P-521"
524
+ };
525
+ var isCryptoKey = (key) => {
526
+ if (key?.[Symbol.toStringTag] === "CryptoKey")
527
+ return true;
528
+ try {
529
+ return key instanceof CryptoKey;
530
+ } catch {
531
+ return false;
532
+ }
533
+ };
534
+ var isKeyObject = (key) => key?.[Symbol.toStringTag] === "KeyObject";
535
+ var isKeyLike = (key) => isCryptoKey(key) || isKeyObject(key);
536
+ function message(msg, actual, ...types) {
537
+ if (types.length > 2) {
538
+ const last = types.pop();
539
+ msg += `one of type ${types.join(", ")}, or ${last}.`;
540
+ } else
541
+ types.length === 2 ? msg += `one of type ${types[0]} or ${types[1]}.` : msg += `of type ${types[0]}.`;
542
+ return actual == null ? msg += ` Received ${actual}` : typeof actual == "function" && actual.name ? msg += ` Received function ${actual.name}` : typeof actual == "object" && actual != null && actual.constructor?.name && (msg += ` Received an instance of ${actual.constructor.name}`), msg;
543
+ }
544
+ function invalidKeyType(alg, actual, secret) {
545
+ const types = ["CryptoKey", "KeyObject", "JSON Web Key"];
546
+ return secret && types.push("Uint8Array"), new TypeError(message(`Key for the ${alg} algorithm must be `, actual, ...types));
547
+ }
548
+ var unusable = (name, prop = "algorithm.name") => new TypeError(`CryptoKey does not support this operation, its ${prop} must be ${name}`);
549
+ function checkUsage(key, usage) {
550
+ if (usage && !key.usages.includes(usage))
551
+ throw new TypeError(`CryptoKey does not support this operation, its usages must include ${usage}.`);
552
+ }
553
+ function checkModulusLength(alg, key) {
554
+ const { modulusLength } = key.algorithm;
555
+ if (typeof modulusLength != "number" || modulusLength < 2048)
556
+ throw new TypeError(`${alg} requires key modulusLength to be 2048 bits or larger`);
557
+ }
558
+ function checkCryptoKey(key, expected, usage) {
559
+ const algorithm = key.algorithm;
560
+ if (algorithm.name !== expected.name)
561
+ throw unusable(expected.name);
562
+ if (expected.hash && algorithm.hash?.name !== expected.hash)
563
+ throw unusable(expected.hash, "algorithm.hash");
564
+ if (expected.namedCurve && algorithm.namedCurve !== expected.namedCurve)
565
+ throw unusable(expected.namedCurve, "algorithm.namedCurve");
566
+ if (expected.length !== undefined && algorithm.length !== expected.length)
567
+ throw unusable(expected.length, "algorithm.length");
568
+ checkUsage(key, usage);
569
+ }
570
+ function snapshotJwk(jwk) {
571
+ return { __proto__: null, ...jwk };
572
+ }
573
+ function normalizeJwk(jwk) {
574
+ const normalized = snapshotJwk(jwk);
575
+ if (normalized.ext !== undefined && typeof normalized.ext != "boolean")
576
+ throw new TypeError('"ext" (Extractable) Parameter must be a boolean');
577
+ if (normalized.key_ops !== undefined) {
578
+ const value = normalized.key_ops, keyOps = Array.isArray(value) ? [...value] : undefined;
579
+ if (!keyOps || keyOps.some((operation) => typeof operation != "string") || new Set(keyOps).size !== keyOps.length)
580
+ throw new TypeError('"key_ops" (Key Operations) Parameter must be an array of unique strings');
581
+ normalized.key_ops = keyOps;
582
+ }
583
+ return normalized;
584
+ }
585
+ async function jwkToKey(entry, jwk, extractable) {
586
+ if (!entry.kty.includes(jwk.kty))
587
+ throw new JOSENotSupported('Invalid or unsupported JWK "alg" (Algorithm) Parameter value');
588
+ const algorithm = entry.resolve?.({ kty: jwk.kty, crv: jwk.crv }) ?? entry.subtle, isPrivate = !!(jwk.d || jwk.priv), keyData = { ...jwk, ext: extractable ?? jwk.ext };
589
+ return keyData.kty !== "AKP" && delete keyData.alg, delete keyData.use, crypto.subtle.importKey("jwk", keyData, algorithm, keyData.ext ?? !isPrivate, jwk.key_ops ?? entry.usages[isPrivate ? 1 : 0]);
590
+ }
591
+ async function rawKey(key, expected, usage, extractable = false) {
592
+ return key instanceof Uint8Array && (key = await crypto.subtle.importKey("raw", key, expected, extractable, [usage])), checkCryptoKey(key, expected, usage), key;
593
+ }
594
+
595
+ // node_modules/jose/dist/webapi/lib/key_descriptor.js
596
+ function table(entries) {
597
+ const out = { __proto__: null };
598
+ for (const alg in entries)
599
+ out[alg] = { ...entries[alg], alg };
600
+ return out;
601
+ }
602
+
603
+ // node_modules/jose/dist/webapi/lib/jws_algorithms.js
604
+ var sig = [["verify"], ["sign"]];
605
+ function hmac(bits) {
606
+ const subtle = { name: "HMAC", hash: `SHA-${bits}` };
607
+ return { kty: ["oct"], secret: true, subtle, signing: subtle, usages: sig };
608
+ }
609
+ function rsa(bits, saltLength) {
610
+ const subtle = { name: saltLength ? "RSA-PSS" : "RSASSA-PKCS1-v1_5", hash: `SHA-${bits}` };
611
+ return {
612
+ kty: ["RSA"],
613
+ subtle,
614
+ signing: saltLength ? { ...subtle, saltLength } : subtle,
615
+ usages: sig,
616
+ minRsaBits: 2048
617
+ };
618
+ }
619
+ function ecdsa(crv, bits) {
620
+ return {
621
+ kty: ["EC"],
622
+ crv,
623
+ subtle: { name: "ECDSA", namedCurve: crv },
624
+ signing: { name: "ECDSA", hash: `SHA-${bits}` },
625
+ usages: sig
626
+ };
627
+ }
628
+ function eddsa() {
629
+ const subtle = { name: "Ed25519" };
630
+ return {
631
+ kty: ["OKP"],
632
+ crv: "Ed25519",
633
+ subtle,
634
+ signing: subtle,
635
+ usages: sig
636
+ };
637
+ }
638
+ function mldsa(bits) {
639
+ const subtle = { name: `ML-DSA-${bits}` };
640
+ return {
641
+ kty: ["AKP"],
642
+ subtle,
643
+ signing: subtle,
644
+ usages: sig
645
+ };
646
+ }
647
+ var JWS = table({
648
+ HS256: hmac(256),
649
+ HS384: hmac(384),
650
+ HS512: hmac(512),
651
+ RS256: rsa(256),
652
+ RS384: rsa(384),
653
+ RS512: rsa(512),
654
+ PS256: rsa(256, 32),
655
+ PS384: rsa(384, 48),
656
+ PS512: rsa(512, 64),
657
+ ES256: ecdsa("P-256", 256),
658
+ ES384: ecdsa("P-384", 384),
659
+ ES512: ecdsa("P-521", 512),
660
+ EdDSA: eddsa(),
661
+ Ed25519: eddsa(),
662
+ "ML-DSA-44": mldsa(44),
663
+ "ML-DSA-65": mldsa(65),
664
+ "ML-DSA-87": mldsa(87)
665
+ });
666
+ function jwsAlgorithm(alg) {
667
+ const entry = typeof alg == "string" ? JWS[alg] : undefined;
668
+ if (!entry)
669
+ throw new JOSENotSupported(`alg ${alg} is not supported either by JOSE or your javascript runtime`);
670
+ return entry;
671
+ }
672
+
673
+ // node_modules/jose/dist/webapi/lib/jws_verify.js
674
+ function prepareVerify(options) {
675
+ return [options && validateAlgorithms("algorithms", options.algorithms), options?.crit];
676
+ }
677
+ function parseProtectedHeader(encodedProtected) {
678
+ return encodedProtected === undefined ? {} : parseJoseHeader(encodedProtected, JWSInvalid, "JWS Protected Header is invalid");
679
+ }
680
+ function encodeCompactUnencodedPayload(payload) {
681
+ try {
682
+ return encode(payload);
683
+ } catch {
684
+ throw new JWSInvalid("JWS Compact Serialization payload must use only ASCII characters");
685
+ }
686
+ }
687
+ async function verifySignature(jws, shared, key, encodeUnencodedPayload, parsedProtected) {
688
+ const { protected: encodedProtected, header, payload: inputPayload } = jws, parsedProt = parsedProtected ?? parseProtectedHeader(encodedProtected);
689
+ if (!isDisjoint(parsedProt, header))
690
+ throw new JWSInvalid("JWS Protected and JWS Unprotected Header Parameter names must be disjoint");
691
+ const joseHeader = { ...parsedProt, ...header }, b64 = validateB64(parsedProt, validateCrit(JWSInvalid, JWS_RECOGNIZED, shared[1], parsedProt, joseHeader)), { alg } = joseHeader;
692
+ if (typeof alg != "string" || !alg)
693
+ throw new JWSInvalid('JWS "alg" (Algorithm) Header Parameter missing or invalid');
694
+ if (shared[0] && !shared[0].has(alg))
695
+ throw new JOSEAlgNotAllowed('"alg" (Algorithm) Header Parameter value not allowed');
696
+ if (b64) {
697
+ if (typeof inputPayload != "string")
698
+ throw new JWSInvalid("JWS Payload must be a string");
699
+ } else if (typeof inputPayload != "string" && !(inputPayload instanceof Uint8Array))
700
+ throw new JWSInvalid("JWS Payload must be a string or an Uint8Array instance");
701
+ const signingPayload = b64 || typeof inputPayload != "string" ? inputPayload : encodeUnencodedPayload(inputPayload);
702
+ let resolvedKey = false;
703
+ typeof key == "function" && (key = await key(parsedProt, jws), resolvedKey = true);
704
+ const entry = jwsAlgorithm(alg), data = concat(encodedProtected !== undefined ? encode(encodedProtected) : new Uint8Array, encode("."), typeof signingPayload == "string" ? shared[2] ??= encodeBase64url(signingPayload, "payload", JWSInvalid) : signingPayload), signature = decodeBase64url(jws.signature, "signature", JWSInvalid), k = await prepareKey(entry, key, "verify"), cryptoKey = await rawKey(k, entry.subtle, "verify");
705
+ entry.minRsaBits && checkModulusLength(entry.alg, cryptoKey);
706
+ let verified = false;
707
+ try {
708
+ verified = await crypto.subtle.verify(entry.signing, cryptoKey, signature, data);
709
+ } catch {}
710
+ if (!verified)
711
+ throw new JWSSignatureVerificationFailed;
712
+ const result = { payload: typeof signingPayload == "string" ? decodeBase64url(signingPayload, "payload", JWSInvalid) : signingPayload };
713
+ return encodedProtected !== undefined && (result.protectedHeader = parsedProt), header !== undefined && (result.unprotectedHeader = header), resolvedKey ? [{ ...result, key: k }, b64] : [result, b64];
714
+ }
715
+ async function verifyCompact(jws, shared, key) {
716
+ if (jws instanceof Uint8Array && (jws = decoder2.decode(jws)), typeof jws != "string")
717
+ throw new JWSInvalid("Compact JWS must be a string or Uint8Array");
718
+ const { 0: protectedHeader, 1: payload, 2: signature, length } = jws.split(".");
719
+ if (length !== 3)
720
+ throw new JWSInvalid("Invalid Compact JWS");
721
+ return verifySignature({ payload, protected: protectedHeader, signature }, shared, key, encodeCompactUnencodedPayload);
722
+ }
723
+
724
+ // node_modules/jose/dist/webapi/lib/jwt_claims_set.js
725
+ var epoch = (date) => Math.floor(date.getTime() / 1000);
726
+ var multipliers = {
727
+ s: 1,
728
+ m: 60,
729
+ h: 3600,
730
+ d: 86400,
731
+ w: 604800,
732
+ y: 31557600
733
+ };
734
+ var REGEX = /^(\+|\-)? ?(\d+|\d+\.\d+) ?(seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)(?: (ago|from now))?$/i;
735
+ var checkFailed = "check_failed";
736
+ function invalidDuration() {
737
+ throw new TypeError("Invalid time period format");
738
+ }
739
+ function secs(str) {
740
+ typeof str != "string" && invalidDuration();
741
+ const matched = REGEX.exec(str);
742
+ (!matched || matched[4] && matched[1]) && invalidDuration();
743
+ const value = parseFloat(matched[2]), numericDate2 = Math.round(value * multipliers[matched[3][0].toLowerCase()]);
744
+ return Number.isFinite(numericDate2) || invalidDuration(), matched[1] === "-" || matched[4] === "ago" ? -numericDate2 : numericDate2;
745
+ }
746
+ function validateInput(label, input) {
747
+ if (!Number.isFinite(input))
748
+ throw new TypeError(`Invalid ${label} input`);
749
+ return input;
750
+ }
751
+ var normalizeTyp = (value) => {
752
+ const normalized = value.toLowerCase();
753
+ return value.includes("/") ? normalized : `application/${normalized}`;
754
+ };
755
+ var checkAudiencePresence = (audPayload, audOption) => typeof audPayload == "string" ? audOption.includes(audPayload) : Array.isArray(audPayload) ? audOption.some((aud) => audPayload.includes(aud)) : false;
756
+ function validateNumericDate(payload, claim, required = false) {
757
+ const value = payload[claim];
758
+ if (!(value === undefined && !required)) {
759
+ if (typeof value != "number")
760
+ throw new JWTClaimValidationFailed(`"${claim}" claim must be a number`, payload, claim, "invalid");
761
+ return value;
762
+ }
763
+ }
764
+ function unexpectedClaim(payload, claim) {
765
+ throw new JWTClaimValidationFailed(`unexpected "${claim}" claim value`, payload, claim, checkFailed);
766
+ }
767
+ function validateClaimsSet(protectedHeader, encodedPayload, options = {}) {
768
+ let payload;
769
+ try {
770
+ payload = JSON.parse(strictDecoder.decode(encodedPayload));
771
+ } catch {}
772
+ if (!isObject(payload))
773
+ throw new JWTInvalid("JWT Claims Set must be a top-level JSON object");
774
+ const { typ } = options;
775
+ if (typ !== undefined && (typeof protectedHeader.typ != "string" || normalizeTyp(protectedHeader.typ) !== normalizeTyp(typ)))
776
+ throw new JWTClaimValidationFailed('unexpected "typ" JWT header value', payload, "typ", checkFailed);
777
+ const { requiredClaims = [], issuer, subject, audience, maxTokenAge } = options, presenceCheck = [...requiredClaims];
778
+ maxTokenAge !== undefined && presenceCheck.push("iat"), audience !== undefined && presenceCheck.push("aud"), subject !== undefined && presenceCheck.push("sub"), issuer !== undefined && presenceCheck.push("iss");
779
+ for (const claim of new Set(presenceCheck.reverse()))
780
+ if (!Object.hasOwn(payload, claim))
781
+ throw new JWTClaimValidationFailed(`missing required "${claim}" claim`, payload, claim, "missing");
782
+ issuer !== undefined && !(Array.isArray(issuer) ? issuer : [issuer]).includes(payload.iss) && unexpectedClaim(payload, "iss"), subject !== undefined && payload.sub !== subject && unexpectedClaim(payload, "sub"), audience !== undefined && !checkAudiencePresence(payload.aud, typeof audience == "string" ? [audience] : audience) && unexpectedClaim(payload, "aud");
783
+ const { clockTolerance } = options;
784
+ let tolerance = 0;
785
+ if (typeof clockTolerance == "string")
786
+ tolerance = secs(clockTolerance);
787
+ else if (clockTolerance !== undefined) {
788
+ if (typeof clockTolerance != "number")
789
+ throw new TypeError("Invalid clockTolerance option type");
790
+ tolerance = clockTolerance;
791
+ }
792
+ validateInput("clockTolerance option", tolerance);
793
+ const { currentDate } = options, now = validateInput("currentDate option", epoch(currentDate === undefined ? /* @__PURE__ */ new Date : currentDate)), iat = validateNumericDate(payload, "iat", maxTokenAge !== undefined), nbf = validateNumericDate(payload, "nbf");
794
+ if (nbf !== undefined && nbf > now + tolerance)
795
+ throw new JWTClaimValidationFailed('"nbf" claim timestamp check failed', payload, "nbf", checkFailed);
796
+ const exp = validateNumericDate(payload, "exp");
797
+ if (exp !== undefined && exp <= now - tolerance)
798
+ throw new JWTExpired('"exp" claim timestamp check failed', payload, "exp", checkFailed);
799
+ if (maxTokenAge !== undefined) {
800
+ const age = now - iat, max = validateInput("maxTokenAge option", typeof maxTokenAge == "number" ? maxTokenAge : secs(maxTokenAge));
801
+ if (age - tolerance > max)
802
+ throw new JWTExpired('"iat" claim timestamp check failed (too far in the past)', payload, "iat", checkFailed);
803
+ if (age < -tolerance)
804
+ throw new JWTClaimValidationFailed('"iat" claim timestamp check failed (it should be in the past)', payload, "iat", checkFailed);
805
+ }
806
+ return payload;
807
+ }
808
+
809
+ // node_modules/jose/dist/webapi/jwt/verify.js
810
+ async function jwtVerify(jwt, key, options) {
811
+ const [verified, b64] = await verifyCompact(jwt, prepareVerify(options), key);
812
+ if (!b64)
813
+ throw new JWTInvalid("JWTs MUST NOT use unencoded payload");
814
+ const payload = validateClaimsSet(verified.protectedHeader, verified.payload, options);
815
+ return { ...verified, payload };
816
+ }
817
+
818
+ // node_modules/jose/dist/webapi/jwks/local.js
819
+ function isUsableJWK(jwk, entry, alg, kid) {
820
+ const { kty, key_ops: keyOps, ext, kid: jwkKid, alg: jwkAlg, use, crv } = jwk;
821
+ return (ext === undefined || typeof ext == "boolean") && (keyOps === undefined || Array.isArray(keyOps) && keyOps.every((operation, index) => typeof operation == "string" && keyOps.indexOf(operation) === index) && keyOps.includes("verify")) && entry.kty.includes(kty) && (kid === undefined || typeof kid == "string" && kid === jwkKid) && (jwkAlg === undefined ? kty !== "AKP" : alg === jwkAlg) && (use === undefined || use === "sig") && (!entry.crv || crv === entry.crv);
822
+ }
823
+ async function importWithAlgCache(cache, jwk, entry) {
824
+ const cached = cache.get(jwk) || cache.set(jwk, {}).get(jwk), { alg } = entry;
825
+ if (cached[alg] === undefined) {
826
+ const pending = jwkToKey(entry, jwk, true).then((key) => {
827
+ if (key.type !== "public")
828
+ throw new JWKSInvalid("JSON Web Key Set members must be public keys");
829
+ return cached[alg] = key, key;
830
+ }).catch((error) => {
831
+ throw cached[alg] === pending && delete cached[alg], error;
832
+ });
833
+ cached[alg] = pending;
834
+ }
835
+ return cached[alg];
836
+ }
837
+ function createLocalJWKSet(jwks) {
838
+ let snapshot;
839
+ try {
840
+ snapshot = structuredClone(jwks);
841
+ } catch {}
842
+ if (!isJwkSet(snapshot))
843
+ throw new JWKSInvalid("JSON Web Key Set malformed");
844
+ const metadata = snapshot.keys.map((jwk) => {
845
+ const normalized = snapshotJwk(jwk);
846
+ return Array.isArray(normalized.key_ops) && (normalized.key_ops = [...normalized.key_ops]), normalized;
847
+ }), cached = /* @__PURE__ */ new WeakMap;
848
+ return Object.defineProperty(async (protectedHeader, token) => {
849
+ const { alg, kid } = { ...protectedHeader, ...token?.header }, entry = typeof alg == "string" ? JWS[alg] : undefined;
850
+ if (!entry || entry.secret)
851
+ throw new JOSENotSupported('Unsupported "alg" value for a JSON Web Key Set');
852
+ const candidates = snapshot.keys.filter((_, index) => isUsableJWK(metadata[index], entry, alg, kid)), { 0: jwk, length } = candidates;
853
+ if (!length)
854
+ throw new JWKSNoMatchingKey;
855
+ if (length !== 1) {
856
+ const error = new JWKSMultipleMatchingKeys;
857
+ throw error[Symbol.asyncIterator] = async function* () {
858
+ for (const jwk2 of candidates)
859
+ try {
860
+ yield await importWithAlgCache(cached, jwk2, entry);
861
+ } catch {}
862
+ }, error;
863
+ }
864
+ return importWithAlgCache(cached, jwk, entry);
865
+ }, "jwks", {
866
+ value: () => structuredClone(snapshot)
867
+ });
868
+ }
869
+
870
+ // node_modules/jose/dist/webapi/jwks/remote.js
871
+ function isCloudflareWorkers() {
872
+ return typeof WebSocketPair < "u" || typeof navigator < "u" && navigator.userAgent === "Cloudflare-Workers" || typeof EdgeRuntime < "u" && EdgeRuntime === "vercel";
873
+ }
874
+ var USER_AGENT;
875
+ (typeof navigator > "u" || !navigator.userAgent?.startsWith?.("Mozilla/5.0 ")) && (USER_AGENT = "jose/v6.2.12");
876
+ var customFetch = /* @__PURE__ */ Symbol();
877
+ async function fetchJwks(url, headers, signal, fetchImpl = fetch) {
878
+ const response = await fetchImpl(url, {
879
+ method: "GET",
880
+ signal,
881
+ redirect: "manual",
882
+ headers
883
+ }).catch((err) => {
884
+ throw err.name === "TimeoutError" ? new JWKSTimeout : err;
885
+ });
886
+ if (response.status !== 200)
887
+ throw new JOSEError("Expected 200 OK from the JSON Web Key Set HTTP response");
888
+ try {
889
+ return await response.json();
890
+ } catch {
891
+ throw new JOSEError("Failed to parse the JSON Web Key Set HTTP response as JSON");
892
+ }
893
+ }
894
+ var jwksCache = /* @__PURE__ */ Symbol();
895
+ function isFreshFor(timestamp, duration) {
896
+ return Number.isFinite(timestamp) && Date.now() < timestamp + duration;
897
+ }
898
+ function validateDuration(value, fallback, option) {
899
+ if (Number.isNaN(value))
900
+ throw new TypeError(`"${option}" option must not be NaN`);
901
+ return typeof value == "number" ? value : fallback;
902
+ }
903
+ function createRemoteJWKSet(url, options) {
904
+ if (!(url instanceof URL))
905
+ throw new TypeError("url must be an instance of URL");
906
+ const href = new URL(url.href).href, opts = options ?? {}, timeoutOption = opts.timeoutDuration;
907
+ if (typeof timeoutOption == "number" && (!Number.isInteger(timeoutOption) || timeoutOption < 0))
908
+ throw new TypeError('"timeoutDuration" option must be a non-negative integer');
909
+ const timeoutDuration = typeof timeoutOption == "number" ? timeoutOption : 5000, cooldownDuration = validateDuration(opts.cooldownDuration, 30000, "cooldownDuration"), cacheMaxAge = validateDuration(opts.cacheMaxAge, 600000, "cacheMaxAge"), headers = new Headers(opts.headers);
910
+ USER_AGENT && !headers.has("User-Agent") && headers.set("User-Agent", USER_AGENT), headers.has("accept") || headers.set("accept", "application/json, application/jwk-set+json");
911
+ const fetchImpl = opts[customFetch], cache = opts[jwksCache];
912
+ let jwksTimestamp, pendingFetch, reloadSequence = 0, appliedSequence = 0, local;
913
+ if (cache && typeof cache == "object") {
914
+ const { uat, jwks } = cache;
915
+ isFreshFor(uat, cacheMaxAge) && isJwkSet(jwks) && (jwksTimestamp = uat, local = createLocalJWKSet(jwks));
916
+ }
917
+ const reload = async () => {
918
+ if (pendingFetch && isCloudflareWorkers() && (pendingFetch = undefined), !pendingFetch) {
919
+ const sequence = ++reloadSequence, current = pendingFetch = fetchJwks(href, headers, AbortSignal.timeout(timeoutDuration), fetchImpl).then((json) => {
920
+ const next = createLocalJWKSet(json);
921
+ if (sequence <= appliedSequence)
922
+ return;
923
+ local = next;
924
+ const updatedAt = Date.now();
925
+ cache && (cache.uat = updatedAt, cache.jwks = json), jwksTimestamp = updatedAt, appliedSequence = sequence;
926
+ }).finally(() => {
927
+ pendingFetch === current && (pendingFetch = undefined);
928
+ });
929
+ }
930
+ await pendingFetch;
931
+ };
932
+ return Object.defineProperties(async (protectedHeader, token) => {
933
+ (!local || !isFreshFor(jwksTimestamp, cacheMaxAge)) && await reload();
934
+ try {
935
+ return await local(protectedHeader, token);
936
+ } catch (err) {
937
+ if (err instanceof JWKSNoMatchingKey && !isFreshFor(jwksTimestamp, cooldownDuration))
938
+ return await reload(), local(protectedHeader, token);
939
+ throw err;
940
+ }
941
+ }, {
942
+ coolingDown: {
943
+ get: () => isFreshFor(jwksTimestamp, cooldownDuration),
944
+ enumerable: true
945
+ },
946
+ fresh: {
947
+ get: () => isFreshFor(jwksTimestamp, cacheMaxAge),
948
+ enumerable: true
949
+ },
950
+ reload: {
951
+ value: reload,
952
+ enumerable: true
953
+ },
954
+ reloading: {
955
+ get: () => !!pendingFetch,
956
+ enumerable: true
957
+ },
958
+ jwks: {
959
+ value: () => local?.jwks(),
960
+ enumerable: true
961
+ }
962
+ });
963
+ }
964
+
244
965
  // src/runtime/auth/oauth.ts
245
966
  class OAuthAccountIneligibleError extends Error {
246
967
  code;
@@ -249,12 +970,22 @@ class OAuthAccountIneligibleError extends Error {
249
970
  this.code = code;
250
971
  }
251
972
  }
973
+
974
+ class OAuthIdentityLinkError extends Error {
975
+ code;
976
+ constructor(code) {
977
+ super(code);
978
+ this.code = code;
979
+ }
980
+ }
252
981
  var PRESETS = {
253
982
  google: {
254
983
  authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
255
984
  tokenUrl: "https://oauth2.googleapis.com/token",
256
985
  userInfoUrl: "https://openidconnect.googleapis.com/v1/userinfo",
257
986
  scopes: "openid email profile",
987
+ idTokenIssuer: "https://accounts.google.com",
988
+ idTokenJwksUrl: "https://www.googleapis.com/oauth2/v3/certs",
258
989
  profileMap: (r) => ({
259
990
  id: typeof r["sub"] === "string" ? r["sub"] : "",
260
991
  ...typeof r["email"] === "string" ? { email: r["email"] } : {},
@@ -263,6 +994,20 @@ var PRESETS = {
263
994
  metadata: { avatar_url: r["picture"], full_name: r["name"] }
264
995
  })
265
996
  },
997
+ apple: {
998
+ authorizeUrl: "https://appleid.apple.com/auth/authorize",
999
+ tokenUrl: "https://appleid.apple.com/auth/token",
1000
+ userInfoUrl: "https://appleid.apple.com/auth/userinfo",
1001
+ scopes: "name email",
1002
+ idTokenIssuer: "https://appleid.apple.com",
1003
+ idTokenJwksUrl: "https://appleid.apple.com/auth/keys",
1004
+ profileMap: (r) => ({
1005
+ id: typeof r["sub"] === "string" ? r["sub"] : "",
1006
+ ...typeof r["email"] === "string" ? { email: r["email"] } : {},
1007
+ emailVerified: r["email_verified"] === true || r["email_verified"] === "true",
1008
+ ...typeof r["name"] === "string" ? { name: r["name"] } : {}
1009
+ })
1010
+ },
266
1011
  github: {
267
1012
  authorizeUrl: "https://github.com/login/oauth/authorize",
268
1013
  tokenUrl: "https://github.com/login/oauth/access_token",
@@ -296,7 +1041,10 @@ function resolveProvider(name, cfg) {
296
1041
  id: String(r["id"] ?? r["sub"]),
297
1042
  ...typeof r["email"] === "string" ? { email: r["email"] } : {},
298
1043
  ...typeof r["name"] === "string" ? { name: r["name"] } : {}
299
- }))
1044
+ })),
1045
+ ...(cfg.idTokenIssuer ?? preset.idTokenIssuer) === undefined ? {} : { idTokenIssuer: cfg.idTokenIssuer ?? preset.idTokenIssuer },
1046
+ ...(cfg.idTokenJwksUrl ?? preset.idTokenJwksUrl) === undefined ? {} : { idTokenJwksUrl: cfg.idTokenJwksUrl ?? preset.idTokenJwksUrl },
1047
+ ...(cfg.idTokenAudience ?? preset.idTokenAudience) === undefined ? {} : { idTokenAudience: cfg.idTokenAudience ?? preset.idTokenAudience }
300
1048
  };
301
1049
  }
302
1050
 
@@ -308,6 +1056,7 @@ class OAuthService {
308
1056
  uriAllowList;
309
1057
  enforceRedirectAllowList;
310
1058
  providers = new Map;
1059
+ idTokenKeySets = new Map;
311
1060
  constructor(db, apiUrl, siteUrl, configs, fetchImpl = fetch, uriAllowList = [], enforceRedirectAllowList = false) {
312
1061
  this.db = db;
313
1062
  this.apiUrl = apiUrl;
@@ -316,31 +1065,99 @@ class OAuthService {
316
1065
  this.uriAllowList = uriAllowList;
317
1066
  this.enforceRedirectAllowList = enforceRedirectAllowList;
318
1067
  for (const [name, cfg] of Object.entries(configs)) {
319
- this.providers.set(name, resolveProvider(name, cfg));
1068
+ const provider = resolveProvider(name, cfg);
1069
+ this.providers.set(name, provider);
1070
+ if (provider.idTokenJwksUrl) {
1071
+ this.idTokenKeySets.set(name, createRemoteJWKSet(new URL(provider.idTokenJwksUrl), {
1072
+ timeoutDuration: 5000,
1073
+ cooldownDuration: 30000,
1074
+ cacheMaxAge: 10 * 60000,
1075
+ [customFetch]: async (url, options) => this.fetchImpl(url, options)
1076
+ }));
1077
+ }
320
1078
  }
321
1079
  }
322
1080
  has(name) {
323
1081
  return this.providers.has(name);
324
1082
  }
1083
+ async signInWithIdToken(providerName, token, accessToken, nonce) {
1084
+ const profile = await this.verifyIdToken(providerName, token, accessToken, nonce);
1085
+ if (!profile)
1086
+ return null;
1087
+ return this.upsertUser(providerName, profile);
1088
+ }
1089
+ async linkIdentityWithIdToken(providerName, token, userId, accessToken, nonce) {
1090
+ const profile = await this.verifyIdToken(providerName, token, accessToken, nonce);
1091
+ if (!profile)
1092
+ return false;
1093
+ await this.linkIdentityToUser(providerName, profile, userId);
1094
+ return true;
1095
+ }
1096
+ async signInWithProfile(provider, profile) {
1097
+ return this.upsertUser(provider, profile);
1098
+ }
1099
+ async verifyIdToken(providerName, token, accessToken, nonce) {
1100
+ const provider = this.providers.get(providerName);
1101
+ const keySet = this.idTokenKeySets.get(providerName);
1102
+ if (!provider?.idTokenIssuer || !keySet || !token)
1103
+ return null;
1104
+ let payload;
1105
+ let algorithm;
1106
+ try {
1107
+ const verified = await jwtVerify(token, keySet, {
1108
+ issuer: provider.idTokenIssuer,
1109
+ audience: provider.idTokenAudience ?? provider.clientId,
1110
+ algorithms: ["RS256", "ES256"]
1111
+ });
1112
+ payload = verified.payload;
1113
+ algorithm = verified.protectedHeader.alg ?? "";
1114
+ } catch {
1115
+ return null;
1116
+ }
1117
+ if (typeof payload.sub !== "string" || payload.sub.length === 0)
1118
+ return null;
1119
+ if (payload["nonce"] !== undefined && (typeof payload["nonce"] !== "string" || payload["nonce"] !== nonce))
1120
+ return null;
1121
+ const acceptedAudiences = provider.idTokenAudience ?? provider.clientId;
1122
+ if (Array.isArray(payload.aud) && payload.aud.length > 1 && (typeof payload["azp"] !== "string" || (Array.isArray(acceptedAudiences) ? !acceptedAudiences.includes(payload["azp"]) : payload["azp"] !== acceptedAudiences)))
1123
+ return null;
1124
+ if (typeof payload["at_hash"] === "string") {
1125
+ if (!accessToken || await oidcAtHash(accessToken, algorithm) !== payload["at_hash"])
1126
+ return null;
1127
+ }
1128
+ let profile;
1129
+ try {
1130
+ profile = provider.profileMap(payload);
1131
+ } catch {
1132
+ return null;
1133
+ }
1134
+ if (!profile.id)
1135
+ return null;
1136
+ return profile;
1137
+ }
325
1138
  callbackUrl() {
326
1139
  return `${this.apiUrl}/auth/v1/callback`;
327
1140
  }
328
- async authorize(url) {
1141
+ async authorize(url, linkUserId) {
329
1142
  const providerName = url.searchParams.get("provider") ?? "";
330
1143
  const provider = this.providers.get(providerName);
331
1144
  if (!provider) {
332
- return redirect(`${this.siteUrl}#error=provider_not_enabled&error_description=${encodeURIComponent(`provider ${providerName} is not configured`)}`);
1145
+ return redirectWithFragment(this.siteUrl, {
1146
+ error: "provider_not_enabled",
1147
+ error_description: `provider ${providerName} is not configured`
1148
+ });
333
1149
  }
334
1150
  const providerState = randomToken(16);
335
1151
  const scopes = url.searchParams.get("scopes") || provider.scopes;
336
1152
  const redirectTo = resolveRedirect(url.searchParams.get("redirect_to"), this.siteUrl, this.uriAllowList, this.enforceRedirectAllowList);
337
- await this.db.query(`insert into auth.flow_state (provider, provider_state, redirect_to, code_challenge, code_challenge_method, expires_at)
338
- values ($1, $2, $3, $4, $5, now() + interval '10 minutes')`, [
1153
+ await this.db.query(`insert into auth.flow_state (provider, provider_state, redirect_to, code_challenge, code_challenge_method, link_user_id, expires_at)
1154
+ values ($1, $2, $3, $4, $5, $6, now() + interval '10 minutes')`, [
339
1155
  providerName,
340
1156
  providerState,
341
1157
  redirectTo,
342
1158
  url.searchParams.get("code_challenge"),
343
- url.searchParams.get("code_challenge_method")
1159
+ url.searchParams.get("code_challenge_method"),
1160
+ linkUserId ?? null
344
1161
  ]);
345
1162
  const p = new URLSearchParams({
346
1163
  client_id: provider.clientId,
@@ -349,53 +1166,96 @@ class OAuthService {
349
1166
  scope: scopes,
350
1167
  state: providerState
351
1168
  });
352
- return redirect(`${provider.authorizeUrl}?${p}`);
1169
+ const reserved = new Set([
1170
+ "provider",
1171
+ "redirect_to",
1172
+ "scopes",
1173
+ "code_challenge",
1174
+ "code_challenge_method",
1175
+ "skip_http_redirect",
1176
+ "client_id",
1177
+ "redirect_uri",
1178
+ "response_type",
1179
+ "scope",
1180
+ "state"
1181
+ ]);
1182
+ for (const [key, value] of url.searchParams) {
1183
+ if (!reserved.has(key))
1184
+ p.append(key, value);
1185
+ }
1186
+ const providerUrl = `${provider.authorizeUrl}?${p}`;
1187
+ if (url.searchParams.get("skip_http_redirect") === "true") {
1188
+ return Response.json({ url: providerUrl });
1189
+ }
1190
+ return redirect(providerUrl);
353
1191
  }
354
1192
  async callback(url, createSession) {
355
1193
  const code = url.searchParams.get("code");
356
1194
  const state = url.searchParams.get("state");
357
1195
  if (!code || !state)
358
- return redirect(`${this.siteUrl}#error=invalid_request`);
1196
+ return redirectWithFragment(this.siteUrl, { error: "invalid_request" });
359
1197
  const res = await this.db.query(`delete from auth.flow_state where provider_state = $1 and expires_at > now() returning *`, [state]);
360
1198
  const flow = res.rows[0];
361
1199
  if (!flow)
362
- return redirect(`${this.siteUrl}#error=invalid_state`);
1200
+ return redirectWithFragment(this.siteUrl, { error: "invalid_state" });
363
1201
  const provider = this.providers.get(flow.provider);
364
1202
  if (!provider)
365
- return redirect(`${flow.redirect_to}#error=provider_not_enabled`);
1203
+ return redirectWithFragment(flow.redirect_to, { error: "provider_not_enabled" });
366
1204
  let profile;
367
1205
  try {
368
1206
  const token = await this.exchangeCode(provider, code);
369
1207
  profile = await this.fetchProfile(provider, token);
370
1208
  } catch {
371
- return redirect(`${flow.redirect_to}#error=server_error&error_description=${encodeURIComponent("OAuth provider request failed")}`);
1209
+ return redirectWithFragment(flow.redirect_to, {
1210
+ error: "server_error",
1211
+ error_description: "OAuth provider request failed"
1212
+ });
372
1213
  }
373
1214
  let userId;
374
1215
  try {
375
- userId = await this.upsertUser(provider.name, profile);
1216
+ if (flow.link_user_id) {
1217
+ await this.linkIdentityToUser(provider.name, profile, flow.link_user_id);
1218
+ userId = flow.link_user_id;
1219
+ } else {
1220
+ userId = await this.upsertUser(provider.name, profile);
1221
+ }
376
1222
  } catch (e) {
377
1223
  if (e instanceof OAuthAccountIneligibleError) {
378
- return redirect(`${flow.redirect_to}#error=access_denied&error_code=${e.code}`);
1224
+ return redirectWithFragment(flow.redirect_to, { error: "access_denied", error_code: e.code });
1225
+ }
1226
+ if (e instanceof OAuthIdentityLinkError) {
1227
+ return redirectWithFragment(flow.redirect_to, { error: "access_denied", error_code: e.code });
379
1228
  }
380
- return redirect(`${flow.redirect_to}#error=server_error&error_description=${encodeURIComponent("OAuth callback failed")}`);
1229
+ return redirectWithFragment(flow.redirect_to, {
1230
+ error: "server_error",
1231
+ error_description: "OAuth callback failed"
1232
+ });
381
1233
  }
382
1234
  if (flow.code_challenge) {
383
1235
  const authCode = randomToken(24);
384
1236
  await this.db.query(`insert into auth.flow_state (provider, provider_state, redirect_to, code_challenge, code_challenge_method, auth_code, user_id, expires_at)
385
1237
  values ($1, $2, $3, $4, $5, $6, $7, now() + interval '5 minutes')`, [flow.provider, randomToken(16), flow.redirect_to, flow.code_challenge, flow.code_challenge_method, authCode, userId]);
386
- return redirect(`${flow.redirect_to}?code=${authCode}`);
1238
+ return redirectWithQuery(flow.redirect_to, { code: authCode });
387
1239
  }
388
1240
  let session;
389
1241
  try {
390
1242
  session = await createSession(userId);
391
1243
  } catch (e) {
392
1244
  if (e instanceof OAuthAccountIneligibleError) {
393
- return redirect(`${flow.redirect_to}#error=access_denied&error_code=${e.code}`);
1245
+ return redirectWithFragment(flow.redirect_to, { error: "access_denied", error_code: e.code });
394
1246
  }
395
- return redirect(`${flow.redirect_to}#error=server_error&error_description=${encodeURIComponent("OAuth callback failed")}`);
1247
+ return redirectWithFragment(flow.redirect_to, {
1248
+ error: "server_error",
1249
+ error_description: "OAuth callback failed"
1250
+ });
396
1251
  }
397
- const hash = `#access_token=${session.access_token}&refresh_token=${session.refresh_token}&expires_in=${session.expires_in}&token_type=bearer&provider_token=`;
398
- return redirect(`${flow.redirect_to}${hash}`);
1252
+ return redirectWithFragment(flow.redirect_to, {
1253
+ access_token: session.access_token,
1254
+ refresh_token: session.refresh_token,
1255
+ expires_in: String(session.expires_in),
1256
+ token_type: "bearer",
1257
+ provider_token: ""
1258
+ });
399
1259
  }
400
1260
  async exchangePkce(authCode, verifier) {
401
1261
  const res = await this.db.query(`select * from auth.flow_state where auth_code = $1 and expires_at > now()`, [authCode]);
@@ -450,6 +1310,20 @@ class OAuthService {
450
1310
  async upsertUser(provider, profile) {
451
1311
  return this.db.transaction((query) => this.upsertUserInTransaction(query, provider, profile));
452
1312
  }
1313
+ async linkIdentityToUser(provider, profile, userId) {
1314
+ await this.db.transaction(async (query) => {
1315
+ await this.assertUserEligible(query, userId);
1316
+ const identityData = JSON.stringify({ sub: profile.id, email: profile.email, ...profile.metadata });
1317
+ const inserted = await query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
1318
+ values ($1, $2, $3, $4::jsonb)
1319
+ on conflict (provider, provider_id) do nothing
1320
+ returning user_id`, [userId, provider, profile.id, identityData]);
1321
+ if (inserted.rows.length === 0) {
1322
+ throw new OAuthIdentityLinkError("identity_already_exists");
1323
+ }
1324
+ await this.syncUserProviders(query, userId);
1325
+ });
1326
+ }
453
1327
  async upsertUserInTransaction(query, provider, profile) {
454
1328
  const existing = await query(`select user_id from auth.identities where provider = $1 and provider_id = $2`, [provider, profile.id]);
455
1329
  if (existing.rows[0]) {
@@ -460,11 +1334,12 @@ class OAuthService {
460
1334
  JSON.stringify({ sub: profile.id, email: profile.email, ...profile.metadata })
461
1335
  ]);
462
1336
  await query(`update auth.users set last_sign_in_at = now() where id = $1`, [existing.rows[0].user_id]);
1337
+ await this.syncUserProviders(query, existing.rows[0].user_id);
463
1338
  return existing.rows[0].user_id;
464
1339
  }
465
1340
  let userId;
466
- const email = profile.email?.toLowerCase().trim() ?? null;
467
- const verified = !!profile.email && profile.emailVerified === true;
1341
+ const email = typeof profile.email === "string" ? profile.email.toLowerCase().trim() || null : null;
1342
+ const verified = email !== null && profile.emailVerified === true;
468
1343
  if (email && verified) {
469
1344
  const byEmail = await query(`select id from auth.users where lower(email) = $1`, [email]);
470
1345
  userId = byEmail.rows[0]?.id;
@@ -508,8 +1383,10 @@ class OAuthService {
508
1383
  values ($1, $2, $3, $4::jsonb)
509
1384
  on conflict (provider, provider_id) do nothing
510
1385
  returning user_id`, [userId, provider, profile.id, identityData]);
511
- if (linked.rows[0])
1386
+ if (linked.rows[0]) {
1387
+ await this.syncUserProviders(query, linked.rows[0].user_id);
512
1388
  return linked.rows[0].user_id;
1389
+ }
513
1390
  const raced = await query(`select user_id from auth.identities where provider = $1 and provider_id = $2`, [provider, profile.id]);
514
1391
  const racedUserId = raced.rows[0]?.user_id;
515
1392
  if (!racedUserId)
@@ -521,8 +1398,27 @@ class OAuthService {
521
1398
  await query(`update auth.identities set last_sign_in_at = now(), identity_data = $3
522
1399
  where provider = $1 and provider_id = $2`, [provider, profile.id, identityData]);
523
1400
  await query(`update auth.users set last_sign_in_at = now() where id = $1`, [racedUserId]);
1401
+ await this.syncUserProviders(query, racedUserId);
524
1402
  return racedUserId;
525
1403
  }
1404
+ async syncUserProviders(query, userId) {
1405
+ await query(`update auth.users
1406
+ set raw_app_meta_data = jsonb_set(
1407
+ coalesce(raw_app_meta_data, '{}'::jsonb),
1408
+ '{providers}',
1409
+ (
1410
+ select jsonb_agg(provider order by provider)
1411
+ from (
1412
+ select distinct provider
1413
+ from auth.identities
1414
+ where user_id = $1
1415
+ ) as providers
1416
+ ),
1417
+ true
1418
+ ),
1419
+ updated_at = now()
1420
+ where id = $1`, [userId]);
1421
+ }
526
1422
  async assertUserEligible(query, userId) {
527
1423
  const users = await query(`select deleted_at is not null as is_deleted,
528
1424
  coalesce(banned_until > now(), false) as is_banned
@@ -534,9 +1430,27 @@ class OAuthService {
534
1430
  throw new OAuthAccountIneligibleError("user_banned");
535
1431
  }
536
1432
  }
1433
+ async function oidcAtHash(accessToken, algorithm) {
1434
+ const hash = algorithm === "RS256" || algorithm === "ES256" ? "SHA-256" : null;
1435
+ if (!hash)
1436
+ return null;
1437
+ const digest = new Uint8Array(await crypto.subtle.digest(hash, new TextEncoder().encode(accessToken)));
1438
+ return base64url(digest.subarray(0, digest.length / 2));
1439
+ }
537
1440
  function redirect(location) {
538
1441
  return new Response(null, { status: 303, headers: { location } });
539
1442
  }
1443
+ function redirectWithQuery(location, params) {
1444
+ const target = new URL(location);
1445
+ for (const [key, value] of Object.entries(params))
1446
+ target.searchParams.set(key, value);
1447
+ return redirect(target.toString());
1448
+ }
1449
+ function redirectWithFragment(location, params) {
1450
+ const target = new URL(location);
1451
+ target.hash = new URLSearchParams(params).toString();
1452
+ return redirect(target.toString());
1453
+ }
540
1454
  function base64url(bytes) {
541
1455
  let bin = "";
542
1456
  for (const b of bytes)
@@ -1040,6 +1954,7 @@ var DEFAULT_AUTH_SETTINGS = {
1040
1954
  anonymousUsers: true,
1041
1955
  autoconfirm: true,
1042
1956
  minPasswordLength: 6,
1957
+ requirePasswordReauthentication: false,
1043
1958
  disabledProviders: [],
1044
1959
  otpLength: 6,
1045
1960
  otpExpirySeconds: 3600,
@@ -1049,7 +1964,13 @@ var DEFAULT_AUTH_SETTINGS = {
1049
1964
  smsTemplate: "Your one-time code is {{ .Code }}",
1050
1965
  maxEnrolledFactors: 10,
1051
1966
  totpEnrollEnabled: true,
1052
- totpVerifyEnabled: true
1967
+ totpVerifyEnabled: true,
1968
+ recoveryCodesEnrollEnabled: true,
1969
+ recoveryCodesVerifyEnabled: true,
1970
+ recoveryCodesCount: 10,
1971
+ recoveryCodeLength: 16,
1972
+ recoveryCodesMaxVerifyAttempts: 5,
1973
+ recoveryCodesLockoutSeconds: 300
1053
1974
  };
1054
1975
  function sanitize(raw) {
1055
1976
  const s = { ...DEFAULT_AUTH_SETTINGS };
@@ -1062,6 +1983,9 @@ function sanitize(raw) {
1062
1983
  if (typeof raw["minPasswordLength"] === "number" && Number.isFinite(raw["minPasswordLength"])) {
1063
1984
  s.minPasswordLength = Math.max(4, Math.min(72, Math.floor(raw["minPasswordLength"])));
1064
1985
  }
1986
+ if (typeof raw["requirePasswordReauthentication"] === "boolean") {
1987
+ s.requirePasswordReauthentication = raw["requirePasswordReauthentication"];
1988
+ }
1065
1989
  if (Array.isArray(raw["disabledProviders"])) {
1066
1990
  s.disabledProviders = raw["disabledProviders"].filter((p) => typeof p === "string").slice(0, 50);
1067
1991
  }
@@ -1088,6 +2012,22 @@ function sanitize(raw) {
1088
2012
  s.totpEnrollEnabled = raw["totpEnrollEnabled"];
1089
2013
  if (typeof raw["totpVerifyEnabled"] === "boolean")
1090
2014
  s.totpVerifyEnabled = raw["totpVerifyEnabled"];
2015
+ if (typeof raw["recoveryCodesEnrollEnabled"] === "boolean")
2016
+ s.recoveryCodesEnrollEnabled = raw["recoveryCodesEnrollEnabled"];
2017
+ if (typeof raw["recoveryCodesVerifyEnabled"] === "boolean")
2018
+ s.recoveryCodesVerifyEnabled = raw["recoveryCodesVerifyEnabled"];
2019
+ if (typeof raw["recoveryCodesCount"] === "number" && Number.isFinite(raw["recoveryCodesCount"])) {
2020
+ s.recoveryCodesCount = Math.max(1, Math.min(50, Math.floor(raw["recoveryCodesCount"])));
2021
+ }
2022
+ if (typeof raw["recoveryCodeLength"] === "number" && Number.isFinite(raw["recoveryCodeLength"])) {
2023
+ s.recoveryCodeLength = Math.max(8, Math.min(64, Math.floor(raw["recoveryCodeLength"])));
2024
+ }
2025
+ if (typeof raw["recoveryCodesMaxVerifyAttempts"] === "number" && Number.isFinite(raw["recoveryCodesMaxVerifyAttempts"])) {
2026
+ s.recoveryCodesMaxVerifyAttempts = Math.max(1, Math.min(20, Math.floor(raw["recoveryCodesMaxVerifyAttempts"])));
2027
+ }
2028
+ if (typeof raw["recoveryCodesLockoutSeconds"] === "number" && Number.isFinite(raw["recoveryCodesLockoutSeconds"])) {
2029
+ s.recoveryCodesLockoutSeconds = Math.max(1, Math.min(86400, Math.floor(raw["recoveryCodesLockoutSeconds"])));
2030
+ }
1091
2031
  return s;
1092
2032
  }
1093
2033
  async function loadAuthSettings(db, defaults = {}) {
@@ -1226,6 +2166,140 @@ async function verifyTotp(secretBase32, code, window = 1, atMs = Date.now()) {
1226
2166
  return false;
1227
2167
  }
1228
2168
 
2169
+ // src/runtime/auth/web3.ts
2170
+ var BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
2171
+ var CLOCK_SKEW_SECONDS = 60;
2172
+ function decodeBase58(value) {
2173
+ if (!value)
2174
+ return null;
2175
+ const bytes = [];
2176
+ for (const character of value) {
2177
+ const digit = BASE58_ALPHABET.indexOf(character);
2178
+ if (digit < 0)
2179
+ return null;
2180
+ let carry = digit;
2181
+ for (let index = 0;index < bytes.length; index += 1) {
2182
+ carry += bytes[index] * 58;
2183
+ bytes[index] = carry & 255;
2184
+ carry >>= 8;
2185
+ }
2186
+ while (carry > 0) {
2187
+ bytes.push(carry & 255);
2188
+ carry >>= 8;
2189
+ }
2190
+ }
2191
+ let leadingZeros = 0;
2192
+ while (leadingZeros < value.length && value[leadingZeros] === "1")
2193
+ leadingZeros += 1;
2194
+ return new Uint8Array([...new Array(leadingZeros).fill(0), ...bytes.reverse()]);
2195
+ }
2196
+ function encodeBase58(value) {
2197
+ if (value.length === 0)
2198
+ return "";
2199
+ const digits = [];
2200
+ for (const byte of value) {
2201
+ let carry = byte;
2202
+ for (let index = 0;index < digits.length; index += 1) {
2203
+ carry += digits[index] * 256;
2204
+ digits[index] = carry % 58;
2205
+ carry = Math.floor(carry / 58);
2206
+ }
2207
+ while (carry > 0) {
2208
+ digits.push(carry % 58);
2209
+ carry = Math.floor(carry / 58);
2210
+ }
2211
+ }
2212
+ let leadingZeros = 0;
2213
+ while (leadingZeros < value.length && value[leadingZeros] === 0)
2214
+ leadingZeros += 1;
2215
+ return `${"1".repeat(leadingZeros)}${digits.reverse().map((digit) => BASE58_ALPHABET[digit]).join("")}`;
2216
+ }
2217
+ function field(lines, name) {
2218
+ const prefix = `${name}: `;
2219
+ const line = lines.find((entry) => entry.startsWith(prefix));
2220
+ return line?.slice(prefix.length);
2221
+ }
2222
+ function unixSeconds(value) {
2223
+ if (!value)
2224
+ return;
2225
+ const time = Date.parse(value);
2226
+ return Number.isFinite(time) ? Math.floor(time / 1000) : undefined;
2227
+ }
2228
+ function parseSolanaSignInMessage(message) {
2229
+ if (!message || message.length > 16384)
2230
+ return null;
2231
+ const lines = message.split(`
2232
+ `);
2233
+ const header = lines[0]?.match(/^(.+) wants you to sign in with your Solana account:$/);
2234
+ const address = lines[1]?.trim();
2235
+ const uri = field(lines, "URI");
2236
+ const version = field(lines, "Version");
2237
+ const issuedAt = unixSeconds(field(lines, "Issued At"));
2238
+ if (!header || !address || !uri || version !== "1" || issuedAt === undefined)
2239
+ return null;
2240
+ const publicKey = decodeBase58(address);
2241
+ if (!publicKey || publicKey.length !== 32 || encodeBase58(publicKey) !== address)
2242
+ return null;
2243
+ let parsedUri;
2244
+ try {
2245
+ parsedUri = new URL(uri);
2246
+ } catch {
2247
+ return null;
2248
+ }
2249
+ if (parsedUri.host !== header[1])
2250
+ return null;
2251
+ const expirationTime = unixSeconds(field(lines, "Expiration Time"));
2252
+ const notBefore = unixSeconds(field(lines, "Not Before"));
2253
+ if (field(lines, "Expiration Time") && expirationTime === undefined)
2254
+ return null;
2255
+ if (field(lines, "Not Before") && notBefore === undefined)
2256
+ return null;
2257
+ const now = Math.floor(Date.now() / 1000);
2258
+ if (issuedAt > now + CLOCK_SKEW_SECONDS)
2259
+ return null;
2260
+ if (expirationTime !== undefined && expirationTime <= now)
2261
+ return null;
2262
+ if (notBefore !== undefined && notBefore > now + CLOCK_SKEW_SECONDS)
2263
+ return null;
2264
+ return {
2265
+ address,
2266
+ domain: header[1],
2267
+ uri,
2268
+ issuedAt,
2269
+ ...expirationTime === undefined ? {} : { expirationTime },
2270
+ ...notBefore === undefined ? {} : { notBefore },
2271
+ ...field(lines, "Nonce") === undefined ? {} : { nonce: field(lines, "Nonce") }
2272
+ };
2273
+ }
2274
+ async function verifySolanaSignIn(message, signature) {
2275
+ const parsed = parseSolanaSignInMessage(message);
2276
+ if (!parsed)
2277
+ return null;
2278
+ const publicKey = decodeBase58(parsed.address);
2279
+ if (!publicKey)
2280
+ return null;
2281
+ let signatureBytes;
2282
+ try {
2283
+ const normalized = signature.replace(/-/g, "+").replace(/_/g, "/");
2284
+ const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
2285
+ signatureBytes = Uint8Array.from(Buffer.from(padded, "base64"));
2286
+ } catch {
2287
+ return null;
2288
+ }
2289
+ if (signatureBytes.length !== 64)
2290
+ return null;
2291
+ try {
2292
+ const publicKeyBuffer = Uint8Array.from(publicKey).buffer;
2293
+ const signatureBuffer = Uint8Array.from(signatureBytes).buffer;
2294
+ const messageBuffer = new TextEncoder().encode(message).buffer;
2295
+ const key = await crypto.subtle.importKey("raw", publicKeyBuffer, "Ed25519", false, ["verify"]);
2296
+ const valid = await crypto.subtle.verify("Ed25519", key, signatureBuffer, messageBuffer);
2297
+ return valid ? parsed : null;
2298
+ } catch {
2299
+ return null;
2300
+ }
2301
+ }
2302
+
1229
2303
  // src/runtime/auth/handler.ts
1230
2304
  function authError(status, errorCode, msg) {
1231
2305
  return json(status, { code: status, error_code: errorCode, msg });
@@ -1236,6 +2310,12 @@ function normalizeEmailInput(value) {
1236
2310
  const email = value.trim().toLowerCase();
1237
2311
  return email || null;
1238
2312
  }
2313
+ function base64url2(bytes) {
2314
+ let binary = "";
2315
+ for (const byte of bytes)
2316
+ binary += String.fromCharCode(byte);
2317
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
2318
+ }
1239
2319
  function randomOtp(length) {
1240
2320
  const n = Math.max(6, Math.min(10, Math.floor(length)));
1241
2321
  let code = "";
@@ -1261,9 +2341,41 @@ function normalizePhone(value) {
1261
2341
  const phone = value.trim();
1262
2342
  return /^\+[1-9]\d{7,14}$/.test(phone) ? phone : null;
1263
2343
  }
2344
+ function banDurationSeconds(value) {
2345
+ if (value === "none")
2346
+ return null;
2347
+ if (typeof value !== "string")
2348
+ return;
2349
+ const match = value.trim().match(/^(\d+(?:\.\d+)?)(ns|us|\u00B5s|ms|s|m|h)$/);
2350
+ if (!match)
2351
+ return;
2352
+ const amount = Number(match[1]);
2353
+ const multipliers = { ns: 0.000000001, us: 0.000001, "\xB5s": 0.000001, ms: 0.001, s: 1, m: 60, h: 3600 };
2354
+ const multiplier = multipliers[match[2]];
2355
+ const seconds = amount * multiplier;
2356
+ return Number.isFinite(seconds) ? seconds : undefined;
2357
+ }
1264
2358
  function recordValue(value) {
1265
2359
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
1266
2360
  }
2361
+ var RECOVERY_CODE_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
2362
+ function normalizeRecoveryCode(value) {
2363
+ return typeof value === "string" ? value.replace(/[\s-]/g, "").toLowerCase() : "";
2364
+ }
2365
+ function randomRecoveryCode(length) {
2366
+ let code = "";
2367
+ while (code.length < length) {
2368
+ const bytes = crypto.getRandomValues(new Uint8Array(Math.max(16, length - code.length)));
2369
+ for (const byte of bytes) {
2370
+ if (byte >= 224)
2371
+ continue;
2372
+ code += RECOVERY_CODE_ALPHABET[byte % RECOVERY_CODE_ALPHABET.length];
2373
+ if (code.length === length)
2374
+ break;
2375
+ }
2376
+ }
2377
+ return code;
2378
+ }
1267
2379
  function databaseDiagnosticCode(error) {
1268
2380
  const candidate = typeof error === "object" && error !== null && "code" in error ? error.code : null;
1269
2381
  return typeof candidate === "string" && /^[0-9A-Z]{5}$/.test(candidate) ? candidate : "database_error";
@@ -1287,12 +2399,21 @@ async function sendSmsUntilAbort(sender, message, signal) {
1287
2399
  signal.removeEventListener("abort", rejectForAbort);
1288
2400
  }
1289
2401
  }
1290
- function json(status, body) {
2402
+ function json(status, body, extraHeaders = {}) {
1291
2403
  return new Response(status === 204 ? null : JSON.stringify(body), {
1292
2404
  status,
1293
- headers: { "content-type": "application/json; charset=utf-8" }
2405
+ headers: { "content-type": "application/json; charset=utf-8", ...extraHeaders }
1294
2406
  });
1295
2407
  }
2408
+ function redirect2(location) {
2409
+ return new Response(null, { status: 303, headers: { location } });
2410
+ }
2411
+ function redirectWithQuery2(location, params) {
2412
+ const target = new URL(location);
2413
+ for (const [key, value] of Object.entries(params))
2414
+ target.searchParams.set(key, value);
2415
+ return redirect2(target.toString());
2416
+ }
1296
2417
  function iso(v) {
1297
2418
  if (v === null || v === undefined)
1298
2419
  return null;
@@ -1389,26 +2510,61 @@ class AuthHandler {
1389
2510
  return await this.getUser(req);
1390
2511
  if (path === "user" && method === "PUT")
1391
2512
  return await this.updateUser(req);
2513
+ if (/^user\/identities\/[^/]+$/.test(path) && method === "DELETE") {
2514
+ return await this.unlinkIdentity(req, decodeURIComponent(path.split("/")[2]));
2515
+ }
2516
+ if (path === "reauthenticate" && (method === "GET" || method === "POST"))
2517
+ return await this.reauthenticate(req);
1392
2518
  if (path === "logout" && method === "POST")
1393
2519
  return await this.logout(req, url);
1394
2520
  if (path === "otp" && method === "POST")
1395
2521
  return await this.sendOtp(req);
1396
2522
  if (path === "recover" && method === "POST")
1397
2523
  return this.limit("recover", req) ?? await this.sendRecovery(req);
1398
- if (["magiclink", "resend"].includes(path) && method === "POST")
2524
+ if (path === "magiclink" && method === "POST")
1399
2525
  return await this.sendOtp(req);
2526
+ if (path === "resend" && method === "POST")
2527
+ return await this.sendOtp(req, true);
1400
2528
  if (path === "verify" && method === "POST")
1401
2529
  return this.limit("verify", req) ?? await this.verifyToken(req);
1402
2530
  if (path === "verify" && method === "GET")
1403
2531
  return await this.verifyLink(url);
2532
+ if (path === "sso" && method === "POST")
2533
+ return await this.sso(req);
2534
+ if (path === "invite" && method === "POST") {
2535
+ if (ctx.role !== "service_role") {
2536
+ return authError(403, "insufficient_permissions", "Admin endpoints require the service_role key");
2537
+ }
2538
+ return await this.generateAdminMagicLink(req, true, "invite");
2539
+ }
1404
2540
  if (path === "factors" && method === "POST")
1405
2541
  return await this.enrollFactor(req);
2542
+ if (path === "factors/recovery-codes" && method === "GET")
2543
+ return await this.recoveryCodesStatus(req);
2544
+ if (path === "factors/recovery-codes" && method === "POST")
2545
+ return await this.generateRecoveryCodes(req);
2546
+ if (path === "factors/recovery-codes" && method === "DELETE")
2547
+ return await this.deleteRecoveryCodes(req);
2548
+ if (path === "factors/recovery-codes/verify" && method === "POST")
2549
+ return await this.verifyRecoveryCode(req);
2550
+ if (path === "factors/recovery-codes/regenerate" && method === "POST")
2551
+ return await this.regenerateRecoveryCodes(req);
1406
2552
  if (/^factors\/[^/]+\/challenge$/.test(path) && method === "POST")
1407
2553
  return await this.challengeFactor(req, path.split("/")[1]);
1408
2554
  if (/^factors\/[^/]+\/verify$/.test(path) && method === "POST")
1409
2555
  return await this.verifyFactor(req, path.split("/")[1]);
1410
2556
  if (/^factors\/[^/]+$/.test(path) && method === "DELETE")
1411
2557
  return await this.unenrollFactor(req, path.split("/")[1]);
2558
+ if (path === "user/identities/authorize" && method === "GET") {
2559
+ const user = await this.userFromBearer(req);
2560
+ if (!user)
2561
+ return authError(401, "no_authorization", "Invalid or expired token");
2562
+ const provider = url.searchParams.get("provider") ?? "";
2563
+ if (provider && this.settings.disabledProviders.includes(provider)) {
2564
+ return authError(422, "provider_disabled", `Sign-ins with ${provider} are disabled`);
2565
+ }
2566
+ return await this.oauth.authorize(url, user.id);
2567
+ }
1412
2568
  if (path === "authorize" && method === "GET") {
1413
2569
  const provider = url.searchParams.get("provider") ?? "";
1414
2570
  if (provider && this.settings.disabledProviders.includes(provider)) {
@@ -1443,15 +2599,26 @@ class AuthHandler {
1443
2599
  return callbackUrl;
1444
2600
  }
1445
2601
  async signup(req) {
1446
- const body = await req.json().catch(() => ({}));
2602
+ let body;
2603
+ try {
2604
+ const parsed = await req.json();
2605
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
2606
+ return authError(400, "validation_failed", "Signup body must be a JSON object");
2607
+ }
2608
+ body = parsed;
2609
+ } catch {
2610
+ return authError(400, "validation_failed", "Signup body must be valid JSON");
2611
+ }
1447
2612
  if (!("email" in body) && !("password" in body)) {
1448
2613
  if (!this.settings.anonymousUsers) {
1449
2614
  return authError(422, "anonymous_provider_disabled", "Anonymous sign-ins are disabled");
1450
2615
  }
1451
2616
  const res = await this.db.query(`insert into auth.users (aud, role, raw_app_meta_data, raw_user_meta_data, is_anonymous, last_sign_in_at)
1452
- values ('authenticated', 'authenticated', '{}', $1, true, now())
2617
+ values ('authenticated', 'authenticated', '{"provider":"anonymous","providers":["anonymous"]}', $1, true, now())
1453
2618
  returning *`, [JSON.stringify(body.data ?? {})]);
1454
- return json(200, await this.sessionFor(res.rows[0]));
2619
+ return json(200, await this.sessionFor(res.rows[0], undefined, {
2620
+ amr: [{ method: "anonymous", timestamp: Math.floor(Date.now() / 1000) }]
2621
+ }));
1455
2622
  }
1456
2623
  if (this.settings.disableSignup) {
1457
2624
  return authError(422, "signup_disabled", "Signups not allowed for this instance");
@@ -1486,31 +2653,64 @@ class AuthHandler {
1486
2653
  return authError(422, "user_already_exists", "User already registered");
1487
2654
  await this.audit("user_signedup", { actorId: newUser.id, actorEmail: email });
1488
2655
  if (!autoconfirm) {
1489
- await this.issueToken(email, "otp", false, "confirm");
2656
+ await this.issueToken(email, "otp", false, "confirm", {}, new URL(req.url).searchParams.get("redirect_to"), body.code_challenge, body.code_challenge_method);
1490
2657
  return json(200, this.userJson(newUser));
1491
2658
  }
1492
2659
  return json(200, await this.sessionFor(newUser));
1493
2660
  }
2661
+ async sso(req) {
2662
+ const body = await req.json().catch(() => ({}));
2663
+ const providerId = typeof body.provider_id === "string" ? body.provider_id.trim().toLowerCase() : "";
2664
+ const domain = typeof body.domain === "string" ? body.domain.trim().toLowerCase() : "";
2665
+ const providers = this.config.oauthProviders ?? {};
2666
+ const provider = providerId && providers[providerId] ? providerId : Object.entries(providers).find(([, config]) => domain && (config.ssoDomains ?? []).some((item) => item.toLowerCase() === domain))?.[0] ?? "";
2667
+ if (!provider)
2668
+ return authError(404, "sso_provider_not_found", "SSO provider not found");
2669
+ const authorizeUrl = new URL(req.url);
2670
+ authorizeUrl.pathname = authorizeUrl.pathname.replace(/\/sso$/, "/authorize");
2671
+ authorizeUrl.search = "";
2672
+ authorizeUrl.searchParams.set("provider", provider);
2673
+ authorizeUrl.searchParams.set("skip_http_redirect", String(body.skip_http_redirect === true));
2674
+ for (const [key, value] of [
2675
+ ["redirect_to", body.redirect_to],
2676
+ ["code_challenge", body.code_challenge],
2677
+ ["code_challenge_method", body.code_challenge_method]
2678
+ ]) {
2679
+ if (value)
2680
+ authorizeUrl.searchParams.set(key, value);
2681
+ }
2682
+ return this.oauth.authorize(authorizeUrl);
2683
+ }
1494
2684
  async token(req, url) {
1495
2685
  const grantType = url.searchParams.get("grant_type");
1496
2686
  const body = await req.json().catch(() => ({}));
1497
2687
  if (grantType === "password") {
1498
- const email = (body.email ?? "").toLowerCase().trim();
2688
+ const email = body.email === undefined ? null : normalizeEmailInput(body.email);
2689
+ if (body.email !== undefined && !email)
2690
+ return authError(400, "validation_failed", "email must not be empty");
2691
+ const phone = body.phone === undefined ? null : normalizePhone(body.phone);
2692
+ if (body.phone !== undefined && !phone)
2693
+ return authError(400, "validation_failed", "phone must be a valid E.164 number");
2694
+ if (!email && !phone)
2695
+ return authError(400, "validation_failed", "email or phone is required");
1499
2696
  const res = await this.db.query(`select *, coalesce(banned_until > now(), false) as is_banned
1500
- from auth.users where lower(email) = $1 and deleted_at is null`, [email]);
2697
+ from auth.users
2698
+ where (($1::text is not null and lower(email) = $1)
2699
+ or ($2::text is not null and phone = $2))
2700
+ and deleted_at is null`, [email, phone]);
1501
2701
  const user = res.rows[0];
1502
2702
  if (user?.encrypted_password && user.is_banned) {
1503
2703
  return authError(400, "user_banned", "User is banned");
1504
2704
  }
1505
2705
  if (!user || !user.encrypted_password || !await verifyPassword(body.password ?? "", user.encrypted_password)) {
1506
- await this.audit("login_failed", { actorEmail: email, traits: { grant_type: "password" } });
2706
+ await this.audit("login_failed", { actorEmail: email ?? phone, traits: { grant_type: "password" } });
1507
2707
  return authError(400, "invalid_credentials", "Invalid login credentials");
1508
2708
  }
1509
2709
  const response = await this.db.transaction(async (query) => {
1510
2710
  const currentResult = await query(`select *, coalesce(banned_until > now(), false) as is_banned
1511
2711
  from auth.users where id = $1 for update`, [user.id]);
1512
2712
  const current = currentResult.rows[0];
1513
- if (!current || current.deleted_at !== null || !current.encrypted_password || current.email?.toLowerCase().trim() !== email) {
2713
+ if (!current || current.deleted_at !== null || !current.encrypted_password || (email ? current.email?.toLowerCase().trim() !== email : current.phone !== phone)) {
1514
2714
  return authError(400, "invalid_credentials", "Invalid login credentials");
1515
2715
  }
1516
2716
  if (current.is_banned) {
@@ -1519,7 +2719,10 @@ class AuthHandler {
1519
2719
  if (current.encrypted_password !== user.encrypted_password) {
1520
2720
  return authError(400, "invalid_credentials", "Invalid login credentials");
1521
2721
  }
1522
- if (!this.settings.autoconfirm && !current.email_confirmed_at) {
2722
+ if (phone && !current.phone_confirmed_at) {
2723
+ return authError(400, "phone_not_confirmed", "Phone not confirmed");
2724
+ }
2725
+ if (email && !this.settings.autoconfirm && !current.email_confirmed_at) {
1523
2726
  return authError(400, "email_not_confirmed", "Email not confirmed");
1524
2727
  }
1525
2728
  const signedIn = await query(`update auth.users set last_sign_in_at = now() where id = $1 returning *`, [current.id]);
@@ -1530,7 +2733,7 @@ class AuthHandler {
1530
2733
  return json(200, await this.sessionFor(updated, undefined, undefined, query));
1531
2734
  });
1532
2735
  if (response.ok) {
1533
- await this.audit("login", { actorId: user.id, actorEmail: email, traits: { grant_type: "password" } });
2736
+ await this.audit("login", { actorId: user.id, actorEmail: user.email ?? user.phone, traits: { grant_type: "password" } });
1534
2737
  }
1535
2738
  return response;
1536
2739
  }
@@ -1545,11 +2748,72 @@ class AuthHandler {
1545
2748
  const verifier = body.code_verifier;
1546
2749
  if (!authCode || !verifier)
1547
2750
  return authError(400, "validation_failed", "auth_code and code_verifier required");
2751
+ const emailClaim = await this.exchangeEmailPkce(authCode, verifier);
2752
+ if (emailClaim) {
2753
+ return json(200, await this.sessionForEmailPkce(emailClaim.userId, emailClaim.provider));
2754
+ }
1548
2755
  const userId = await this.oauth.exchangePkce(authCode, verifier);
1549
2756
  if (!userId)
1550
2757
  return authError(403, "flow_state_not_found", "invalid or expired auth code");
1551
2758
  return json(200, await this.sessionForOAuth(userId));
1552
2759
  }
2760
+ if (grantType === "id_token") {
2761
+ const provider = typeof body.provider === "string" ? body.provider.toLowerCase() : "";
2762
+ const idToken = body.id_token;
2763
+ if (!provider || !idToken)
2764
+ return authError(400, "validation_failed", "provider and id_token required");
2765
+ if (String(body.link_identity) === "true" || body.link_identity === "1") {
2766
+ const current = await this.userFromBearer(req);
2767
+ if (!current)
2768
+ return authError(401, "no_authorization", "This endpoint requires a Bearer token");
2769
+ try {
2770
+ const linked = await this.oauth.linkIdentityWithIdToken(provider, idToken, current.id, body.access_token, body.nonce);
2771
+ if (!linked)
2772
+ return authError(400, "invalid_credentials", "Invalid ID token");
2773
+ } catch (error) {
2774
+ if (error instanceof OAuthIdentityLinkError) {
2775
+ return authError(422, error.code, "Identity is already linked");
2776
+ }
2777
+ throw error;
2778
+ }
2779
+ return this.sessionForOAuth(current.id).then((session) => json(200, session));
2780
+ }
2781
+ const userId = await this.oauth.signInWithIdToken(provider, idToken, body.access_token, body.nonce);
2782
+ if (!userId)
2783
+ return authError(400, "invalid_credentials", "Invalid ID token");
2784
+ return this.db.transaction(async (query) => {
2785
+ const result = await query(`select * from auth.users where id = $1 and deleted_at is null
2786
+ and (banned_until is null or banned_until <= now()) for update`, [userId]);
2787
+ const user = result.rows[0];
2788
+ if (!user)
2789
+ return authError(400, "invalid_credentials", "Invalid ID token");
2790
+ return json(200, await this.sessionFor(user, undefined, {
2791
+ amr: [{ method: "oauth", timestamp: Math.floor(Date.now() / 1000) }]
2792
+ }, query));
2793
+ });
2794
+ }
2795
+ if (grantType === "web3") {
2796
+ if (body.chain !== "solana" || !body.message || !body.signature) {
2797
+ return authError(400, "validation_failed", "chain, message and signature are required");
2798
+ }
2799
+ const wallet = await verifySolanaSignIn(body.message, body.signature);
2800
+ if (!wallet)
2801
+ return authError(400, "invalid_credentials", "Invalid Web3 credentials");
2802
+ const userId = await this.oauth.signInWithProfile("solana", {
2803
+ id: wallet.address,
2804
+ metadata: { address: wallet.address, chain: "solana" }
2805
+ });
2806
+ return this.db.transaction(async (query) => {
2807
+ const result = await query(`select * from auth.users where id = $1 and deleted_at is null
2808
+ and (banned_until is null or banned_until <= now()) for update`, [userId]);
2809
+ const user = result.rows[0];
2810
+ if (!user)
2811
+ return authError(400, "invalid_credentials", "Invalid Web3 credentials");
2812
+ return json(200, await this.sessionFor(user, undefined, {
2813
+ amr: [{ method: "web3", timestamp: Math.floor(Date.now() / 1000) }]
2814
+ }, query));
2815
+ });
2816
+ }
1553
2817
  return authError(400, "invalid_grant", `unsupported grant_type: ${grantType}`);
1554
2818
  }
1555
2819
  async getUser(req) {
@@ -1558,7 +2822,44 @@ class AuthHandler {
1558
2822
  return authError(401, "no_authorization", "Invalid or expired token");
1559
2823
  return json(200, this.userJson(user, await this.getUserFactors(user.id), await this.getUserIdentities(user.id)));
1560
2824
  }
2825
+ async unlinkIdentity(req, identityId) {
2826
+ const user = await this.userFromBearer(req);
2827
+ if (!user)
2828
+ return authError(401, "no_authorization", "Invalid or expired token");
2829
+ return this.db.transaction(async (query) => {
2830
+ const current = (await query(`select * from auth.users where id = $1 and deleted_at is null
2831
+ and (banned_until is null or banned_until <= now()) for update`, [user.id])).rows[0];
2832
+ if (!current)
2833
+ return authError(401, "no_authorization", "Invalid or expired token");
2834
+ const identity = (await query(`select id from auth.identities where id = $1 and user_id = $2`, [identityId, user.id])).rows[0];
2835
+ if (!identity)
2836
+ return authError(404, "identity_not_found", "Identity not found");
2837
+ const count = await query(`select count(*)::int as count from auth.identities where user_id = $1`, [user.id]);
2838
+ if ((count.rows[0]?.count ?? 0) <= 1) {
2839
+ return authError(422, "cannot_unlink_identity", "Cannot unlink the only identity");
2840
+ }
2841
+ await query(`delete from auth.identities where id = $1 and user_id = $2`, [identity.id, user.id]);
2842
+ await query(`update auth.users
2843
+ set raw_app_meta_data = jsonb_set(
2844
+ coalesce(raw_app_meta_data, '{}'::jsonb),
2845
+ '{providers}',
2846
+ (
2847
+ select jsonb_agg(provider order by provider)
2848
+ from (
2849
+ select distinct provider
2850
+ from auth.identities
2851
+ where user_id = $1
2852
+ ) as providers
2853
+ ),
2854
+ true
2855
+ ),
2856
+ updated_at = now()
2857
+ where id = $1`, [user.id]);
2858
+ return json(200, {});
2859
+ });
2860
+ }
1561
2861
  async updateUser(req) {
2862
+ const claims = await this.claimsFromBearer(req);
1562
2863
  const user = await this.userFromBearer(req);
1563
2864
  if (!user)
1564
2865
  return authError(401, "no_authorization", "Invalid or expired token");
@@ -1569,8 +2870,17 @@ class AuthHandler {
1569
2870
  if (body.email !== undefined && !requestedEmail) {
1570
2871
  return authError(400, "validation_failed", "email must not be empty");
1571
2872
  }
2873
+ if (body.phone !== undefined) {
2874
+ if (requestedEmail !== undefined)
2875
+ return authError(400, "validation_failed", "email and phone cannot be updated together");
2876
+ const phone = normalizePhone(body.phone);
2877
+ if (!phone)
2878
+ return authError(400, "validation_failed", "phone must be a valid E.164 number");
2879
+ return this.requestPhoneChange(req, user, phone);
2880
+ }
2881
+ const requiresReauthentication = Boolean(body.password) && this.settings.requirePasswordReauthentication && await this.sessionRequiresReauthentication(claims);
1572
2882
  if (requestedEmail && !this.settings.autoconfirm) {
1573
- return authError(501, "email_change_confirmation_not_supported", "Email changes that require confirmation are not supported by SupaCloud Lite V1");
2883
+ return this.requestEmailChange(req, user, requestedEmail, body);
1574
2884
  }
1575
2885
  if (body.password) {
1576
2886
  if (body.password.length < this.settings.minPasswordLength) {
@@ -1593,6 +2903,14 @@ class AuthHandler {
1593
2903
  return authError(401, "no_authorization", "Invalid or expired token");
1594
2904
  const transactionSets = [...sets];
1595
2905
  const transactionParams = [...params];
2906
+ if (requiresReauthentication) {
2907
+ if (!body.nonce) {
2908
+ return authError(400, "reauthentication_needed", "Password update requires reauthentication");
2909
+ }
2910
+ if (!await this.consumeReauthentication(query, current, body.nonce)) {
2911
+ return authError(422, "reauthentication_not_valid", "Nonce has expired or is invalid");
2912
+ }
2913
+ }
1596
2914
  const upgradingAnon = (current.is_anonymous ?? false) && requestedEmail !== undefined;
1597
2915
  if (requestedEmail) {
1598
2916
  const email = requestedEmail;
@@ -1616,7 +2934,7 @@ class AuthHandler {
1616
2934
  if (!updated)
1617
2935
  return authError(401, "no_authorization", "Invalid or expired token");
1618
2936
  if (requestedEmail)
1619
- await this.ensureEmailIdentity(updated, query);
2937
+ await this.ensureEmailIdentity(updated, query, upgradingAnon);
1620
2938
  return json(200, await this.userJsonWithRelations(updated, query));
1621
2939
  });
1622
2940
  } catch (error) {
@@ -1627,6 +2945,161 @@ class AuthHandler {
1627
2945
  }
1628
2946
  return response;
1629
2947
  }
2948
+ async reauthenticate(req) {
2949
+ const user = await this.userFromBearer(req);
2950
+ if (!user)
2951
+ return authError(401, "no_authorization", "Invalid or expired token");
2952
+ const prepared = await this.db.transaction(async (query) => {
2953
+ const currentResult = await query(`select * from auth.users where id = $1 and deleted_at is null
2954
+ and (banned_until is null or banned_until <= now()) for update`, [user.id]);
2955
+ const current = currentResult.rows[0];
2956
+ if (!current)
2957
+ return authError(401, "no_authorization", "Invalid or expired token");
2958
+ const email = current.email_confirmed_at && current.email ? current.email : null;
2959
+ const phone = !email && current.phone_confirmed_at && current.phone ? current.phone : null;
2960
+ if (!email && !phone) {
2961
+ if (current.email && !current.email_confirmed_at) {
2962
+ return authError(422, "email_not_confirmed", "Please verify your email first.");
2963
+ }
2964
+ if (current.phone && !current.phone_confirmed_at) {
2965
+ return authError(422, "phone_not_confirmed", "Please verify your phone first.");
2966
+ }
2967
+ return authError(400, "validation_failed", "Reauthentication requires the user to have an email or a phone number");
2968
+ }
2969
+ const contact = email ?? phone;
2970
+ const sender = this.config.smsSender;
2971
+ if (!email && !sender) {
2972
+ return authError(422, "phone_provider_disabled", "Phone sign-ins are disabled");
2973
+ }
2974
+ const limited = email ? this.limit("otp", req) : this.limitKey("sms", `phone:${await keyedDigest(this.config.jwtSecret, "supacloud-lite:phone-fingerprint:v1", contact)}`) ?? this.limit("sms", req);
2975
+ if (limited)
2976
+ return limited;
2977
+ const code = randomOtp(this.settings.otpLength);
2978
+ const tokenDigest = `hmac-sha256:v1:${await keyedDigest(this.config.jwtSecret, "supacloud-lite:reauthentication-otp:v1", `${contact}\x00${code}`)}`;
2979
+ const issuanceId = crypto.randomUUID();
2980
+ await query(`delete from auth.one_time_tokens where user_id = $1 and token_type = 'reauthentication'`, [current.id]);
2981
+ await query(`insert into auth.one_time_tokens (id, user_id, email, phone, token_type, token, expires_at)
2982
+ values ($1, $2, $3, $4, 'reauthentication', $5, now() + $6::interval)`, [issuanceId, current.id, email, phone, tokenDigest, `${this.settings.otpExpirySeconds} seconds`]);
2983
+ const updated = (await query(`update auth.users
2984
+ set reauthentication_token = $1, reauthentication_sent_at = now(), updated_at = now()
2985
+ where id = $2 returning *`, [tokenDigest, current.id])).rows[0];
2986
+ return updated ? { user: updated, email, phone, code, issuanceId, tokenDigest } : null;
2987
+ });
2988
+ if (prepared instanceof Response)
2989
+ return prepared;
2990
+ if (!prepared)
2991
+ return authError(401, "no_authorization", "Invalid or expired token");
2992
+ try {
2993
+ if (prepared.email) {
2994
+ await this.config.mailer.send({
2995
+ to: prepared.email,
2996
+ subject: "Your reauthentication code",
2997
+ text: `Your reauthentication code is ${prepared.code}`
2998
+ });
2999
+ } else {
3000
+ const smsSender = this.config.smsSender;
3001
+ if (!smsSender || !prepared.phone)
3002
+ return authError(422, "phone_provider_disabled", "Phone sign-ins are disabled");
3003
+ await this.sendPhoneOtp(smsSender, prepared.phone, this.settings.smsTemplate.replace(/\{\{\s*\.Code\s*\}\}/g, prepared.code));
3004
+ }
3005
+ } catch {
3006
+ await this.db.query(`delete from auth.one_time_tokens where id = $1 and token = $2`, [prepared.issuanceId, prepared.tokenDigest]);
3007
+ await this.db.query(`update auth.users
3008
+ set reauthentication_token = '', reauthentication_sent_at = null
3009
+ where id = $1 and reauthentication_token = $2`, [prepared.user.id, prepared.tokenDigest]);
3010
+ return authError(502, prepared.email ? "email_provider_failed" : "sms_provider_failed", "Unable to send the verification code");
3011
+ }
3012
+ return json(200, {});
3013
+ }
3014
+ async sessionRequiresReauthentication(claims) {
3015
+ if (!claims?.sub || typeof claims.session_id !== "string" || !claims.session_id)
3016
+ return true;
3017
+ const result = await this.db.query(`select min(created_at) as started_at
3018
+ from auth.refresh_tokens where session_id = $1 and user_id = $2`, [claims.session_id, claims.sub]);
3019
+ const startedAt = timestampMs(result.rows[0]?.started_at ?? null);
3020
+ return startedAt === null || Date.now() - startedAt >= 24 * 60 * 60 * 1000;
3021
+ }
3022
+ async consumeReauthentication(query, user, nonce) {
3023
+ const email = user.email_confirmed_at && user.email ? user.email : null;
3024
+ const phone = !email && user.phone_confirmed_at && user.phone ? user.phone : null;
3025
+ const contact = email ?? phone;
3026
+ if (!contact || !nonce)
3027
+ return false;
3028
+ const tokenDigest = `hmac-sha256:v1:${await keyedDigest(this.config.jwtSecret, "supacloud-lite:reauthentication-otp:v1", `${contact}\x00${nonce}`)}`;
3029
+ const candidate = await query(`select id from auth.one_time_tokens
3030
+ where user_id = $1 and token_type = 'reauthentication'
3031
+ and token = $2 and expires_at > now() and attempts < $3
3032
+ limit 1`, [user.id, tokenDigest, AuthHandler.MAX_OTP_ATTEMPTS]);
3033
+ if (candidate.rows.length === 0) {
3034
+ await query(`update auth.one_time_tokens set attempts = attempts + 1
3035
+ where user_id = $1 and token_type = 'reauthentication' and expires_at > now()`, [user.id]);
3036
+ await query(`delete from auth.one_time_tokens
3037
+ where user_id = $1 and token_type = 'reauthentication' and attempts >= $2`, [user.id, AuthHandler.MAX_OTP_ATTEMPTS]);
3038
+ return false;
3039
+ }
3040
+ const claimed = await query(`delete from auth.one_time_tokens
3041
+ where id = $1 and user_id = $2 and token = $3 and expires_at > now()
3042
+ returning id`, [candidate.rows[0].id, user.id, tokenDigest]);
3043
+ if (claimed.rows.length === 0)
3044
+ return false;
3045
+ const cleared = await query(`update auth.users
3046
+ set reauthentication_token = '', reauthentication_sent_at = null
3047
+ where id = $1 and reauthentication_token = $2
3048
+ returning id`, [user.id, tokenDigest]);
3049
+ return cleared.rows.length > 0;
3050
+ }
3051
+ async requestEmailChange(req, user, newEmail, body) {
3052
+ const url = new URL(req.url);
3053
+ const redirectTo = resolveRedirect(url.searchParams.get("redirect_to"), this.config.siteUrl, this.config.uriAllowList, this.config.enforceRedirectAllowList);
3054
+ const challenge = typeof body.code_challenge === "string" && body.code_challenge ? body.code_challenge : null;
3055
+ const challengeMethod = challenge ? body.code_challenge_method?.toLowerCase() || "s256" : null;
3056
+ const prepared = await this.db.transaction(async (query) => {
3057
+ const currentResult = await query(`select * from auth.users where id = $1 and deleted_at is null
3058
+ and (banned_until is null or banned_until <= now()) for update`, [user.id]);
3059
+ const current = currentResult.rows[0];
3060
+ if (!current)
3061
+ return authError(401, "no_authorization", "Invalid or expired token");
3062
+ if (current.email?.toLowerCase().trim() === newEmail)
3063
+ return json(200, await this.userJsonWithRelations(current, query));
3064
+ const clash = await query(`select id from auth.users where lower(email) = $1 and id <> $2`, [newEmail, current.id]);
3065
+ if (clash.rows.length > 0)
3066
+ return authError(422, "email_exists", "A user with this email address has already been registered");
3067
+ const code = randomOtp(this.settings.otpLength);
3068
+ const linkToken = randomToken(24);
3069
+ await query(`delete from auth.one_time_tokens where user_id = $1 and token_type = 'email_change'`, [current.id]);
3070
+ await query(`insert into auth.one_time_tokens (user_id, email, token_type, token, expires_at)
3071
+ values ($1, $2, 'email_change', $3, now() + $4::interval),
3072
+ ($1, $2, 'email_change', $5, now() + $4::interval)`, [current.id, newEmail, code, `${this.settings.otpExpirySeconds} seconds`, linkToken]);
3073
+ await query(`update auth.users
3074
+ set email_change = $1, email_change_sent_at = now(), email_change_confirm_status = 0, updated_at = now()
3075
+ where id = $2`, [newEmail, current.id]);
3076
+ await query(`delete from auth.flow_state
3077
+ where provider = 'email-change' and user_id = $1 and auth_code is null`, [current.id]);
3078
+ if (challenge) {
3079
+ await query(`insert into auth.flow_state
3080
+ (provider, provider_state, redirect_to, code_challenge, code_challenge_method, user_id, expires_at)
3081
+ values ('email-change', $1, $2, $3, $4, $5, now() + interval '10 minutes')`, [linkToken, redirectTo, challenge, challengeMethod, current.id]);
3082
+ }
3083
+ const updated = (await query(`select * from auth.users where id = $1`, [current.id])).rows[0];
3084
+ if (!updated)
3085
+ return authError(401, "no_authorization", "Invalid or expired token");
3086
+ return { user: updated, linkToken, code, redirectTo };
3087
+ });
3088
+ if (prepared instanceof Response)
3089
+ return prepared;
3090
+ const actionUrl = new URL(`${this.config.apiUrl}/auth/v1/verify`);
3091
+ actionUrl.searchParams.set("token", prepared.linkToken);
3092
+ actionUrl.searchParams.set("type", "email_change");
3093
+ actionUrl.searchParams.set("redirect_to", prepared.redirectTo);
3094
+ await this.config.mailer.send({
3095
+ to: newEmail,
3096
+ subject: "Confirm your email change",
3097
+ text: `Confirm your new email address with this link: ${actionUrl}
3098
+
3099
+ Or use code ${prepared.code}`
3100
+ });
3101
+ return json(200, await this.userJsonWithRelations(prepared.user));
3102
+ }
1630
3103
  async logout(req, url) {
1631
3104
  const claims = await this.claimsFromBearer(req);
1632
3105
  if (claims?.sub) {
@@ -1649,9 +3122,12 @@ class AuthHandler {
1649
3122
  }
1650
3123
  return new Response(null, { status: 204 });
1651
3124
  }
1652
- async issueToken(email, tokenType, createUser, flavor = "login") {
3125
+ async issueToken(email, tokenType, createUser, flavor = "login", metadata = {}, requestedRedirect, codeChallenge, codeChallengeMethod) {
1653
3126
  const normalized = email.toLowerCase().trim();
1654
3127
  const expiry = `${this.settings.otpExpirySeconds} seconds`;
3128
+ const normalizedChallenge = typeof codeChallenge === "string" && codeChallenge ? codeChallenge : null;
3129
+ const normalizedChallengeMethod = normalizedChallenge ? codeChallengeMethod?.toLowerCase() || "s256" : null;
3130
+ const resolvedRedirect = resolveRedirect(requestedRedirect, this.config.siteUrl, this.config.uriAllowList, this.config.enforceRedirectAllowList);
1655
3131
  const prepared = await this.db.transaction(async (query) => {
1656
3132
  let user = (await query(`select * from auth.users where lower(email) = $1 for update`, [normalized])).rows[0];
1657
3133
  if (!user) {
@@ -1660,9 +3136,9 @@ class AuthHandler {
1660
3136
  if (this.settings.disableSignup)
1661
3137
  return { kind: "signup_disabled" };
1662
3138
  const inserted = await query(`insert into auth.users (aud, role, email, raw_app_meta_data, raw_user_meta_data)
1663
- values ('authenticated', 'authenticated', $1, '{"provider":"email","providers":["email"]}', '{}')
3139
+ values ('authenticated', 'authenticated', $1, '{"provider":"email","providers":["email"]}', $2::jsonb)
1664
3140
  on conflict (email) do nothing
1665
- returning *`, [normalized]);
3141
+ returning *`, [normalized, JSON.stringify(metadata)]);
1666
3142
  user = inserted.rows[0];
1667
3143
  if (!user) {
1668
3144
  user = (await query(`select * from auth.users where lower(email) = $1 for update`, [normalized])).rows[0];
@@ -1676,6 +3152,20 @@ class AuthHandler {
1676
3152
  await query(`delete from auth.one_time_tokens where lower(email) = $1 and token_type = any($2::text[])`, [normalized, tokenTypes]);
1677
3153
  await query(`insert into auth.one_time_tokens (user_id, email, token_type, token, expires_at)
1678
3154
  values ($1, $2, $3, $4, now() + $7::interval), ($1, $2, $5, $6, now() + $7::interval)`, [user.id, normalized, tokenType, code, tokenType === "otp" ? "magiclink" : tokenType, linkToken, expiry]);
3155
+ if (normalizedChallenge) {
3156
+ await query(`delete from auth.flow_state
3157
+ where provider in ('email', 'email-recovery') and user_id = $1 and auth_code is null`, [user.id]);
3158
+ await query(`insert into auth.flow_state
3159
+ (provider, provider_state, redirect_to, code_challenge, code_challenge_method, user_id, expires_at)
3160
+ values ($1, $2, $3, $4, $5, $6, now() + interval '10 minutes')`, [
3161
+ tokenType === "recovery" ? "email-recovery" : "email",
3162
+ linkToken,
3163
+ resolvedRedirect,
3164
+ normalizedChallenge,
3165
+ normalizedChallengeMethod,
3166
+ user.id
3167
+ ]);
3168
+ }
1679
3169
  return { kind: "issued", user, code, linkToken };
1680
3170
  });
1681
3171
  if (prepared.kind === "unknown")
@@ -1685,7 +3175,13 @@ class AuthHandler {
1685
3175
  }
1686
3176
  const { user, code, linkToken } = prepared;
1687
3177
  const kind = tokenType === "otp" ? "magiclink" : tokenType;
1688
- const link = `${this.config.apiUrl}/auth/v1/verify?token=${linkToken}&type=${kind}`;
3178
+ const linkUrl = new URL(`${this.config.apiUrl}/auth/v1/verify`);
3179
+ linkUrl.searchParams.set("token", linkToken);
3180
+ linkUrl.searchParams.set("type", kind);
3181
+ if (requestedRedirect) {
3182
+ linkUrl.searchParams.set("redirect_to", resolvedRedirect);
3183
+ }
3184
+ const link = linkUrl.toString();
1689
3185
  await this.config.mailer.send({
1690
3186
  to: normalized,
1691
3187
  subject: tokenType === "recovery" ? "Reset your password" : flavor === "confirm" ? "Confirm your email" : "Your login code",
@@ -1697,25 +3193,90 @@ Or enter the code ${code}` : `Your one-time code is ${code}
1697
3193
 
1698
3194
  Or sign in with this link: ${link}`
1699
3195
  });
1700
- return json(200, {});
1701
- }
1702
- async sendOtp(req) {
1703
- const body = await req.json().catch(() => ({}));
1704
- if (body.email && body.phone)
1705
- return authError(400, "validation_failed", "email and phone are mutually exclusive");
1706
- if (body.phone !== undefined) {
1707
- if (body.channel !== undefined && body.channel !== "sms") {
1708
- return authError(422, "unsupported_channel", "Only the sms phone channel is supported");
1709
- }
1710
- const phone = normalizePhone(body.phone);
1711
- if (!phone)
1712
- return authError(400, "validation_failed", "phone must be a valid E.164 number");
1713
- return this.limit("sms", req) ?? this.issuePhoneToken(phone, body.create_user !== false, recordValue(body.data));
3196
+ return json(200, {});
3197
+ }
3198
+ async sendOtp(req, resend = false) {
3199
+ const body = await req.json().catch(() => ({}));
3200
+ if (body.email && body.phone)
3201
+ return authError(400, "validation_failed", "email and phone are mutually exclusive");
3202
+ if (body.phone !== undefined) {
3203
+ if (body.channel !== undefined && body.channel !== "sms") {
3204
+ return authError(422, "unsupported_channel", "Only the sms phone channel is supported");
3205
+ }
3206
+ const phone = normalizePhone(body.phone);
3207
+ if (!phone)
3208
+ return authError(400, "validation_failed", "phone must be a valid E.164 number");
3209
+ if (resend && body.type === "phone_change") {
3210
+ const user = await this.userFromBearer(req);
3211
+ if (!user || user.phone_change !== phone)
3212
+ return json(200, {});
3213
+ return this.requestPhoneChange(req, user, phone);
3214
+ }
3215
+ return this.limit("sms", req) ?? this.issuePhoneToken(phone, body.create_user !== false, recordValue(body.data));
3216
+ }
3217
+ const email = normalizeEmailInput(body.email);
3218
+ if (!email)
3219
+ return authError(400, "validation_failed", "email or phone is required");
3220
+ if (resend && body.type === "email_change") {
3221
+ return this.resendEmailChange(req, email, body);
3222
+ }
3223
+ const signupResend = resend && body.type === "signup";
3224
+ return this.limit("otp", req) ?? this.issueToken(email, "otp", signupResend ? false : body.create_user !== false, signupResend ? "confirm" : "login", recordValue(body.data), new URL(req.url).searchParams.get("redirect_to"), body.code_challenge, body.code_challenge_method);
3225
+ }
3226
+ async resendEmailChange(req, newEmail, body) {
3227
+ const result = await this.db.query(`select * from auth.users
3228
+ where lower(email_change) = $1 and deleted_at is null
3229
+ and (banned_until is null or banned_until <= now())`, [newEmail]);
3230
+ const user = result.rows[0];
3231
+ if (!user)
3232
+ return json(200, {});
3233
+ return this.requestEmailChange(req, user, newEmail, body);
3234
+ }
3235
+ async requestPhoneChange(req, user, newPhone) {
3236
+ const sender = this.config.smsSender;
3237
+ if (!this.settings.smsEnabled || !sender) {
3238
+ return authError(422, "phone_provider_disabled", "Phone sign-ins are disabled");
3239
+ }
3240
+ const fingerprint = await keyedDigest(this.config.jwtSecret, "supacloud-lite:phone-fingerprint:v1", newPhone);
3241
+ const limited = this.limitKey("sms", `phone:${fingerprint}`) ?? this.limit("sms", req);
3242
+ if (limited)
3243
+ return limited;
3244
+ const token = randomOtp(this.settings.otpLength);
3245
+ const tokenDigest = `hmac-sha256:v1:${await keyedDigest(this.config.jwtSecret, "supacloud-lite:phone-otp:v1", `${newPhone}\x00${token}`)}`;
3246
+ const issuanceId = crypto.randomUUID();
3247
+ const prepared = await this.db.transaction(async (query) => {
3248
+ const currentResult = await query(`select * from auth.users where id = $1 and deleted_at is null
3249
+ and (banned_until is null or banned_until <= now()) for update`, [user.id]);
3250
+ const current = currentResult.rows[0];
3251
+ if (!current)
3252
+ return null;
3253
+ if (current.phone === newPhone)
3254
+ return { user: current, unchanged: true };
3255
+ const clash = await query(`select id from auth.users where phone = $1 and id <> $2`, [newPhone, current.id]);
3256
+ if (clash.rows.length > 0)
3257
+ return { conflict: true };
3258
+ await query(`delete from auth.one_time_tokens where user_id = $1 and token_type = 'phone_change'`, [current.id]);
3259
+ await query(`insert into auth.one_time_tokens (id, user_id, phone, token_type, token, expires_at)
3260
+ values ($1, $2, $3, 'phone_change', $4, now() + $5::interval)`, [issuanceId, current.id, newPhone, tokenDigest, `${this.settings.otpExpirySeconds} seconds`]);
3261
+ const updated = (await query(`update auth.users
3262
+ set phone_change = $1, phone_change_token = $2, phone_change_sent_at = now(), updated_at = now()
3263
+ where id = $3 returning *`, [newPhone, tokenDigest, current.id])).rows[0];
3264
+ return updated ? { user: updated, unchanged: false } : null;
3265
+ });
3266
+ if (!prepared)
3267
+ return authError(401, "no_authorization", "Invalid or expired token");
3268
+ if ("conflict" in prepared)
3269
+ return authError(422, "phone_exists", "A user with this phone number has already been registered");
3270
+ if (prepared.unchanged)
3271
+ return json(200, await this.userJsonWithRelations(prepared.user));
3272
+ try {
3273
+ await this.sendPhoneOtp(sender, newPhone, this.settings.smsTemplate.replace(/\{\{\s*\.Code\s*\}\}/g, token));
3274
+ } catch {
3275
+ await this.db.query(`delete from auth.one_time_tokens where id = $1 and token = $2`, [issuanceId, tokenDigest]);
3276
+ await this.db.query(`update auth.users set phone_change = null, phone_change_token = '', phone_change_sent_at = null where id = $1`, [user.id]);
3277
+ return authError(502, "sms_provider_failed", "Unable to send the verification code");
1714
3278
  }
1715
- const email = normalizeEmailInput(body.email);
1716
- if (!email)
1717
- return authError(400, "validation_failed", "email or phone is required");
1718
- return this.limit("otp", req) ?? this.issueToken(email, "otp", body.create_user !== false);
3279
+ return json(200, await this.userJsonWithRelations(prepared.user));
1719
3280
  }
1720
3281
  async issuePhoneToken(phone, createUser, metadata) {
1721
3282
  const sender = this.config.smsSender;
@@ -1769,7 +3330,7 @@ Or sign in with this link: ${link}`
1769
3330
  const resolved = await this.phoneUser(query, request);
1770
3331
  if (resolved === "unknown" || resolved === "signup_disabled")
1771
3332
  return { state: resolved };
1772
- await this.createPhoneIdentity(query, resolved);
3333
+ await this.ensurePhoneIdentity(resolved, query);
1773
3334
  await query(`delete from auth.one_time_tokens where phone = $1 and token_type = 'sms'`, [request.phone]);
1774
3335
  await query(`insert into auth.one_time_tokens (id, user_id, phone, token_type, token, expires_at)
1775
3336
  values ($1, $2, $3, 'sms', $4, now() + $5::interval)`, [request.issuanceId, resolved.id, request.phone, request.tokenDigest, request.expiry]);
@@ -1813,11 +3374,6 @@ Or sign in with this link: ${link}`
1813
3374
  from auth.users where phone = $1 for update`, [phone]);
1814
3375
  return users.rows[0];
1815
3376
  }
1816
- createPhoneIdentity(query, user) {
1817
- return query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
1818
- values ($1, 'phone', $2, $3::jsonb)
1819
- on conflict (provider, provider_id) do nothing`, [user.id, user.phone, JSON.stringify({ sub: user.id, phone: user.phone, phone_verified: user.phone_confirmed_at != null })]);
1820
- }
1821
3377
  async deliverPhoneOtp(issuance, sender, phone, body) {
1822
3378
  try {
1823
3379
  await this.sendPhoneOtp(sender, phone, body);
@@ -1872,10 +3428,21 @@ Or sign in with this link: ${link}`
1872
3428
  const email = normalizeEmailInput(body.email);
1873
3429
  if (!email)
1874
3430
  return authError(400, "validation_failed", "email is required");
1875
- return this.issueToken(email, "recovery", false);
3431
+ return this.issueToken(email, "recovery", false, "login", {}, new URL(req.url).searchParams.get("redirect_to"), body.code_challenge, body.code_challenge_method);
1876
3432
  }
1877
3433
  static MAX_OTP_ATTEMPTS = 5;
1878
- async redeem(token, types, email) {
3434
+ async redeem(token, types, email, sessionOptions) {
3435
+ const redemption = {
3436
+ token,
3437
+ tokenTypes: `{${types.join(",")}}`,
3438
+ email: email?.toLowerCase().trim() ?? null
3439
+ };
3440
+ return this.db.transaction(async (query) => {
3441
+ const user = await this.claimEmailToken(query, redemption);
3442
+ return user ? this.sessionFor(user, undefined, sessionOptions, query) : null;
3443
+ });
3444
+ }
3445
+ async redeemUser(token, types, email) {
1879
3446
  const redemption = {
1880
3447
  token,
1881
3448
  tokenTypes: `{${types.join(",")}}`,
@@ -1883,7 +3450,7 @@ Or sign in with this link: ${link}`
1883
3450
  };
1884
3451
  return this.db.transaction(async (query) => {
1885
3452
  const user = await this.claimEmailToken(query, redemption);
1886
- return user ? this.sessionFor(user, undefined, undefined, query) : null;
3453
+ return user;
1887
3454
  });
1888
3455
  }
1889
3456
  async claimEmailToken(query, redemption) {
@@ -1935,6 +3502,20 @@ Or sign in with this link: ${link}`
1935
3502
  const token = body.token ?? body.token_hash;
1936
3503
  if (!token)
1937
3504
  return authError(400, "validation_failed", "token or token_hash is required");
3505
+ if (body.type === "phone_change") {
3506
+ const phone = normalizePhone(body.phone);
3507
+ if (!phone || !body.token || !/^\d{6,10}$/.test(body.token)) {
3508
+ return authError(400, "validation_failed", "phone, token, and type=phone_change are required");
3509
+ }
3510
+ const fingerprint = await keyedDigest(this.config.jwtSecret, "supacloud-lite:phone-fingerprint:v1", phone);
3511
+ const limited = this.limitKey("verify", `phone:${fingerprint}`);
3512
+ if (limited)
3513
+ return limited;
3514
+ const session = await this.redeemPhoneChange(phone, body.token);
3515
+ if (!session)
3516
+ return authError(403, "otp_expired", "Token has expired or is invalid");
3517
+ return json(200, session);
3518
+ }
1938
3519
  if (body.type === "sms" || body.phone !== undefined) {
1939
3520
  const phone = normalizePhone(body.phone);
1940
3521
  if (body.type !== "sms" || !phone || !body.token || !/^\d{6,10}$/.test(body.token)) {
@@ -1955,8 +3536,22 @@ Or sign in with this link: ${link}`
1955
3536
  return authError(403, "otp_expired", "Token has expired or is invalid");
1956
3537
  return json(200, session);
1957
3538
  }
1958
- const types = body.type === "recovery" ? ["recovery"] : body.type === "magiclink" ? ["magiclink"] : ["otp", "magiclink"];
1959
- const session = await this.redeem(token, types, body.email);
3539
+ if (body.type === "email_change") {
3540
+ const result = await this.redeemEmailChange(token, body.email);
3541
+ if (!result)
3542
+ return authError(403, "otp_expired", "Token has expired or is invalid");
3543
+ if ("pending" in result) {
3544
+ return json(200, {
3545
+ msg: "Confirmation link accepted. Please proceed to confirm link sent to the other email",
3546
+ code: "200"
3547
+ });
3548
+ }
3549
+ return json(200, result);
3550
+ }
3551
+ const types = body.type === "recovery" ? ["recovery"] : body.type === "magiclink" ? ["magiclink"] : body.type === "signup" || body.type === "invite" ? [body.type] : ["otp", "magiclink"];
3552
+ const session = await this.redeem(token, types, body.email, {
3553
+ amr: [{ method: body.type === "recovery" ? "recovery" : "otp", timestamp: Math.floor(Date.now() / 1000) }]
3554
+ });
1960
3555
  if (!session)
1961
3556
  return authError(403, "otp_expired", "Token has expired or is invalid");
1962
3557
  return json(200, session);
@@ -2004,24 +3599,199 @@ Or sign in with this link: ${link}`
2004
3599
  const user = users.rows[0];
2005
3600
  if (!user)
2006
3601
  return null;
2007
- await query(`insert into auth.identities (user_id, provider, provider_id, identity_data, last_sign_in_at)
2008
- values ($1, 'phone', $2, $3::jsonb, now())
2009
- on conflict (provider, provider_id) do update
2010
- set identity_data = excluded.identity_data, last_sign_in_at = now(), updated_at = now()`, [user.id, phone, JSON.stringify({ sub: user.id, phone, phone_verified: true })]);
3602
+ await this.ensurePhoneIdentity(user, query);
3603
+ await query(`update auth.identities
3604
+ set last_sign_in_at = now(), updated_at = now()
3605
+ where user_id = $1 and provider = 'phone' and provider_id = $2`, [user.id, phone]);
2011
3606
  return this.sessionFor(user, undefined, { amr: [{ method: "otp", timestamp: Math.floor(Date.now() / 1000) }] }, query);
2012
3607
  });
2013
3608
  }
3609
+ async redeemPhoneChange(phone, code) {
3610
+ const tokenDigest = `hmac-sha256:v1:${await keyedDigest(this.config.jwtSecret, "supacloud-lite:phone-otp:v1", `${phone}\x00${code}`)}`;
3611
+ return this.db.transaction(async (query) => {
3612
+ const candidate = (await query(`select id, user_id from auth.one_time_tokens
3613
+ where phone = $1 and token_type = 'phone_change' and token = $2 and expires_at > now()
3614
+ limit 1`, [phone, tokenDigest])).rows[0];
3615
+ if (!candidate)
3616
+ return null;
3617
+ const currentResult = await query(`select * from auth.users where id = $1 and phone_change = $2 and deleted_at is null
3618
+ and (banned_until is null or banned_until <= now()) for update`, [candidate.user_id, phone]);
3619
+ const current = currentResult.rows[0];
3620
+ if (!current)
3621
+ return null;
3622
+ const clash = await query(`select id from auth.users where phone = $1 and id <> $2`, [phone, current.id]);
3623
+ if (clash.rows.length > 0)
3624
+ return null;
3625
+ const claimed = await query(`delete from auth.one_time_tokens where id = $1 and token = $2 returning id`, [candidate.id, tokenDigest]);
3626
+ if (claimed.rows.length === 0)
3627
+ return null;
3628
+ await query(`delete from auth.identities where user_id = $1 and provider = 'phone'`, [current.id]);
3629
+ const updated = (await query(`update auth.users
3630
+ set phone = $1, phone_confirmed_at = now(),
3631
+ phone_change = null, phone_change_token = '', phone_change_sent_at = null,
3632
+ updated_at = now()
3633
+ where id = $2 and deleted_at is null
3634
+ and (banned_until is null or banned_until <= now())
3635
+ returning *`, [phone, current.id])).rows[0];
3636
+ if (!updated)
3637
+ return null;
3638
+ await this.ensurePhoneIdentity(updated, query);
3639
+ return this.sessionFor(updated, undefined, { amr: [{ method: "otp", timestamp: Math.floor(Date.now() / 1000) }] }, query);
3640
+ });
3641
+ }
2014
3642
  async verifyLink(url) {
2015
3643
  const token = url.searchParams.get("token") ?? "";
2016
3644
  const type = url.searchParams.get("type") ?? "magiclink";
2017
3645
  const redirectTo = resolveRedirect(url.searchParams.get("redirect_to"), this.config.siteUrl, this.config.uriAllowList, this.config.enforceRedirectAllowList);
2018
- const session = await this.redeem(token, [type]);
3646
+ const flow = (await this.db.query(`select id, provider, redirect_to, code_challenge, code_challenge_method
3647
+ from auth.flow_state
3648
+ where provider in ('email', 'email-recovery', 'email-change')
3649
+ and provider_state = $1 and auth_code is null and expires_at > now()`, [token])).rows[0];
3650
+ const emailChange = type === "email_change" ? await this.redeemEmailChangeUser(token) : null;
3651
+ if (emailChange === "pending") {
3652
+ return redirectWithQuery2(redirectTo, {
3653
+ message: "Confirmation link accepted. Please proceed to confirm link sent to the other email"
3654
+ });
3655
+ }
3656
+ let user = null;
3657
+ let session = null;
3658
+ if (type === "email_change") {
3659
+ user = emailChange;
3660
+ } else if (flow) {
3661
+ user = await this.redeemUser(token, [type]);
3662
+ } else {
3663
+ session = await this.redeem(token, [type], undefined, {
3664
+ amr: [{ method: type === "recovery" ? "recovery" : "otp", timestamp: Math.floor(Date.now() / 1000) }]
3665
+ });
3666
+ }
3667
+ if (type === "email_change" || flow) {
3668
+ if (!user) {
3669
+ return new Response(null, { status: 303, headers: { location: redirectTo + "#error=access_denied&error_code=otp_expired" } });
3670
+ }
3671
+ if (flow) {
3672
+ const authCode = randomToken(24);
3673
+ const claimed = await this.db.query(`update auth.flow_state
3674
+ set auth_code = $1, user_id = $2, expires_at = now() + interval '5 minutes'
3675
+ where id = $3 and auth_code is null and expires_at > now()
3676
+ returning redirect_to`, [authCode, user.id, flow.id]);
3677
+ const target = claimed.rows[0]?.redirect_to;
3678
+ if (target)
3679
+ return redirectWithQuery2(target, { code: authCode });
3680
+ }
3681
+ session = await this.sessionFor(user, undefined, {
3682
+ amr: [{ method: type === "recovery" ? "recovery" : "otp", timestamp: Math.floor(Date.now() / 1000) }]
3683
+ });
3684
+ }
2019
3685
  if (!session) {
2020
- return new Response(null, { status: 303, headers: { location: `${redirectTo}#error=access_denied&error_code=otp_expired` } });
3686
+ return new Response(null, { status: 303, headers: { location: redirectTo + "#error=access_denied&error_code=otp_expired" } });
2021
3687
  }
2022
3688
  const hash = `#access_token=${session.access_token}&refresh_token=${session.refresh_token}&expires_in=${session.expires_in}&token_type=bearer&type=${type}`;
2023
3689
  return new Response(null, { status: 303, headers: { location: `${redirectTo}${hash}` } });
2024
3690
  }
3691
+ async exchangeEmailPkce(authCode, verifier) {
3692
+ return this.db.transaction(async (query) => {
3693
+ const flow = (await query(`select id, provider, redirect_to, code_challenge, code_challenge_method
3694
+ from auth.flow_state
3695
+ where provider in ('email', 'email-recovery', 'email-change')
3696
+ and auth_code = $1 and expires_at > now()`, [authCode])).rows[0];
3697
+ if (!flow)
3698
+ return null;
3699
+ if (flow.code_challenge_method === "s256" || flow.code_challenge_method === "S256") {
3700
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
3701
+ const challenge = base64url2(new Uint8Array(digest));
3702
+ if (challenge !== flow.code_challenge)
3703
+ return null;
3704
+ } else if (flow.code_challenge !== verifier) {
3705
+ return null;
3706
+ }
3707
+ const claimed = await query(`delete from auth.flow_state where id = $1 and auth_code = $2 and expires_at > now()
3708
+ returning user_id`, [flow.id, authCode]);
3709
+ const userId = claimed.rows[0]?.user_id;
3710
+ return userId ? { userId, provider: flow.provider } : null;
3711
+ });
3712
+ }
3713
+ async redeemEmailChangeUser(token, email) {
3714
+ return this.db.transaction((query) => this.claimEmailChange(query, token, email));
3715
+ }
3716
+ async redeemEmailChange(token, email) {
3717
+ return this.db.transaction(async (query) => {
3718
+ const user = await this.claimEmailChange(query, token, email);
3719
+ if (user === "pending")
3720
+ return { pending: true };
3721
+ return user ? this.sessionFor(user, undefined, {
3722
+ amr: [{ method: "otp", timestamp: Math.floor(Date.now() / 1000) }]
3723
+ }, query) : null;
3724
+ });
3725
+ }
3726
+ async claimEmailChange(query, token, email) {
3727
+ const candidate = (await query(`select id, user_id, email, token_type from auth.one_time_tokens
3728
+ where token = $1 and token_type in ('email_change', 'email_change_current', 'email_change_new')
3729
+ and expires_at > now()
3730
+ and ($2::text is null or lower(email) = lower($2))
3731
+ limit 1`, [token, email?.toLowerCase().trim() ?? null])).rows[0];
3732
+ if (!candidate)
3733
+ return null;
3734
+ const currentResult = await query(`select * from auth.users where id = $1 and email_change is not null and deleted_at is null
3735
+ and (banned_until is null or banned_until <= now()) for update`, [candidate.user_id]);
3736
+ const current = currentResult.rows[0];
3737
+ if (!current)
3738
+ return null;
3739
+ if (candidate.token_type === "email_change" ? current.email_change?.toLowerCase() !== candidate.email.toLowerCase() : candidate.token_type === "email_change_current" ? current.email?.toLowerCase() !== candidate.email.toLowerCase() : current.email_change?.toLowerCase() !== candidate.email.toLowerCase())
3740
+ return null;
3741
+ const targetEmail = candidate.token_type === "email_change_current" ? current.email_change : candidate.email;
3742
+ if (!targetEmail)
3743
+ return null;
3744
+ const clash = await query(`select id from auth.users where lower(email) = lower($1) and id <> $2`, [targetEmail, candidate.user_id]);
3745
+ if (clash.rows.length > 0)
3746
+ return null;
3747
+ const needsSecondConfirmation = candidate.token_type !== "email_change";
3748
+ if (needsSecondConfirmation && (current.email_change_confirm_status ?? 0) === 0) {
3749
+ const deleted = await query(`delete from auth.one_time_tokens
3750
+ where user_id = $1 and token_type = $2 returning id`, [candidate.user_id, candidate.token_type]);
3751
+ if (deleted.rows.length === 0)
3752
+ return null;
3753
+ const updated = (await query(`update auth.users
3754
+ set email_change_confirm_status = 1,
3755
+ email_change_token_current = case when $1 = 'email_change_current' then '' else email_change_token_current end,
3756
+ email_change_token_new = case when $1 = 'email_change_new' then '' else email_change_token_new end,
3757
+ updated_at = now()
3758
+ where id = $2
3759
+ returning *`, [candidate.token_type, candidate.user_id])).rows[0];
3760
+ return updated ? "pending" : null;
3761
+ }
3762
+ const deleted = await query(`delete from auth.one_time_tokens where user_id = $1
3763
+ and token_type = $2 returning id`, [candidate.user_id, candidate.token_type]);
3764
+ if (deleted.rows.length === 0)
3765
+ return null;
3766
+ const updated = (await query(`update auth.users
3767
+ set email = $1, email_confirmed_at = now(),
3768
+ email_change = null, email_change_sent_at = null,
3769
+ email_change_token_current = '', email_change_token_new = '',
3770
+ email_change_confirm_status = 0,
3771
+ is_anonymous = false,
3772
+ raw_app_meta_data = case when is_anonymous
3773
+ then coalesce(raw_app_meta_data, '{}'::jsonb) || '{"provider":"email","providers":["email"]}'::jsonb
3774
+ else raw_app_meta_data end,
3775
+ updated_at = now()
3776
+ where id = $2
3777
+ returning *`, [targetEmail, candidate.user_id])).rows[0];
3778
+ if (!updated)
3779
+ return null;
3780
+ await this.ensureEmailIdentity(updated, query);
3781
+ return updated;
3782
+ }
3783
+ async sessionForEmailPkce(userId, provider) {
3784
+ return this.db.transaction(async (query) => {
3785
+ const result = await query(`select * from auth.users where id = $1 and deleted_at is null
3786
+ and (banned_until is null or banned_until <= now()) for update`, [userId]);
3787
+ const user = result.rows[0];
3788
+ if (!user)
3789
+ throw new OAuthAccountIneligibleError("user_not_found");
3790
+ return this.sessionFor(user, undefined, {
3791
+ amr: [{ method: provider === "email-recovery" ? "recovery" : "otp", timestamp: Math.floor(Date.now() / 1000) }]
3792
+ }, query);
3793
+ });
3794
+ }
2025
3795
  async admin(req, ctx, path, method) {
2026
3796
  if (ctx.role !== "service_role") {
2027
3797
  return authError(403, "insufficient_permissions", "Admin endpoints require the service_role key");
@@ -2054,38 +3824,96 @@ Or sign in with this link: ${link}`
2054
3824
  return json(200, { id: factorMatch[2] });
2055
3825
  }
2056
3826
  if (path === "admin/users" && method === "GET") {
2057
- const res = await this.db.query(`select * from auth.users order by created_at desc limit 1000`);
2058
- return json(200, { users: res.rows.map((user) => this.userJson(user)), aud: "authenticated" });
3827
+ const requestUrl = new URL(req.url);
3828
+ const pageValue = requestUrl.searchParams.get("page");
3829
+ const perPageValue = requestUrl.searchParams.get("per_page");
3830
+ const page = pageValue === null ? 1 : Number(pageValue);
3831
+ const perPage = perPageValue === null ? 50 : Number(perPageValue);
3832
+ if (!Number.isInteger(page) || page < 1 || !Number.isInteger(perPage) || perPage < 1 || perPage > 1000) {
3833
+ return authError(400, "validation_failed", "page must be a positive integer and per_page must be between 1 and 1000");
3834
+ }
3835
+ const total = Number((await this.db.query(`select count(*)::int as count from auth.users`)).rows[0]?.count ?? 0);
3836
+ const res = await this.db.query(`select * from auth.users order by created_at desc, id desc limit $1 offset $2`, [perPage, (page - 1) * perPage]);
3837
+ const lastPage = Math.max(1, Math.ceil(total / perPage));
3838
+ const linkUrl = (targetPage) => {
3839
+ const target = new URL(requestUrl);
3840
+ target.searchParams.set("page", String(targetPage));
3841
+ target.searchParams.set("per_page", String(perPage));
3842
+ return target.toString();
3843
+ };
3844
+ const links = [
3845
+ `<${linkUrl(1)}>; rel="first"`,
3846
+ `<${linkUrl(lastPage)}>; rel="last"`,
3847
+ ...page > 1 ? [`<${linkUrl(page - 1)}>; rel="prev"`] : [],
3848
+ ...page < lastPage ? [`<${linkUrl(page + 1)}>; rel="next"`] : []
3849
+ ];
3850
+ return json(200, { users: res.rows.map((user) => this.userJson(user)), aud: "authenticated" }, { "x-total-count": String(total), link: links.join(", ") });
2059
3851
  }
2060
3852
  if (path === "admin/users" && method === "POST") {
2061
3853
  const body = await req.json().catch(() => ({}));
2062
3854
  const email = normalizeEmailInput(body.email);
2063
- if (!email)
2064
- return authError(400, "validation_failed", "email is required");
3855
+ if (body.email !== undefined && !email)
3856
+ return authError(400, "validation_failed", "email must not be empty");
3857
+ const phone = body.phone === undefined ? null : normalizePhone(body.phone);
3858
+ if (body.phone !== undefined && !phone) {
3859
+ return authError(400, "validation_failed", "phone must be a valid E.164 number");
3860
+ }
3861
+ if (!email && !phone)
3862
+ return authError(400, "validation_failed", "email or phone is required");
3863
+ if (body.phone_confirm !== undefined && typeof body.phone_confirm !== "boolean") {
3864
+ return authError(400, "validation_failed", "phone_confirm must be a boolean");
3865
+ }
3866
+ const banSeconds = body.ban_duration === undefined ? null : banDurationSeconds(body.ban_duration);
3867
+ if (banSeconds === undefined)
3868
+ return authError(400, "validation_failed", "ban_duration is invalid");
2065
3869
  const hashed = body.password ? await hashPassword(body.password) : null;
2066
3870
  const created = await this.db.transaction(async (query) => {
2067
- const existing = await query(`select id from auth.users where lower(email) = $1 for update`, [email.toLowerCase().trim()]);
2068
- if (existing.rows.length > 0)
2069
- return null;
3871
+ if (email) {
3872
+ const existing = await query(`select id from auth.users where lower(email) = $1 for update`, [email]);
3873
+ if (existing.rows.length > 0)
3874
+ return { error: "email_exists" };
3875
+ }
3876
+ if (phone) {
3877
+ const phoneExisting = await query(`select id from auth.users where phone = $1 for update`, [phone]);
3878
+ if (phoneExisting.rows.length > 0)
3879
+ return { error: "phone_exists" };
3880
+ }
2070
3881
  const res = await query(`insert into auth.users
2071
- (aud, role, email, encrypted_password, email_confirmed_at, raw_app_meta_data, raw_user_meta_data)
2072
- values ('authenticated', 'authenticated', $1, $2, case when $3 then now() else null end, $4, $5)
2073
- on conflict (email) do nothing
3882
+ (aud, role, email, encrypted_password, email_confirmed_at, phone, phone_confirmed_at,
3883
+ banned_until, raw_app_meta_data, raw_user_meta_data)
3884
+ values ('authenticated', 'authenticated', $1, $2, case when $3 then now() else null end,
3885
+ $4, case when $5 then now() else null end,
3886
+ case when $6::double precision is null then null
3887
+ else now() + ($6::double precision * interval '1 second') end, $7, $8)
3888
+ on conflict do nothing
2074
3889
  returning *`, [
2075
- email.toLowerCase().trim(),
3890
+ email,
2076
3891
  hashed,
2077
- body.email_confirm ?? true,
2078
- JSON.stringify({ provider: "email", providers: ["email"], ...body.app_metadata ?? {} }),
3892
+ body.email_confirm ?? false,
3893
+ phone,
3894
+ body.phone_confirm === true,
3895
+ banSeconds,
3896
+ JSON.stringify({
3897
+ provider: email ? "email" : "phone",
3898
+ providers: email && phone ? ["email", "phone"] : [email ? "email" : "phone"],
3899
+ ...body.app_metadata ?? {}
3900
+ }),
2079
3901
  JSON.stringify(body.user_metadata ?? {})
2080
3902
  ]);
2081
3903
  const user = res.rows[0];
2082
3904
  if (!user)
2083
3905
  return null;
2084
- await this.ensureEmailIdentity(user, query);
3906
+ if (email)
3907
+ await this.ensureEmailIdentity(user, query);
3908
+ if (phone)
3909
+ await this.ensurePhoneIdentity(user, query);
2085
3910
  return this.userJsonWithRelations(user, query);
2086
3911
  });
2087
3912
  if (!created)
2088
3913
  return authError(422, "email_exists", "A user with this email address has already been registered");
3914
+ if ("error" in created) {
3915
+ return authError(422, created.error, created.error === "email_exists" ? "A user with this email address has already been registered" : "A user with this phone number has already been registered");
3916
+ }
2089
3917
  return json(200, created);
2090
3918
  }
2091
3919
  if (idMatch && method === "GET") {
@@ -2109,6 +3937,32 @@ Or sign in with this link: ${link}`
2109
3937
  params.push(await hashPassword(body.password));
2110
3938
  sets.push(`encrypted_password = $${params.length}`);
2111
3939
  }
3940
+ const requestedPhone = body.phone === undefined ? undefined : normalizePhone(body.phone);
3941
+ if (body.phone !== undefined && !requestedPhone) {
3942
+ return authError(400, "validation_failed", "phone must be a valid E.164 number");
3943
+ }
3944
+ if (body.phone_confirm !== undefined && typeof body.phone_confirm !== "boolean") {
3945
+ return authError(400, "validation_failed", "phone_confirm must be a boolean");
3946
+ }
3947
+ if (body.ban_duration !== undefined) {
3948
+ const seconds = banDurationSeconds(body.ban_duration);
3949
+ if (seconds === undefined)
3950
+ return authError(400, "validation_failed", "ban_duration is invalid");
3951
+ if (seconds === null) {
3952
+ sets.push("banned_until = null");
3953
+ } else {
3954
+ params.push(seconds);
3955
+ sets.push(`banned_until = now() + ($${params.length} * interval '1 second')`);
3956
+ }
3957
+ }
3958
+ if (requestedPhone) {
3959
+ params.push(requestedPhone);
3960
+ sets.push(`phone = $${params.length}`);
3961
+ }
3962
+ if (body.phone_confirm === true)
3963
+ sets.push("phone_confirmed_at = now()");
3964
+ if (body.phone_confirm === false)
3965
+ sets.push("phone_confirmed_at = null");
2112
3966
  if (body.user_metadata) {
2113
3967
  params.push(JSON.stringify(body.user_metadata));
2114
3968
  sets.push(`raw_user_meta_data = $${params.length}::jsonb`);
@@ -2118,12 +3972,15 @@ Or sign in with this link: ${link}`
2118
3972
  sets.push(`raw_app_meta_data = coalesce(raw_app_meta_data, '{}'::jsonb) || $${params.length}::jsonb`);
2119
3973
  }
2120
3974
  if (body.email_confirm === true)
2121
- sets.push(`email_confirmed_at = now()`);
3975
+ sets.push("email_confirmed_at = now()");
3976
+ if (body.email_confirm === false)
3977
+ sets.push("email_confirmed_at = null");
2122
3978
  if (sets.length === 0)
2123
3979
  return authError(400, "validation_failed", "nothing to update");
2124
3980
  params.push(idMatch[1]);
2125
3981
  let updated;
2126
3982
  let emailConflict = false;
3983
+ let phoneConflict = false;
2127
3984
  try {
2128
3985
  updated = await this.db.transaction(async (query) => {
2129
3986
  if (requestedEmail) {
@@ -2133,77 +3990,157 @@ Or sign in with this link: ${link}`
2133
3990
  return null;
2134
3991
  }
2135
3992
  }
3993
+ if (requestedPhone) {
3994
+ const clash = await query(`select id from auth.users where phone = $1 and id <> $2`, [requestedPhone, idMatch[1]]);
3995
+ if (clash.rows.length > 0) {
3996
+ phoneConflict = true;
3997
+ return null;
3998
+ }
3999
+ }
2136
4000
  const res = await query(`update auth.users set ${sets.join(", ")}, updated_at = now() where id = $${params.length} returning *`, params);
2137
4001
  const user = res.rows[0];
2138
4002
  if (!user)
2139
4003
  return null;
2140
4004
  if (requestedEmail)
2141
4005
  await this.ensureEmailIdentity(user, query);
4006
+ if (requestedPhone || body.phone_confirm !== undefined) {
4007
+ if (requestedPhone) {
4008
+ await query(`delete from auth.identities
4009
+ where user_id = $1 and provider = 'phone' and provider_id <> $2`, [user.id, requestedPhone]);
4010
+ }
4011
+ await this.ensurePhoneIdentity(user, query);
4012
+ }
2142
4013
  return this.userJsonWithRelations(user, query);
2143
4014
  });
2144
4015
  } catch (error) {
2145
4016
  if (databaseDiagnosticCode(error) === "23505" && requestedEmail) {
2146
4017
  return authError(422, "email_exists", "A user with this email address has already been registered");
2147
4018
  }
4019
+ if (databaseDiagnosticCode(error) === "23505" && requestedPhone) {
4020
+ return authError(422, "phone_exists", "A user with this phone number has already been registered");
4021
+ }
2148
4022
  throw error;
2149
4023
  }
2150
4024
  if (emailConflict)
2151
4025
  return authError(422, "email_exists", "A user with this email address has already been registered");
4026
+ if (phoneConflict)
4027
+ return authError(422, "phone_exists", "A user with this phone number has already been registered");
2152
4028
  if (!updated)
2153
4029
  return authError(404, "user_not_found", "User not found");
2154
4030
  return json(200, updated);
2155
4031
  }
2156
4032
  if (idMatch && method === "DELETE") {
2157
- return await this.eraseUser(idMatch[1]);
4033
+ return await this.eraseUser(idMatch[1], req);
2158
4034
  }
2159
4035
  return authError(404, "not_found", `unknown admin endpoint`);
2160
4036
  }
2161
- async generateAdminMagicLink(req) {
4037
+ async generateAdminMagicLink(req, sendEmail = false, forcedType) {
2162
4038
  const body = await req.json().catch(() => ({}));
2163
- if (body.type !== "magiclink") {
2164
- return authError(422, "unsupported_link_type", "Only magiclink generation is supported");
4039
+ if (forcedType)
4040
+ body.type = forcedType;
4041
+ if (!["signup", "invite", "magiclink", "recovery", "email_change_current", "email_change_new"].includes(body.type ?? "")) {
4042
+ return authError(422, "unsupported_link_type", "Unsupported email action link type");
2165
4043
  }
2166
4044
  const email = normalizeEmailInput(body.email);
2167
4045
  if (!email)
2168
4046
  return authError(400, "validation_failed", "email is required");
4047
+ const newEmail = body.new_email === undefined ? null : normalizeEmailInput(body.new_email);
4048
+ if (body.type === "email_change_current" || body.type === "email_change_new") {
4049
+ if (!newEmail)
4050
+ return authError(400, "validation_failed", "new_email is required");
4051
+ if (newEmail === email)
4052
+ return authError(422, "validation_failed", "new_email must differ from email");
4053
+ }
4054
+ if (body.type === "signup") {
4055
+ if (!body.password) {
4056
+ return authError(400, "validation_failed", "password is required for signup links");
4057
+ }
4058
+ if (body.password.length < this.settings.minPasswordLength) {
4059
+ return authError(422, "weak_password", `Password should be at least ${this.settings.minPasswordLength} characters.`);
4060
+ }
4061
+ }
2169
4062
  const emailOtp = randomOtp(this.settings.otpLength);
2170
4063
  const hashedToken = await sha256Hex(randomToken(32));
2171
4064
  const expiry = `${this.settings.otpExpirySeconds} seconds`;
4065
+ const passwordHash = body.type === "signup" ? await hashPassword(body.password) : null;
2172
4066
  const requestUrl = new URL(req.url);
2173
4067
  const redirectTo = resolveRedirect(requestUrl.searchParams.get("redirect_to") ?? body.redirect_to, this.config.siteUrl, this.config.uriAllowList, this.config.enforceRedirectAllowList);
2174
4068
  const user = await this.db.transaction(async (query) => {
2175
4069
  const existing = await query(`select * from auth.users where lower(email) = $1 for update`, [email]);
2176
4070
  let linkedUser = existing.rows[0];
4071
+ if (!linkedUser && (body.type === "recovery" || body.type === "email_change_current" || body.type === "email_change_new")) {
4072
+ return null;
4073
+ }
4074
+ if (linkedUser && (body.type === "signup" || body.type === "invite")) {
4075
+ return { error: "email_exists" };
4076
+ }
4077
+ if (linkedUser && (body.type === "email_change_current" || body.type === "email_change_new")) {
4078
+ const duplicate = await query(`select id from auth.users where lower(email) = $1 and id <> $2`, [newEmail, linkedUser.id]);
4079
+ if (duplicate.rows.length > 0)
4080
+ return { error: "email_exists" };
4081
+ const tokenType = body.type;
4082
+ await query(`delete from auth.one_time_tokens where user_id = $1 and token_type = $2`, [linkedUser.id, tokenType]);
4083
+ await query(`insert into auth.one_time_tokens (user_id, email, token_type, token, expires_at)
4084
+ values ($1, $2, $3, $4, now() + $5::interval),
4085
+ ($1, $2, $3, $6, now() + $5::interval)`, [linkedUser.id, tokenType === "email_change_current" ? email : newEmail, tokenType, emailOtp, expiry, hashedToken]);
4086
+ const tokenColumn = tokenType === "email_change_current" ? "email_change_token_current" : "email_change_token_new";
4087
+ await query(`update auth.users
4088
+ set email_change = $1, email_change_sent_at = now(), email_change_confirm_status = 0,
4089
+ ${tokenColumn} = $2, updated_at = now()
4090
+ where id = $3`, [newEmail, hashedToken, linkedUser.id]);
4091
+ return (await query(`select * from auth.users where id = $1`, [linkedUser.id])).rows[0];
4092
+ }
2177
4093
  if (!linkedUser) {
2178
4094
  const result = await query(`insert into auth.users
2179
- (aud, role, email, raw_app_meta_data, raw_user_meta_data)
2180
- values ('authenticated', 'authenticated', $1, '{"provider":"email","providers":["email"]}', $2::jsonb)
4095
+ (aud, role, email, encrypted_password, invited_at, raw_app_meta_data, raw_user_meta_data)
4096
+ values ('authenticated', 'authenticated', $1, $2, case when $3 then now() else null end,
4097
+ '{"provider":"email","providers":["email"]}', $4::jsonb)
2181
4098
  on conflict (email) do update set email = excluded.email
2182
- returning *`, [email, JSON.stringify(body.data ?? {})]);
4099
+ returning *`, [email, passwordHash, body.type === "invite", JSON.stringify(body.data ?? {})]);
2183
4100
  linkedUser = result.rows[0];
2184
4101
  }
2185
- await query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
2186
- values ($1::uuid, 'email', $1::text, $2::jsonb)
2187
- on conflict (provider, provider_id) do nothing`, [linkedUser.id, JSON.stringify({ sub: linkedUser.id, email })]);
4102
+ await this.ensureEmailIdentity(linkedUser, query);
2188
4103
  await query(`delete from auth.one_time_tokens
2189
- where lower(email) = $1 and token_type = any($2::text[])`, [email, "{otp,magiclink}"]);
4104
+ where lower(email) = $1 and token_type = any($2::text[])`, [email, body.type === "magiclink" ? "{otp,magiclink}" : `{${body.type}}`]);
2190
4105
  await query(`insert into auth.one_time_tokens (user_id, email, token_type, token, expires_at)
2191
- values ($1, $2, 'otp', $3, now() + $5::interval),
2192
- ($1, $2, 'magiclink', $4, now() + $5::interval)`, [linkedUser.id, email, emailOtp, hashedToken, expiry]);
4106
+ values ($1, $2, $3, $4, now() + $7::interval),
4107
+ ($1, $2, $5, $6, now() + $7::interval)`, [
4108
+ linkedUser.id,
4109
+ email,
4110
+ body.type === "magiclink" ? "otp" : body.type,
4111
+ emailOtp,
4112
+ body.type === "magiclink" ? "magiclink" : body.type,
4113
+ hashedToken,
4114
+ expiry
4115
+ ]);
2193
4116
  return linkedUser;
2194
4117
  });
4118
+ if (!user)
4119
+ return authError(404, "user_not_found", "User not found");
4120
+ if ("error" in user)
4121
+ return authError(422, user.error, "A user with this email address has already been registered");
2195
4122
  const actionUrl = new URL(`${this.config.apiUrl}/auth/v1/verify`);
2196
4123
  actionUrl.searchParams.set("token", hashedToken);
2197
- actionUrl.searchParams.set("type", "magiclink");
4124
+ actionUrl.searchParams.set("type", body.type === "email_change_current" || body.type === "email_change_new" ? "email_change" : body.type);
2198
4125
  actionUrl.searchParams.set("redirect_to", redirectTo);
2199
- await this.audit("user_magiclink_requested", { actorId: user.id, actorEmail: email, type: "admin" });
4126
+ if (sendEmail) {
4127
+ await this.audit("user_invited", { actorId: user.id, actorEmail: email, type: "admin" });
4128
+ await this.config.mailer.send({
4129
+ to: email,
4130
+ subject: "You have been invited",
4131
+ text: `Accept your invitation with this link: ${actionUrl}`
4132
+ });
4133
+ return json(200, { user: this.userJson(user, [], await this.getUserIdentities(user.id)) });
4134
+ }
4135
+ const auditAction = body.type === "invite" ? "user_invited" : body.type === "signup" ? "user_signedup" : body.type === "recovery" ? "user_recovery_requested" : body.type === "email_change_current" || body.type === "email_change_new" ? "user_email_change_requested" : "user_magiclink_requested";
4136
+ await this.audit(auditAction, { actorId: user.id, actorEmail: email, type: "admin" });
2200
4137
  return json(200, {
2201
4138
  ...this.userJson(user, [], await this.getUserIdentities(user.id)),
2202
4139
  action_link: actionUrl.toString(),
2203
4140
  email_otp: emailOtp,
2204
4141
  hashed_token: hashedToken,
2205
4142
  redirect_to: redirectTo,
2206
- verification_type: "magiclink"
4143
+ verification_type: body.type
2207
4144
  });
2208
4145
  }
2209
4146
  async audit(action, opts = {}) {
@@ -2250,17 +4187,46 @@ Or sign in with this link: ${link}`
2250
4187
  mfa_factors: factors.rows
2251
4188
  });
2252
4189
  }
2253
- async eraseUser(userId) {
4190
+ async eraseUser(userId, req) {
4191
+ const body = await req.json().catch(() => ({}));
4192
+ const shouldSoftDelete = body.should_soft_delete === true;
4193
+ const userResult = await this.db.query(`select * from auth.users where id = $1`, [userId]);
4194
+ const user = userResult.rows[0];
4195
+ if (!user)
4196
+ return authError(404, "user_not_found", "User not found");
2254
4197
  const before = await this.db.query(`select
2255
4198
  (select count(*) from auth.identities where user_id = $1)::int as identities,
2256
4199
  (select count(*) from auth.refresh_tokens where user_id = $1)::int as sessions,
2257
4200
  (select count(*) from auth.mfa_factors where user_id = $1)::int as factors`, [userId]);
4201
+ if (shouldSoftDelete) {
4202
+ const deleted = await this.db.transaction(async (query) => {
4203
+ const updated = await query(`update auth.users set deleted_at = coalesce(deleted_at, now()), updated_at = now()
4204
+ where id = $1 returning *`, [userId]);
4205
+ if (updated.rows.length === 0)
4206
+ return null;
4207
+ await query(`update auth.refresh_tokens set revoked = true, updated_at = now() where user_id = $1`, [userId]);
4208
+ await query(`delete from auth.one_time_tokens where user_id = $1`, [userId]);
4209
+ await query(`delete from auth.flow_state where user_id = $1`, [userId]);
4210
+ return updated.rows[0];
4211
+ });
4212
+ if (!deleted)
4213
+ return authError(404, "user_not_found", "User not found");
4214
+ await this.audit("user_deleted", { actorId: userId, type: "admin", traits: { erased: false, soft_deleted: true } });
4215
+ const c = before.rows[0];
4216
+ return json(200, {
4217
+ user: this.userJson(deleted),
4218
+ erased: false,
4219
+ user_id: userId,
4220
+ cascaded: { identities: c.identities, sessions: c.sessions, mfa_factors: c.factors }
4221
+ });
4222
+ }
2258
4223
  const del = await this.db.query(`delete from auth.users where id = $1 returning id`, [userId]);
2259
4224
  if (del.rows.length === 0)
2260
4225
  return authError(404, "user_not_found", "User not found");
2261
4226
  const c = before.rows[0];
2262
4227
  await this.audit("user_deleted", { actorId: userId, type: "admin", traits: { erased: true } });
2263
4228
  return json(200, {
4229
+ user: this.userJson(user),
2264
4230
  erased: true,
2265
4231
  user_id: userId,
2266
4232
  cascaded: { identities: c.identities, sessions: c.sessions, mfa_factors: c.factors }
@@ -2330,9 +4296,12 @@ Or sign in with this link: ${link}`
2330
4296
  and (banned_until is null or banned_until <= now()) for update`, [user.id]);
2331
4297
  if (!currentResult.rows[0])
2332
4298
  return authError(401, "no_authorization", "Invalid or expired token");
2333
- const fr = await query(`select id from auth.mfa_factors where id = $1 and user_id = $2`, [factorId, user.id]);
4299
+ const fr = await query(`select id, factor_type from auth.mfa_factors where id = $1 and user_id = $2`, [factorId, user.id]);
2334
4300
  if (fr.rows.length === 0)
2335
4301
  return authError(404, "mfa_factor_not_found", "MFA factor not found");
4302
+ if (fr.rows[0].factor_type === "recovery_code") {
4303
+ return authError(422, "validation_failed", "Recovery code factors use the recovery-codes API");
4304
+ }
2336
4305
  const ins = await query(`insert into auth.mfa_challenges (factor_id, expires_at) values ($1, $2) returning id, expires_at`, [factorId, expiresAt]);
2337
4306
  const c = ins.rows[0];
2338
4307
  return json(200, {
@@ -2350,10 +4319,13 @@ Or sign in with this link: ${link}`
2350
4319
  return authError(422, "mfa_totp_verify_disabled", "TOTP verification is disabled");
2351
4320
  }
2352
4321
  const body = await req.json().catch(() => ({}));
2353
- const fr = await this.db.query(`select id, secret, status from auth.mfa_factors where id = $1 and user_id = $2`, [factorId, user.id]);
4322
+ const fr = await this.db.query(`select id, secret, status, factor_type from auth.mfa_factors where id = $1 and user_id = $2`, [factorId, user.id]);
2354
4323
  const factor = fr.rows[0];
2355
4324
  if (!factor)
2356
4325
  return authError(404, "mfa_factor_not_found", "MFA factor not found");
4326
+ if (factor.factor_type === "recovery_code") {
4327
+ return authError(422, "validation_failed", "Recovery code factors use the recovery-codes API");
4328
+ }
2357
4329
  const cr = await this.db.query(`select id, expires_at, verified_at from auth.mfa_challenges where id = $1 and factor_id = $2`, [body.challenge_id ?? "", factorId]);
2358
4330
  const challenge = cr.rows[0];
2359
4331
  if (!challenge)
@@ -2373,10 +4345,10 @@ Or sign in with this link: ${link}`
2373
4345
  const currentUser = currentUsers.rows[0];
2374
4346
  if (!currentUser)
2375
4347
  return null;
2376
- const currentFactors = await query(`select id, secret, status from auth.mfa_factors
4348
+ const currentFactors = await query(`select id, secret, status, factor_type from auth.mfa_factors
2377
4349
  where id = $1 and user_id = $2 for update`, [factorId, user.id]);
2378
4350
  const currentFactor = currentFactors.rows[0];
2379
- if (!currentFactor || !await verifyTotp(currentFactor.secret, body.code ?? ""))
4351
+ if (!currentFactor || currentFactor.factor_type === "recovery_code" || !await verifyTotp(currentFactor.secret, body.code ?? ""))
2380
4352
  return null;
2381
4353
  const claimed = await query(`update auth.mfa_challenges set verified_at = now()
2382
4354
  where id = $1 and factor_id = $2 and verified_at is null and expires_at >= now()
@@ -2399,18 +4371,212 @@ Or sign in with this link: ${link}`
2399
4371
  }
2400
4372
  return json(200, session);
2401
4373
  }
4374
+ async recoveryCodesStatus(req) {
4375
+ const user = await this.userFromBearer(req);
4376
+ if (!user)
4377
+ return authError(401, "no_authorization", "This endpoint requires a Bearer token");
4378
+ const result = await this.db.query(`select f.id, count(c.id)::int as total,
4379
+ count(c.id) filter (where c.consumed_at is null)::int as remaining
4380
+ from auth.mfa_recovery_code_sets s
4381
+ join auth.mfa_factors f on f.id = s.mfa_factor_id
4382
+ left join auth.mfa_recovery_codes c on c.mfa_recovery_code_set_id = s.id
4383
+ where s.user_id = $1
4384
+ group by f.id`, [user.id]);
4385
+ const row = result.rows[0];
4386
+ if (!row)
4387
+ return authError(404, "mfa_factor_not_found", "The user has not enrolled recovery codes");
4388
+ return json(200, { id: row.id, type: "recovery_code", total: row.total, remaining: row.remaining });
4389
+ }
4390
+ async generateRecoveryCodes(req) {
4391
+ const user = await this.userFromBearer(req);
4392
+ if (!user)
4393
+ return authError(401, "no_authorization", "This endpoint requires a Bearer token");
4394
+ if (!this.settings.recoveryCodesEnrollEnabled) {
4395
+ return authError(422, "mfa_recovery_codes_enroll_not_enabled", "MFA enroll is disabled for recovery codes");
4396
+ }
4397
+ if (!await this.requireAal2(req))
4398
+ return authError(403, "insufficient_aal", "AAL2 required to generate recovery codes");
4399
+ const body = await req.json().catch(() => ({}));
4400
+ const friendlyName = typeof body.friendly_name === "string" && body.friendly_name.trim() ? body.friendly_name.trim().slice(0, 255) : "Recovery codes";
4401
+ const codes = Array.from({ length: this.settings.recoveryCodesCount }, () => randomRecoveryCode(this.settings.recoveryCodeLength));
4402
+ const hashes = await Promise.all(codes.map((code) => hashPassword(code)));
4403
+ return this.db.transaction(async (query) => {
4404
+ const current = (await query(`select * from auth.users where id = $1 and deleted_at is null
4405
+ and (banned_until is null or banned_until <= now()) for update`, [user.id])).rows[0];
4406
+ if (!current)
4407
+ return authError(401, "no_authorization", "Invalid or expired token");
4408
+ const existing = await query(`select 1 from auth.mfa_recovery_code_sets where user_id = $1`, [user.id]);
4409
+ if (existing.rows.length > 0) {
4410
+ return authError(422, "mfa_verified_factor_exists", "Recovery codes are already enrolled for this user, use regenerate to rotate them");
4411
+ }
4412
+ const verified = await query(`select count(*)::int as count from auth.mfa_factors
4413
+ where user_id = $1 and status = 'verified' and factor_type <> 'recovery_code'`, [user.id]);
4414
+ if ((verified.rows[0]?.count ?? 0) < 1) {
4415
+ return authError(422, "mfa_recovery_codes_sole_factor", "At least one other verified factor is required to generate recovery codes");
4416
+ }
4417
+ const duplicate = await query(`select 1 from auth.mfa_factors where user_id = $1 and friendly_name = $2`, [user.id, friendlyName]);
4418
+ if (duplicate.rows.length > 0) {
4419
+ return authError(422, "mfa_factor_name_conflict", "A factor with this friendly name already exists");
4420
+ }
4421
+ const factors = await query(`select count(*)::int as count from auth.mfa_factors where user_id = $1`, [user.id]);
4422
+ if ((factors.rows[0]?.count ?? 0) >= this.settings.maxEnrolledFactors) {
4423
+ return authError(422, "too_many_enrolled_mfa_factors", "Maximum number of enrolled MFA factors reached");
4424
+ }
4425
+ const factor = (await query(`insert into auth.mfa_factors (user_id, friendly_name, factor_type, status)
4426
+ values ($1, $2, 'recovery_code', 'verified') returning id`, [user.id, friendlyName])).rows[0];
4427
+ const set = (await query(`insert into auth.mfa_recovery_code_sets (user_id, mfa_factor_id) values ($1, $2) returning id`, [user.id, factor.id])).rows[0];
4428
+ for (const hash of hashes) {
4429
+ await query(`insert into auth.mfa_recovery_codes (mfa_recovery_code_set_id, code_hash) values ($1, $2)`, [set.id, hash]);
4430
+ }
4431
+ return json(200, {
4432
+ id: factor.id,
4433
+ type: "recovery_code",
4434
+ friendly_name: friendlyName,
4435
+ total: codes.length,
4436
+ codes
4437
+ });
4438
+ });
4439
+ }
4440
+ async regenerateRecoveryCodes(req) {
4441
+ const user = await this.userFromBearer(req);
4442
+ if (!user)
4443
+ return authError(401, "no_authorization", "This endpoint requires a Bearer token");
4444
+ if (!this.settings.recoveryCodesEnrollEnabled) {
4445
+ return authError(422, "mfa_recovery_codes_enroll_not_enabled", "MFA enroll is disabled for recovery codes");
4446
+ }
4447
+ if (!await this.requireAal2(req))
4448
+ return authError(403, "insufficient_aal", "AAL2 required to regenerate recovery codes");
4449
+ const codes = Array.from({ length: this.settings.recoveryCodesCount }, () => randomRecoveryCode(this.settings.recoveryCodeLength));
4450
+ const hashes = await Promise.all(codes.map((code) => hashPassword(code)));
4451
+ return this.db.transaction(async (query) => {
4452
+ const current = (await query(`select * from auth.users where id = $1 and deleted_at is null
4453
+ and (banned_until is null or banned_until <= now()) for update`, [user.id])).rows[0];
4454
+ if (!current)
4455
+ return authError(401, "no_authorization", "Invalid or expired token");
4456
+ const set = (await query(`select id, mfa_factor_id from auth.mfa_recovery_code_sets where user_id = $1 for update`, [user.id])).rows[0];
4457
+ if (!set)
4458
+ return authError(404, "mfa_factor_not_found", "The user has not enrolled recovery codes");
4459
+ const factor = (await query(`select id, friendly_name from auth.mfa_factors where id = $1 and user_id = $2`, [set.mfa_factor_id, user.id])).rows[0];
4460
+ if (!factor)
4461
+ return authError(404, "mfa_factor_not_found", "The user has not enrolled recovery codes");
4462
+ await query(`delete from auth.mfa_recovery_codes where mfa_recovery_code_set_id = $1`, [set.id]);
4463
+ for (const hash of hashes) {
4464
+ await query(`insert into auth.mfa_recovery_codes (mfa_recovery_code_set_id, code_hash) values ($1, $2)`, [set.id, hash]);
4465
+ }
4466
+ await query(`update auth.mfa_recovery_code_sets
4467
+ set failed_verification_count = 0, verification_locked_until = null, updated_at = now()
4468
+ where id = $1`, [set.id]);
4469
+ return json(200, {
4470
+ id: factor.id,
4471
+ type: "recovery_code",
4472
+ friendly_name: factor.friendly_name ?? "Recovery codes",
4473
+ total: codes.length,
4474
+ codes
4475
+ });
4476
+ });
4477
+ }
4478
+ async deleteRecoveryCodes(req) {
4479
+ const user = await this.userFromBearer(req);
4480
+ if (!user)
4481
+ return authError(401, "no_authorization", "This endpoint requires a Bearer token");
4482
+ if (!await this.requireAal2(req))
4483
+ return authError(403, "insufficient_aal", "AAL2 required to delete recovery codes");
4484
+ return this.db.transaction(async (query) => {
4485
+ const current = (await query(`select * from auth.users where id = $1 and deleted_at is null
4486
+ and (banned_until is null or banned_until <= now()) for update`, [user.id])).rows[0];
4487
+ if (!current)
4488
+ return authError(401, "no_authorization", "Invalid or expired token");
4489
+ const factor = (await query(`select mfa_factor_id as id from auth.mfa_recovery_code_sets where user_id = $1 for update`, [user.id])).rows[0];
4490
+ if (!factor)
4491
+ return authError(404, "mfa_factor_not_found", "The user has not enrolled recovery codes");
4492
+ await query(`delete from auth.mfa_recovery_codes
4493
+ where mfa_recovery_code_set_id in (select id from auth.mfa_recovery_code_sets where user_id = $1)`, [user.id]);
4494
+ await query(`delete from auth.mfa_recovery_code_sets where user_id = $1`, [user.id]);
4495
+ await query(`delete from auth.mfa_factors where id = $1 and user_id = $2`, [factor.id, user.id]);
4496
+ return json(200, { id: factor.id });
4497
+ });
4498
+ }
4499
+ async verifyRecoveryCode(req) {
4500
+ const user = await this.userFromBearer(req);
4501
+ if (!user)
4502
+ return authError(401, "no_authorization", "This endpoint requires a Bearer token");
4503
+ if (!this.settings.recoveryCodesVerifyEnabled) {
4504
+ return authError(422, "mfa_recovery_codes_verify_not_enabled", "MFA verification is disabled for recovery codes");
4505
+ }
4506
+ const body = await req.json().catch(() => ({}));
4507
+ const code = normalizeRecoveryCode(body.code);
4508
+ if (!code)
4509
+ return authError(400, "validation_failed", "Code needs to be non-empty");
4510
+ return this.db.transaction(async (query) => {
4511
+ const current = (await query(`select * from auth.users where id = $1 and deleted_at is null
4512
+ and (banned_until is null or banned_until <= now()) for update`, [user.id])).rows[0];
4513
+ if (!current)
4514
+ return authError(401, "no_authorization", "Invalid or expired token");
4515
+ const set = (await query(`select id, failed_verification_count, verification_locked_until
4516
+ from auth.mfa_recovery_code_sets where user_id = $1 for update`, [user.id])).rows[0];
4517
+ if (!set)
4518
+ return authError(422, "mfa_verification_failed", "Invalid recovery code entered");
4519
+ if (set.verification_locked_until && new Date(set.verification_locked_until).getTime() > Date.now()) {
4520
+ return authError(429, "mfa_recovery_codes_locked", "Too many failed verification attempts, try again later");
4521
+ }
4522
+ const entries = await query(`select id, code_hash from auth.mfa_recovery_codes
4523
+ where mfa_recovery_code_set_id = $1 and consumed_at is null`, [set.id]);
4524
+ let matched = null;
4525
+ for (const entry of entries.rows) {
4526
+ if (await verifyPassword(code, entry.code_hash) && matched === null)
4527
+ matched = entry.id;
4528
+ }
4529
+ if (!matched) {
4530
+ const failures = set.failed_verification_count + 1;
4531
+ await query(`update auth.mfa_recovery_code_sets
4532
+ set failed_verification_count = $1::int,
4533
+ verification_locked_until = case when $1::int >= $2::int
4534
+ then now() + $3::interval else verification_locked_until end,
4535
+ updated_at = now()
4536
+ where id = $4::uuid`, [failures, this.settings.recoveryCodesMaxVerifyAttempts, `${this.settings.recoveryCodesLockoutSeconds} seconds`, set.id]);
4537
+ return authError(422, "mfa_verification_failed", "Invalid recovery code entered");
4538
+ }
4539
+ const consumed = await query(`update auth.mfa_recovery_codes set consumed_at = now()
4540
+ where id = $1 and consumed_at is null returning id`, [matched]);
4541
+ if (consumed.rows.length === 0)
4542
+ return authError(422, "mfa_verification_failed", "Invalid recovery code entered");
4543
+ await query(`update auth.mfa_recovery_code_sets
4544
+ set failed_verification_count = 0, verification_locked_until = null, updated_at = now()
4545
+ where id = $1`, [set.id]);
4546
+ const session = await this.sessionFor(current, undefined, {
4547
+ aal: "aal2",
4548
+ amr: [
4549
+ { method: "password", timestamp: Math.floor(Date.now() / 1000) },
4550
+ { method: "mfa/recovery_code", timestamp: Math.floor(Date.now() / 1000) }
4551
+ ]
4552
+ }, query);
4553
+ return json(200, session);
4554
+ });
4555
+ }
4556
+ async requireAal2(req) {
4557
+ const claims = await this.claimsFromBearer(req);
4558
+ return claims?.aal === "aal2";
4559
+ }
2402
4560
  async unenrollFactor(req, factorId) {
2403
4561
  const user = await this.userFromBearer(req);
2404
4562
  if (!user)
2405
4563
  return authError(401, "no_authorization", "This endpoint requires a Bearer token");
4564
+ const hasAal2 = await this.requireAal2(req);
2406
4565
  return this.db.transaction(async (query) => {
2407
4566
  const currentResult = await query(`select * from auth.users where id = $1 and deleted_at is null
2408
4567
  and (banned_until is null or banned_until <= now()) for update`, [user.id]);
2409
4568
  if (!currentResult.rows[0])
2410
4569
  return authError(401, "no_authorization", "Invalid or expired token");
2411
- const del = await query(`delete from auth.mfa_factors where id = $1 and user_id = $2 returning id`, [factorId, user.id]);
2412
- if (del.rows.length === 0)
4570
+ const factor = await query(`select id, factor_type, status from auth.mfa_factors where id = $1 and user_id = $2 for update`, [factorId, user.id]);
4571
+ if (factor.rows.length === 0)
2413
4572
  return authError(404, "mfa_factor_not_found", "MFA factor not found");
4573
+ if (factor.rows[0].factor_type === "recovery_code") {
4574
+ return authError(422, "validation_failed", "Recovery code factors use the recovery-codes API");
4575
+ }
4576
+ if (factor.rows[0].status === "verified" && !hasAal2) {
4577
+ return authError(403, "insufficient_aal", "AAL2 required to unenroll a verified MFA factor");
4578
+ }
4579
+ const del = await query(`delete from auth.mfa_factors where id = $1 and user_id = $2 returning id`, [factorId, user.id]);
2414
4580
  return json(200, { id: factorId });
2415
4581
  });
2416
4582
  }
@@ -2440,13 +4606,45 @@ Or sign in with this link: ${link}`
2440
4606
  updated_at: iso(r.updated_at)
2441
4607
  }));
2442
4608
  }
2443
- async ensureEmailIdentity(user, query = (sql, params) => this.db.query(sql, params)) {
4609
+ async ensureEmailIdentity(user, query = (sql, params) => this.db.query(sql, params), syncProviders = true) {
2444
4610
  if (!user.email)
2445
4611
  return;
2446
4612
  await query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
2447
4613
  values ($1::uuid, 'email', $1::text, $2::jsonb)
2448
4614
  on conflict (provider, provider_id) do update
2449
4615
  set identity_data = excluded.identity_data, updated_at = now()`, [user.id, JSON.stringify({ sub: user.id, email: user.email })]);
4616
+ if (syncProviders)
4617
+ await this.syncUserProviders(user, query);
4618
+ }
4619
+ async ensurePhoneIdentity(user, query = (sql, params) => this.db.query(sql, params)) {
4620
+ if (!user.phone)
4621
+ return;
4622
+ await query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
4623
+ values ($1::uuid, 'phone', $2, $3::jsonb)
4624
+ on conflict (provider, provider_id) do update
4625
+ set identity_data = excluded.identity_data, updated_at = now()`, [user.id, user.phone, JSON.stringify({ sub: user.id, phone: user.phone, phone_verified: Boolean(user.phone_confirmed_at) })]);
4626
+ await this.syncUserProviders(user, query);
4627
+ }
4628
+ async syncUserProviders(user, query = (sql, params) => this.db.query(sql, params)) {
4629
+ const updated = (await query(`update auth.users
4630
+ set raw_app_meta_data = jsonb_set(
4631
+ coalesce(raw_app_meta_data, '{}'::jsonb),
4632
+ '{providers}',
4633
+ (
4634
+ select jsonb_agg(provider order by provider)
4635
+ from (
4636
+ select distinct provider
4637
+ from auth.identities
4638
+ where user_id = $1
4639
+ ) as providers
4640
+ ),
4641
+ true
4642
+ ),
4643
+ updated_at = now()
4644
+ where id = $1
4645
+ returning *`, [user.id])).rows[0];
4646
+ if (updated)
4647
+ Object.assign(user, updated);
2450
4648
  }
2451
4649
  async userJsonWithRelations(user, query = (sql, params) => this.db.query(sql, params)) {
2452
4650
  return this.userJson(user, await this.getUserFactors(user.id, query), await this.getUserIdentities(user.id, query));
@@ -2532,6 +4730,14 @@ Or sign in with this link: ${link}`
2532
4730
  phone: u.phone ?? "",
2533
4731
  phone_confirmed_at: iso(u.phone_confirmed_at),
2534
4732
  confirmed_at: iso(u.email_confirmed_at ?? u.phone_confirmed_at),
4733
+ ...u.email_change ? {
4734
+ new_email: u.email_change,
4735
+ email_change_sent_at: iso(u.email_change_sent_at)
4736
+ } : {},
4737
+ ...u.phone_change ? {
4738
+ new_phone: u.phone_change,
4739
+ phone_change_sent_at: iso(u.phone_change_sent_at)
4740
+ } : {},
2535
4741
  last_sign_in_at: iso(u.last_sign_in_at),
2536
4742
  app_metadata: u.raw_app_meta_data ?? {},
2537
4743
  user_metadata: u.raw_user_meta_data ?? {},
@@ -3343,6 +5549,11 @@ create table if not exists auth.users (
3343
5549
  email text unique,
3344
5550
  encrypted_password text,
3345
5551
  email_confirmed_at timestamptz,
5552
+ email_change_token_new text default '',
5553
+ email_change text,
5554
+ email_change_sent_at timestamptz,
5555
+ email_change_token_current text default '',
5556
+ email_change_confirm_status smallint default 0,
3346
5557
  last_sign_in_at timestamptz,
3347
5558
  raw_app_meta_data jsonb default '{}'::jsonb,
3348
5559
  raw_user_meta_data jsonb default '{}'::jsonb,
@@ -3351,11 +5562,23 @@ create table if not exists auth.users (
3351
5562
  updated_at timestamptz default now(),
3352
5563
  phone text unique,
3353
5564
  phone_confirmed_at timestamptz,
5565
+ phone_change text default '',
5566
+ phone_change_token text default '',
5567
+ phone_change_sent_at timestamptz,
3354
5568
  banned_until timestamptz,
3355
5569
  deleted_at timestamptz,
3356
5570
  is_anonymous boolean default false
3357
5571
  );
3358
5572
 
5573
+ alter table auth.users add column if not exists email_change text;
5574
+ alter table auth.users add column if not exists email_change_sent_at timestamptz;
5575
+ alter table auth.users add column if not exists email_change_token_new text default '';
5576
+ alter table auth.users add column if not exists email_change_token_current text default '';
5577
+ alter table auth.users add column if not exists email_change_confirm_status smallint default 0;
5578
+ alter table auth.users add column if not exists phone_change text default '';
5579
+ alter table auth.users add column if not exists phone_change_token text default '';
5580
+ alter table auth.users add column if not exists phone_change_sent_at timestamptz;
5581
+
3359
5582
  create table if not exists auth.refresh_tokens (
3360
5583
  id bigserial primary key,
3361
5584
  token text unique not null,
@@ -3424,6 +5647,7 @@ create table if not exists auth.flow_state (
3424
5647
  created_at timestamptz default now(),
3425
5648
  expires_at timestamptz not null
3426
5649
  );
5650
+ alter table auth.flow_state add column if not exists link_user_id uuid;
3427
5651
 
3428
5652
  create table if not exists auth.mfa_factors (
3429
5653
  id uuid primary key default gen_random_uuid(),
@@ -3444,6 +5668,25 @@ create table if not exists auth.mfa_challenges (
3444
5668
  expires_at timestamptz not null
3445
5669
  );
3446
5670
 
5671
+ create table if not exists auth.mfa_recovery_code_sets (
5672
+ id uuid primary key default gen_random_uuid(),
5673
+ user_id uuid not null,
5674
+ mfa_factor_id uuid not null unique,
5675
+ failed_verification_count int not null default 0,
5676
+ verification_locked_until timestamptz,
5677
+ created_at timestamptz default now(),
5678
+ updated_at timestamptz default now(),
5679
+ unique (user_id)
5680
+ );
5681
+
5682
+ create table if not exists auth.mfa_recovery_codes (
5683
+ id uuid primary key default gen_random_uuid(),
5684
+ mfa_recovery_code_set_id uuid not null references auth.mfa_recovery_code_sets(id) on delete cascade,
5685
+ code_hash text not null,
5686
+ consumed_at timestamptz,
5687
+ created_at timestamptz default now()
5688
+ );
5689
+
3447
5690
  create table if not exists auth.audit_log_entries (
3448
5691
  id uuid primary key default gen_random_uuid(),
3449
5692
  payload jsonb,
@@ -3643,6 +5886,12 @@ create table if not exists auth.users (
3643
5886
  is_anonymous boolean default false
3644
5887
  );
3645
5888
 
5889
+ alter table auth.users add column if not exists email_change text;
5890
+ alter table auth.users add column if not exists email_change_sent_at timestamptz;
5891
+ alter table auth.users add column if not exists email_change_token_new text default '';
5892
+ alter table auth.users add column if not exists email_change_token_current text default '';
5893
+ alter table auth.users add column if not exists email_change_confirm_status smallint default 0;
5894
+
3646
5895
  create table if not exists auth.refresh_tokens (
3647
5896
  id bigint generated by default as identity primary key,
3648
5897
  token text unique not null,
@@ -3723,6 +5972,7 @@ create table if not exists auth.flow_state (
3723
5972
  created_at timestamptz default now(),
3724
5973
  expires_at timestamptz not null
3725
5974
  );
5975
+ alter table auth.flow_state add column if not exists link_user_id uuid;
3726
5976
 
3727
5977
  create table if not exists auth.mfa_factors (
3728
5978
  id uuid primary key default gen_random_uuid(),
@@ -3743,6 +5993,25 @@ create table if not exists auth.mfa_challenges (
3743
5993
  expires_at timestamptz not null
3744
5994
  );
3745
5995
 
5996
+ create table if not exists auth.mfa_recovery_code_sets (
5997
+ id uuid primary key default gen_random_uuid(),
5998
+ user_id uuid not null references auth.users(id) on delete cascade,
5999
+ mfa_factor_id uuid not null unique references auth.mfa_factors(id) on delete cascade,
6000
+ failed_verification_count int not null default 0,
6001
+ verification_locked_until timestamptz,
6002
+ created_at timestamptz default now(),
6003
+ updated_at timestamptz default now(),
6004
+ unique (user_id)
6005
+ );
6006
+
6007
+ create table if not exists auth.mfa_recovery_codes (
6008
+ id uuid primary key default gen_random_uuid(),
6009
+ mfa_recovery_code_set_id uuid not null references auth.mfa_recovery_code_sets(id) on delete cascade,
6010
+ code_hash text not null,
6011
+ consumed_at timestamptz,
6012
+ created_at timestamptz default now()
6013
+ );
6014
+
3746
6015
  -- append-only security audit trail (GoTrue-compatible shape)
3747
6016
  create table if not exists auth.audit_log_entries (
3748
6017
  instance_id uuid default '00000000-0000-0000-0000-000000000000',
@@ -9254,7 +11523,7 @@ function imageTransformInputTooLarge(maxInputBytes) {
9254
11523
  message: `The source image exceeds the transformation limit (${maxInputBytes} bytes)`
9255
11524
  };
9256
11525
  }
9257
- function invalid(message) {
11526
+ function invalid2(message) {
9258
11527
  return { ok: false, status: 400, error: "InvalidTransformation", message };
9259
11528
  }
9260
11529
  function unsupported(message) {
@@ -9264,22 +11533,22 @@ function parsePositiveInteger(value, name) {
9264
11533
  if (value === null)
9265
11534
  return;
9266
11535
  if (!/^[1-9]\d*$/.test(value))
9267
- return invalid(`${name} must be a positive integer`);
11536
+ return invalid2(`${name} must be a positive integer`);
9268
11537
  const parsed = Number(value);
9269
11538
  if (!Number.isSafeInteger(parsed))
9270
- return invalid(`${name} is too large`);
11539
+ return invalid2(`${name} is too large`);
9271
11540
  if (parsed > MAX_TRANSFORM_DIMENSION)
9272
- return invalid(`${name} must be between 1 and ${MAX_TRANSFORM_DIMENSION}`);
11541
+ return invalid2(`${name} must be between 1 and ${MAX_TRANSFORM_DIMENSION}`);
9273
11542
  return parsed;
9274
11543
  }
9275
11544
  function parseQuality(value) {
9276
11545
  if (value === null)
9277
11546
  return;
9278
11547
  if (!/^\d+$/.test(value))
9279
- return invalid("quality must be an integer between 20 and 100");
11548
+ return invalid2("quality must be an integer between 20 and 100");
9280
11549
  const parsed = Number(value);
9281
11550
  if (!Number.isSafeInteger(parsed) || parsed < 20 || parsed > 100) {
9282
- return invalid("quality must be an integer between 20 and 100");
11551
+ return invalid2("quality must be an integer between 20 and 100");
9283
11552
  }
9284
11553
  return parsed;
9285
11554
  }
@@ -9294,7 +11563,7 @@ function parseImageTransform(query) {
9294
11563
  if (resizeValue === "cover")
9295
11564
  return unsupported("resize=cover is not supported by Bun.Image in this runtime");
9296
11565
  if (resizeValue !== null && resizeValue !== "contain" && resizeValue !== "fill") {
9297
- return invalid("resize must be one of contain or fill");
11566
+ return invalid2("resize must be one of contain or fill");
9298
11567
  }
9299
11568
  if (resizeValue === null && width !== undefined && height !== undefined) {
9300
11569
  return unsupported("resize defaults to cover when width and height are both set; specify contain or fill");
@@ -9303,13 +11572,13 @@ function parseImageTransform(query) {
9303
11572
  const formatValue = query.get("format");
9304
11573
  const format = formatValue === null ? "origin" : formatValue;
9305
11574
  if (format !== "origin" && format !== "jpeg" && format !== "png" && format !== "webp") {
9306
- return invalid("format must be one of origin, jpeg, png, or webp");
11575
+ return invalid2("format must be one of origin, jpeg, png, or webp");
9307
11576
  }
9308
11577
  const quality = parseQuality(query.get("quality"));
9309
11578
  if (typeof quality !== "number" && quality !== undefined)
9310
11579
  return quality;
9311
11580
  if (quality !== undefined && format === "png") {
9312
- return invalid("quality is supported only for jpeg and webp output");
11581
+ return invalid2("quality is supported only for jpeg and webp output");
9313
11582
  }
9314
11583
  return {
9315
11584
  ok: true,
@@ -9411,7 +11680,7 @@ async function transformImage(source, options, knownSourceSize, configuredLimits
9411
11680
  }
9412
11681
  const outputFormat = options.format === "origin" ? metadata.format : options.format;
9413
11682
  if (options.quality !== undefined && outputFormat !== "jpeg" && outputFormat !== "webp") {
9414
- return invalid("quality is supported only for jpeg and webp output");
11683
+ return invalid2("quality is supported only for jpeg and webp output");
9415
11684
  }
9416
11685
  const contentType = IMAGE_FORMAT_CONTENT_TYPES[outputFormat];
9417
11686
  if (!contentType) {
@@ -11329,7 +13598,7 @@ class GraphqlHandler {
11329
13598
  throw new ApiError(415, { message: "GraphQL requests require application/json" });
11330
13599
  }
11331
13600
  const body = JSON.parse(await boundedBody(request, this.maxBytes));
11332
- if (!isObject(body) || typeof body["query"] !== "string" || !body["query"].trim() || body["variables"] != null && !isObject(body["variables"]) || body["extensions"] != null && !isObject(body["extensions"]) || body["operationName"] != null && typeof body["operationName"] !== "string") {
13601
+ if (!isObject2(body) || typeof body["query"] !== "string" || !body["query"].trim() || body["variables"] != null && !isObject2(body["variables"]) || body["extensions"] != null && !isObject2(body["extensions"]) || body["operationName"] != null && typeof body["operationName"] !== "string") {
11333
13602
  throw new ApiError(400, { message: "Invalid GraphQL request" });
11334
13603
  }
11335
13604
  operationName = body["operationName"] ?? undefined;
@@ -11384,7 +13653,7 @@ async function boundedBody(request, limit) {
11384
13653
  }
11385
13654
  return Buffer.concat(chunks).toString("utf8");
11386
13655
  }
11387
- function isObject(value) {
13656
+ function isObject2(value) {
11388
13657
  return value !== null && typeof value === "object" && !Array.isArray(value);
11389
13658
  }
11390
13659
  function isGraphqlEnvelope(value) {
@@ -12640,14 +14909,14 @@ class PgWireClient {
12640
14909
  if (code === 0) {} else if (code === 3) {
12641
14910
  if (!needPassword())
12642
14911
  return;
12643
- this.socket.write(message(112, cstring(opts.password ?? "")));
14912
+ this.socket.write(message2(112, cstring(opts.password ?? "")));
12644
14913
  } else if (code === 5) {
12645
14914
  if (!needPassword())
12646
14915
  return;
12647
14916
  const salt = payload.subarray(4, 8);
12648
14917
  const inner = md5Hex(Buffer.from((opts.password ?? "") + opts.user, "utf8"));
12649
14918
  const token = "md5" + md5Hex(Buffer.concat([Buffer.from(inner, "utf8"), salt]));
12650
- this.socket.write(message(112, cstring(token)));
14919
+ this.socket.write(message2(112, cstring(token)));
12651
14920
  } else if (code === 10) {
12652
14921
  if (!needPassword())
12653
14922
  return;
@@ -12659,7 +14928,7 @@ class PgWireClient {
12659
14928
  clientNonce = randomBytes(18).toString("base64");
12660
14929
  clientFirstBare = `n=,r=${clientNonce}`;
12661
14930
  const initial = Buffer.from(`n,,${clientFirstBare}`, "utf8");
12662
- this.socket.write(message(112, Buffer.concat([cstring("SCRAM-SHA-256"), int32(initial.length), initial])));
14931
+ this.socket.write(message2(112, Buffer.concat([cstring("SCRAM-SHA-256"), int32(initial.length), initial])));
12663
14932
  } else if (code === 11) {
12664
14933
  const serverFirst = payload.subarray(4).toString("utf8");
12665
14934
  const attrs = scramAttrs(serverFirst);
@@ -12674,15 +14943,15 @@ class PgWireClient {
12674
14943
  const salt = Buffer.from(attrs.s, "base64");
12675
14944
  const iterations = parseInt(attrs.i, 10);
12676
14945
  const saltedPassword = pbkdf2Sync(opts.password, salt, iterations, 32, "sha256");
12677
- const clientKey = hmac(saltedPassword, "Client Key");
14946
+ const clientKey = hmac2(saltedPassword, "Client Key");
12678
14947
  const storedKey = sha256(clientKey);
12679
14948
  const finalNoProof = `c=biws,r=${attrs.r}`;
12680
14949
  const authMessage = `${clientFirstBare},${serverFirst},${finalNoProof}`;
12681
- const clientSignature = hmac(storedKey, authMessage);
14950
+ const clientSignature = hmac2(storedKey, authMessage);
12682
14951
  const proof = xorBuffers(clientKey, clientSignature);
12683
- serverSignature = hmac(hmac(saltedPassword, "Server Key"), authMessage).toString("base64");
14952
+ serverSignature = hmac2(hmac2(saltedPassword, "Server Key"), authMessage).toString("base64");
12684
14953
  const clientFinal = `${finalNoProof},p=${proof.toString("base64")}`;
12685
- this.socket.write(message(112, Buffer.from(clientFinal, "utf8")));
14954
+ this.socket.write(message2(112, Buffer.from(clientFinal, "utf8")));
12686
14955
  } else if (code === 12) {
12687
14956
  const v = scramAttrs(payload.subarray(4).toString("utf8")).v;
12688
14957
  if (v && serverSignature && v !== serverSignature) {
@@ -12723,11 +14992,11 @@ class PgWireClient {
12723
14992
  return op;
12724
14993
  }
12725
14994
  async exec(sql) {
12726
- return this.run(() => this.socket.write(message(81, cstring(sql))));
14995
+ return this.run(() => this.socket.write(message2(81, cstring(sql))));
12727
14996
  }
12728
14997
  async query(sql, params = []) {
12729
14998
  const results = await this.run(() => {
12730
- const parse = message(80, Buffer.concat([cstring(""), cstring(sql), int16(0)]));
14999
+ const parse = message2(80, Buffer.concat([cstring(""), cstring(sql), int16(0)]));
12731
15000
  const paramBufs = [int16(0), int16(params.length)];
12732
15001
  for (const p of params) {
12733
15002
  if (p === null || p === undefined) {
@@ -12738,10 +15007,10 @@ class PgWireClient {
12738
15007
  }
12739
15008
  }
12740
15009
  paramBufs.push(int16(0));
12741
- const bind = message(66, Buffer.concat([cstring(""), cstring(""), ...paramBufs]));
12742
- const describe = message(68, Buffer.concat([Buffer.from("P"), cstring("")]));
12743
- const execute = message(69, Buffer.concat([cstring(""), int32(0)]));
12744
- const sync = message(83, Buffer.alloc(0));
15010
+ const bind = message2(66, Buffer.concat([cstring(""), cstring(""), ...paramBufs]));
15011
+ const describe = message2(68, Buffer.concat([Buffer.from("P"), cstring("")]));
15012
+ const execute = message2(69, Buffer.concat([cstring(""), int32(0)]));
15013
+ const sync = message2(83, Buffer.alloc(0));
12745
15014
  this.socket.write(Buffer.concat([parse, bind, describe, execute, sync]));
12746
15015
  });
12747
15016
  return results[0] ?? { rows: [] };
@@ -12750,7 +15019,7 @@ class PgWireClient {
12750
15019
  return new Promise((resolve) => {
12751
15020
  if (this.closed)
12752
15021
  return resolve();
12753
- this.socket.write(message(88, Buffer.alloc(0)));
15022
+ this.socket.write(message2(88, Buffer.alloc(0)));
12754
15023
  this.socket.end(() => resolve());
12755
15024
  });
12756
15025
  }
@@ -12854,7 +15123,7 @@ class PgWireClient {
12854
15123
  }
12855
15124
  }
12856
15125
  }
12857
- var hmac = (key, data) => createHmac("sha256", key).update(data, "utf8").digest();
15126
+ var hmac2 = (key, data) => createHmac("sha256", key).update(data, "utf8").digest();
12858
15127
  var sha256 = (b) => createHash("sha256").update(b).digest();
12859
15128
  var md5Hex = (b) => createHash("md5").update(b).digest("hex");
12860
15129
  function xorBuffers(a, b) {
@@ -12872,7 +15141,7 @@ function scramAttrs(s) {
12872
15141
  }
12873
15142
  return out;
12874
15143
  }
12875
- function message(type, body) {
15144
+ function message2(type, body) {
12876
15145
  const out = Buffer.alloc(5 + body.length);
12877
15146
  out[0] = type;
12878
15147
  out.writeInt32BE(4 + body.length, 1);
@@ -14846,6 +17115,7 @@ function readAuthSettings(root) {
14846
17115
  const sms = tableAt(root, "auth.sms");
14847
17116
  const mfa = tableAt(root, "auth.mfa");
14848
17117
  const mfaTotp = tableAt(root, "auth.mfa.totp");
17118
+ const recoveryCodes = tableAt(root, "auth.mfa.recovery_codes");
14849
17119
  const out = {};
14850
17120
  const disableSignup = getBool(auth, "disable_signup");
14851
17121
  if (disableSignup !== undefined)
@@ -14862,6 +17132,9 @@ function readAuthSettings(root) {
14862
17132
  const minLen = getInt(auth, "minimum_password_length");
14863
17133
  if (minLen !== undefined)
14864
17134
  out.minPasswordLength = minLen;
17135
+ const requireReauthentication = getBool(auth, "security_update_password_require_reauthentication") ?? getBool(auth, "password_require_reauthentication");
17136
+ if (requireReauthentication !== undefined)
17137
+ out.requirePasswordReauthentication = requireReauthentication;
14865
17138
  const otpLength = getInt(email, "otp_length");
14866
17139
  if (otpLength !== undefined)
14867
17140
  out.otpLength = otpLength;
@@ -14889,6 +17162,24 @@ function readAuthSettings(root) {
14889
17162
  const totpVerify = getBool(mfaTotp, "verify_enabled");
14890
17163
  if (totpVerify !== undefined)
14891
17164
  out.totpVerifyEnabled = totpVerify;
17165
+ const recoveryEnroll = getBool(recoveryCodes, "enroll_enabled");
17166
+ if (recoveryEnroll !== undefined)
17167
+ out.recoveryCodesEnrollEnabled = recoveryEnroll;
17168
+ const recoveryVerify = getBool(recoveryCodes, "verify_enabled");
17169
+ if (recoveryVerify !== undefined)
17170
+ out.recoveryCodesVerifyEnabled = recoveryVerify;
17171
+ const recoveryCount = getInt(recoveryCodes, "count");
17172
+ if (recoveryCount !== undefined)
17173
+ out.recoveryCodesCount = recoveryCount;
17174
+ const recoveryLength = getInt(recoveryCodes, "code_length");
17175
+ if (recoveryLength !== undefined)
17176
+ out.recoveryCodeLength = recoveryLength;
17177
+ const recoveryAttempts = getInt(recoveryCodes, "max_verify_attempts");
17178
+ if (recoveryAttempts !== undefined)
17179
+ out.recoveryCodesMaxVerifyAttempts = recoveryAttempts;
17180
+ const recoveryLockout = getDurationSeconds(recoveryCodes, "lockout_duration");
17181
+ if (recoveryLockout !== undefined)
17182
+ out.recoveryCodesLockoutSeconds = recoveryLockout;
14892
17183
  return out;
14893
17184
  }
14894
17185
  function readRateLimits(root) {
@@ -14933,7 +17224,11 @@ function readOAuthProviders(root, env) {
14933
17224
  authorizeUrl: getString(t, "authorize_url") ?? (url ? `${url}/authorize` : undefined),
14934
17225
  tokenUrl: getString(t, "token_url") ?? (url ? `${url}/token` : undefined),
14935
17226
  userInfoUrl: getString(t, "userinfo_url") ?? (url ? `${url}/userinfo` : undefined),
14936
- scopes: getString(t, "scopes")
17227
+ scopes: getString(t, "scopes"),
17228
+ idTokenIssuer: getString(t, "id_token_issuer") ?? getString(t, "issuer"),
17229
+ idTokenJwksUrl: getString(t, "id_token_jwks_url") ?? getString(t, "jwks_url"),
17230
+ idTokenAudience: getString(t, "id_token_audience") ?? clientId,
17231
+ ssoDomains: getStringArray(t, "sso_domains")
14937
17232
  };
14938
17233
  }
14939
17234
  }
@@ -14965,7 +17260,10 @@ function collectEnvProviders(env, out, prefix, secretKey) {
14965
17260
  authorizeUrl: g("AUTHORIZE_URL"),
14966
17261
  tokenUrl: g("TOKEN_URL"),
14967
17262
  userInfoUrl: g("USERINFO_URL"),
14968
- scopes: g("SCOPES")
17263
+ scopes: g("SCOPES"),
17264
+ idTokenIssuer: g("ID_TOKEN_ISSUER") ?? g("ISSUER"),
17265
+ idTokenJwksUrl: g("ID_TOKEN_JWKS_URL") ?? g("JWKS_URL"),
17266
+ idTokenAudience: g("ID_TOKEN_AUDIENCE") ?? clientId
14969
17267
  };
14970
17268
  }
14971
17269
  }