@supacloud/lite 0.32.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -88,7 +88,7 @@ function randomToken(bytes = 32) {
88
88
  // package.json
89
89
  var package_default = {
90
90
  name: "@supacloud/lite",
91
- version: "0.32.0",
91
+ version: "0.33.0",
92
92
  description: "Bun-native, single-project Supabase-compatible backend powered by PGlite or native PostgreSQL",
93
93
  type: "module",
94
94
  license: "Apache-2.0",
@@ -147,16 +147,16 @@ var package_default = {
147
147
  "@electric-sql/pglite-pgmq": "0.0.8",
148
148
  "@electric-sql/pglite-pgvector": "0.0.11",
149
149
  "@electric-sql/pglite-postgis": "0.2.10",
150
- "@supacloud/db": "0.19.0",
150
+ "@supacloud/db": "0.20.0",
151
151
  "exact-mirror": "1.2.6",
152
152
  tar: "^7.5.22",
153
153
  typebox: "1.3.37"
154
154
  },
155
155
  devDependencies: {
156
- "@supacloud/app": "0.29.0",
157
- "@supacloud/contracts": "0.12.0",
158
- "@supacloud/compiler": "0.48.0",
159
- "@supacloud/elysia": "0.34.0",
156
+ "@supacloud/app": "0.30.0",
157
+ "@supacloud/contracts": "0.13.0",
158
+ "@supacloud/compiler": "0.49.0",
159
+ "@supacloud/elysia": "0.35.0",
160
160
  "@supabase/supabase-js": "^2.117.3",
161
161
  "@types/bun": "^1.4.3",
162
162
  effect: "4.0.5",
@@ -165,13 +165,13 @@ var package_default = {
165
165
  typescript: "^7.0.2"
166
166
  },
167
167
  overrides: {
168
- "@supacloud/db": "0.19.0",
169
- "@supacloud/app": "0.29.0",
170
- "@supacloud/compiler": "0.48.0",
171
- "@supacloud/elysia": "0.34.0",
172
- "@supacloud/contracts": "0.12.0",
173
- "@supacloud/commands": "0.10.0",
174
- "@supacloud/delivery": "0.20.0",
168
+ "@supacloud/db": "0.20.0",
169
+ "@supacloud/app": "0.30.0",
170
+ "@supacloud/compiler": "0.49.0",
171
+ "@supacloud/elysia": "0.35.0",
172
+ "@supacloud/contracts": "0.13.0",
173
+ "@supacloud/commands": "0.11.0",
174
+ "@supacloud/delivery": "0.21.0",
175
175
  "@graphql-tools/utils": "^12.0.4"
176
176
  },
177
177
  engines: {
@@ -247,6 +247,727 @@ function isAllowedRedirect(requested, siteUrl, allowList = []) {
247
247
  return allowList.some((entry) => globToRegExp(entry).test(requested));
248
248
  }
249
249
 
250
+ // node_modules/jose/dist/webapi/lib/buffer_utils.js
251
+ var encoder2 = new TextEncoder;
252
+ var decoder2 = new TextDecoder;
253
+ var strictDecoder = new TextDecoder("utf-8", { fatal: true });
254
+ var MAX_INT32 = 2 ** 32;
255
+ function concat(...buffers) {
256
+ const size = buffers.reduce((acc, { length }) => acc + length, 0), buf = new Uint8Array(size);
257
+ let i = 0;
258
+ for (const buffer of buffers)
259
+ buf.set(buffer, i), i += buffer.length;
260
+ return buf;
261
+ }
262
+ var NON_ASCII = /[^\x00-\x7f]/;
263
+ function encode(string) {
264
+ if (typeof string == "string" && string.length >= 128) {
265
+ if (NON_ASCII.test(string))
266
+ throw new TypeError("non-ASCII string encountered in encode()");
267
+ return encoder2.encode(string);
268
+ }
269
+ const bytes = new Uint8Array(string.length);
270
+ for (let i = 0;i < string.length; i++) {
271
+ const code = string.charCodeAt(i);
272
+ if (code > 127)
273
+ throw new TypeError("non-ASCII string encountered in encode()");
274
+ bytes[i] = code;
275
+ }
276
+ return bytes;
277
+ }
278
+ function decodeBase64(encoded, url = false) {
279
+ if (Uint8Array.fromBase64)
280
+ return Uint8Array.fromBase64(encoded, { alphabet: url ? "base64url" : "base64" });
281
+ if (url) {
282
+ if (encoded.includes("+") || encoded.includes("/"))
283
+ throw new TypeError("Invalid base64url");
284
+ encoded = encoded.replace(/-/g, "+").replace(/_/g, "/");
285
+ }
286
+ const binary = atob(encoded), bytes = new Uint8Array(binary.length);
287
+ for (let i = 0;i < binary.length; i++)
288
+ bytes[i] = binary.charCodeAt(i);
289
+ return bytes;
290
+ }
291
+
292
+ // node_modules/jose/dist/webapi/util/errors.js
293
+ class JOSEError extends Error {
294
+ static code = "ERR_JOSE_GENERIC";
295
+ code = "ERR_JOSE_GENERIC";
296
+ constructor(message, options) {
297
+ super(message, options), this.name = this.constructor.name, Error.captureStackTrace?.(this, this.constructor);
298
+ }
299
+ }
300
+
301
+ class JWTClaimValidationFailed extends JOSEError {
302
+ static code = "ERR_JWT_CLAIM_VALIDATION_FAILED";
303
+ code = "ERR_JWT_CLAIM_VALIDATION_FAILED";
304
+ claim;
305
+ reason;
306
+ payload;
307
+ constructor(message, payload, claim = "unspecified", reason = "unspecified") {
308
+ super(message, { cause: { claim, reason, payload } }), this.claim = claim, this.reason = reason, this.payload = payload;
309
+ }
310
+ }
311
+
312
+ class JWTExpired extends JOSEError {
313
+ static code = "ERR_JWT_EXPIRED";
314
+ code = "ERR_JWT_EXPIRED";
315
+ claim;
316
+ reason;
317
+ payload;
318
+ constructor(message, payload, claim = "unspecified", reason = "unspecified") {
319
+ super(message, { cause: { claim, reason, payload } }), this.claim = claim, this.reason = reason, this.payload = payload;
320
+ }
321
+ }
322
+
323
+ class JOSEAlgNotAllowed extends JOSEError {
324
+ static code = "ERR_JOSE_ALG_NOT_ALLOWED";
325
+ code = "ERR_JOSE_ALG_NOT_ALLOWED";
326
+ }
327
+
328
+ class JOSENotSupported extends JOSEError {
329
+ static code = "ERR_JOSE_NOT_SUPPORTED";
330
+ code = "ERR_JOSE_NOT_SUPPORTED";
331
+ }
332
+ class JWSInvalid extends JOSEError {
333
+ static code = "ERR_JWS_INVALID";
334
+ code = "ERR_JWS_INVALID";
335
+ }
336
+
337
+ class JWTInvalid extends JOSEError {
338
+ static code = "ERR_JWT_INVALID";
339
+ code = "ERR_JWT_INVALID";
340
+ }
341
+ class JWKSInvalid extends JOSEError {
342
+ static code = "ERR_JWKS_INVALID";
343
+ code = "ERR_JWKS_INVALID";
344
+ }
345
+
346
+ class JWKSNoMatchingKey extends JOSEError {
347
+ static code = "ERR_JWKS_NO_MATCHING_KEY";
348
+ code = "ERR_JWKS_NO_MATCHING_KEY";
349
+ constructor(message = "no applicable key found in the JSON Web Key Set", options) {
350
+ super(message, options);
351
+ }
352
+ }
353
+
354
+ class JWKSMultipleMatchingKeys extends JOSEError {
355
+ [Symbol.asyncIterator] = async function* () {};
356
+ static code = "ERR_JWKS_MULTIPLE_MATCHING_KEYS";
357
+ code = "ERR_JWKS_MULTIPLE_MATCHING_KEYS";
358
+ constructor(message = "multiple matching keys found in the JSON Web Key Set", options) {
359
+ super(message, options);
360
+ }
361
+ }
362
+
363
+ class JWKSTimeout extends JOSEError {
364
+ static code = "ERR_JWKS_TIMEOUT";
365
+ code = "ERR_JWKS_TIMEOUT";
366
+ constructor(message = "request timed out", options) {
367
+ super(message, options);
368
+ }
369
+ }
370
+
371
+ class JWSSignatureVerificationFailed extends JOSEError {
372
+ static code = "ERR_JWS_SIGNATURE_VERIFICATION_FAILED";
373
+ code = "ERR_JWS_SIGNATURE_VERIFICATION_FAILED";
374
+ constructor(message = "signature verification failed", options) {
375
+ super(message, options);
376
+ }
377
+ }
378
+
379
+ // node_modules/jose/dist/webapi/util/base64url.js
380
+ var invalid = "The input to be decoded is not correctly encoded.";
381
+ function decode(input) {
382
+ try {
383
+ return decodeBase64(typeof input == "string" ? input : decoder2.decode(input), true);
384
+ } catch (cause) {
385
+ throw new TypeError(invalid, { cause });
386
+ }
387
+ }
388
+
389
+ // node_modules/jose/dist/webapi/lib/validate.js
390
+ function isObject(input) {
391
+ if (typeof input != "object" || input === null || Object.prototype.toString.call(input) !== "[object Object]")
392
+ return false;
393
+ const prototype = Object.getPrototypeOf(input);
394
+ return prototype === null || Object.getPrototypeOf(prototype) === null;
395
+ }
396
+ function isJwkSet(input) {
397
+ return isObject(input) && Array.isArray(input.keys) && Array.from(input.keys).every(isObject);
398
+ }
399
+ function isDisjoint(...headers) {
400
+ const parameters = /* @__PURE__ */ new Set;
401
+ for (const header of headers)
402
+ if (header)
403
+ for (const parameter of Object.keys(header)) {
404
+ if (parameters.has(parameter))
405
+ return false;
406
+ parameters.add(parameter);
407
+ }
408
+ return true;
409
+ }
410
+ function decodeBase64url(value, label, ErrorClass) {
411
+ try {
412
+ return decode(value);
413
+ } catch {
414
+ throw new ErrorClass(`Failed to base64url decode the ${label}`);
415
+ }
416
+ }
417
+ function encodeBase64url(value, label, ErrorClass) {
418
+ try {
419
+ return encode(value);
420
+ } catch {
421
+ throw new ErrorClass(`The ${label} is not a valid base64url string`);
422
+ }
423
+ }
424
+ function parseJoseHeader(b64, ErrorClass, message) {
425
+ let parsed;
426
+ try {
427
+ parsed = JSON.parse(strictDecoder.decode(decode(b64)));
428
+ } catch {
429
+ throw new ErrorClass(message);
430
+ }
431
+ if (!isObject(parsed))
432
+ throw new ErrorClass(message);
433
+ return parsed;
434
+ }
435
+ var JWS_RECOGNIZED = { __proto__: null, b64: true };
436
+ function validateAlgorithms(option, algorithms) {
437
+ if (algorithms !== undefined && (!Array.isArray(algorithms) || algorithms.some((s) => typeof s != "string")))
438
+ throw new TypeError(`"${option}" option must be an array of strings`);
439
+ return algorithms === undefined ? undefined : new Set(algorithms);
440
+ }
441
+ function validateCrit(Err, recognizedDefault, recognizedOption, protectedHeader, joseHeader) {
442
+ if (joseHeader.crit !== undefined && protectedHeader?.crit === undefined)
443
+ throw new Err('"crit" (Critical) Header Parameter MUST be integrity protected');
444
+ if (!protectedHeader || protectedHeader.crit === undefined)
445
+ return [];
446
+ if (!Array.isArray(protectedHeader.crit) || protectedHeader.crit.length === 0 || protectedHeader.crit.some((input) => typeof input != "string" || input.length === 0))
447
+ throw new Err('"crit" (Critical) Header Parameter MUST be an array of non-empty strings when present');
448
+ const recognized = recognizedOption === undefined ? recognizedDefault : { __proto__: null, ...recognizedOption, ...recognizedDefault };
449
+ for (const parameter of protectedHeader.crit) {
450
+ if (!(parameter in recognized))
451
+ throw new JOSENotSupported(`Extension Header Parameter "${parameter}" is not recognized`);
452
+ if (!Object.hasOwn(joseHeader, parameter) || joseHeader[parameter] === undefined)
453
+ throw new Err(`Extension Header Parameter "${parameter}" is missing`);
454
+ if (recognized[parameter] && (!Object.hasOwn(protectedHeader, parameter) || protectedHeader[parameter] === undefined))
455
+ throw new Err(`Extension Header Parameter "${parameter}" MUST be integrity protected`);
456
+ }
457
+ return protectedHeader.crit;
458
+ }
459
+ function validateB64(protectedHeader, extensions) {
460
+ if (extensions.includes("b64")) {
461
+ const b64 = protectedHeader.b64;
462
+ if (typeof b64 != "boolean")
463
+ throw new JWSInvalid('The "b64" (base64url-encode payload) Header Parameter must be a boolean');
464
+ return b64;
465
+ }
466
+ return true;
467
+ }
468
+
469
+ // node_modules/jose/dist/webapi/lib/key.js
470
+ var tag = (key) => key[Symbol.toStringTag];
471
+ var jwkMatchesOp = (entry, key, usage) => {
472
+ const { alg } = entry;
473
+ if (key.use !== undefined) {
474
+ const expected = usage === "sign" || usage === "verify" ? "sig" : "enc";
475
+ if (key.use !== expected)
476
+ throw new TypeError(`Invalid key for this operation, its "use" must be "${expected}" when present`);
477
+ }
478
+ if (key.alg !== undefined && key.alg !== alg)
479
+ throw new TypeError(`Invalid key for this operation, its "alg" must be "${alg}" when present`);
480
+ if (Array.isArray(key.key_ops)) {
481
+ const expectedKeyOp = usage === "encrypt" || usage === "decrypt" ? entry.ops?.[usage === "encrypt" ? 0 : 1] : usage;
482
+ if (expectedKeyOp && !key.key_ops.includes(expectedKeyOp))
483
+ throw new TypeError(`Invalid key for this operation, its "key_ops" must include "${expectedKeyOp}" when present`);
484
+ }
485
+ };
486
+ async function prepareKey(entry, key, usage) {
487
+ const { alg, secret } = entry, privateKey = usage === "decrypt" || usage === "sign";
488
+ if (secret && key instanceof Uint8Array)
489
+ return key;
490
+ let normalized, keyObject;
491
+ if (isObject(key)) {
492
+ if (normalized = normalizeJwk(key), typeof normalized.kty != "string")
493
+ throw invalidKeyType(alg, key, secret);
494
+ 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)))
495
+ 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`);
496
+ if (jwkMatchesOp(entry, normalized, usage), normalized.kty === "oct")
497
+ return decode(normalized.k);
498
+ if (!Object.isFrozen(key)) {
499
+ const { key_ops } = key;
500
+ Array.isArray(key_ops) && Object.freeze(key_ops), Object.freeze(key);
501
+ }
502
+ } else {
503
+ if (!isKeyLike(key))
504
+ throw invalidKeyType(alg, key, secret);
505
+ const expectedType = secret ? "secret" : privateKey ? "private" : "public";
506
+ if (key.type !== expectedType && (secret || ["secret", "public", "private"].includes(key.type)))
507
+ throw new TypeError(`${tag(key)} instances must be of type "${expectedType}" for the ${alg} algorithm`);
508
+ if (isCryptoKey(key))
509
+ return key;
510
+ if (keyObject = key, keyObject.type === "secret")
511
+ return keyObject.export();
512
+ }
513
+ cache ||= /* @__PURE__ */ new WeakMap;
514
+ const cacheKey = key;
515
+ let cached = cache.get(cacheKey);
516
+ if (cached?.[alg])
517
+ return cached[alg];
518
+ if (cached || cache.set(cacheKey, cached = {}), keyObject && typeof keyObject.toCryptoKey == "function") {
519
+ const isPublic = keyObject.type === "public", crv = nist[keyObject.asymmetricKeyDetails?.namedCurve], params = entry.resolve?.({ crv, asymmetricKeyType: keyObject.asymmetricKeyType }) ?? entry.subtle;
520
+ return cached[alg] = keyObject.toCryptoKey(params, isPublic, entry.usages[isPublic ? 0 : 1]);
521
+ }
522
+ return normalized ??= keyObject.export({ format: "jwk" }), normalized.alg = alg, cached[alg] = await jwkToKey(entry, normalized);
523
+ }
524
+ var cache;
525
+ var nist = {
526
+ __proto__: null,
527
+ prime256v1: "P-256",
528
+ secp384r1: "P-384",
529
+ secp521r1: "P-521"
530
+ };
531
+ var isCryptoKey = (key) => {
532
+ if (key?.[Symbol.toStringTag] === "CryptoKey")
533
+ return true;
534
+ try {
535
+ return key instanceof CryptoKey;
536
+ } catch {
537
+ return false;
538
+ }
539
+ };
540
+ var isKeyObject = (key) => key?.[Symbol.toStringTag] === "KeyObject";
541
+ var isKeyLike = (key) => isCryptoKey(key) || isKeyObject(key);
542
+ function message(msg, actual, ...types) {
543
+ if (types.length > 2) {
544
+ const last = types.pop();
545
+ msg += `one of type ${types.join(", ")}, or ${last}.`;
546
+ } else
547
+ types.length === 2 ? msg += `one of type ${types[0]} or ${types[1]}.` : msg += `of type ${types[0]}.`;
548
+ 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;
549
+ }
550
+ function invalidKeyType(alg, actual, secret) {
551
+ const types = ["CryptoKey", "KeyObject", "JSON Web Key"];
552
+ return secret && types.push("Uint8Array"), new TypeError(message(`Key for the ${alg} algorithm must be `, actual, ...types));
553
+ }
554
+ var unusable = (name, prop = "algorithm.name") => new TypeError(`CryptoKey does not support this operation, its ${prop} must be ${name}`);
555
+ function checkUsage(key, usage) {
556
+ if (usage && !key.usages.includes(usage))
557
+ throw new TypeError(`CryptoKey does not support this operation, its usages must include ${usage}.`);
558
+ }
559
+ function checkModulusLength(alg, key) {
560
+ const { modulusLength } = key.algorithm;
561
+ if (typeof modulusLength != "number" || modulusLength < 2048)
562
+ throw new TypeError(`${alg} requires key modulusLength to be 2048 bits or larger`);
563
+ }
564
+ function checkCryptoKey(key, expected, usage) {
565
+ const algorithm = key.algorithm;
566
+ if (algorithm.name !== expected.name)
567
+ throw unusable(expected.name);
568
+ if (expected.hash && algorithm.hash?.name !== expected.hash)
569
+ throw unusable(expected.hash, "algorithm.hash");
570
+ if (expected.namedCurve && algorithm.namedCurve !== expected.namedCurve)
571
+ throw unusable(expected.namedCurve, "algorithm.namedCurve");
572
+ if (expected.length !== undefined && algorithm.length !== expected.length)
573
+ throw unusable(expected.length, "algorithm.length");
574
+ checkUsage(key, usage);
575
+ }
576
+ function snapshotJwk(jwk) {
577
+ return { __proto__: null, ...jwk };
578
+ }
579
+ function normalizeJwk(jwk) {
580
+ const normalized = snapshotJwk(jwk);
581
+ if (normalized.ext !== undefined && typeof normalized.ext != "boolean")
582
+ throw new TypeError('"ext" (Extractable) Parameter must be a boolean');
583
+ if (normalized.key_ops !== undefined) {
584
+ const value = normalized.key_ops, keyOps = Array.isArray(value) ? [...value] : undefined;
585
+ if (!keyOps || keyOps.some((operation) => typeof operation != "string") || new Set(keyOps).size !== keyOps.length)
586
+ throw new TypeError('"key_ops" (Key Operations) Parameter must be an array of unique strings');
587
+ normalized.key_ops = keyOps;
588
+ }
589
+ return normalized;
590
+ }
591
+ async function jwkToKey(entry, jwk, extractable) {
592
+ if (!entry.kty.includes(jwk.kty))
593
+ throw new JOSENotSupported('Invalid or unsupported JWK "alg" (Algorithm) Parameter value');
594
+ const algorithm = entry.resolve?.({ kty: jwk.kty, crv: jwk.crv }) ?? entry.subtle, isPrivate = !!(jwk.d || jwk.priv), keyData = { ...jwk, ext: extractable ?? jwk.ext };
595
+ 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]);
596
+ }
597
+ async function rawKey(key, expected, usage, extractable = false) {
598
+ return key instanceof Uint8Array && (key = await crypto.subtle.importKey("raw", key, expected, extractable, [usage])), checkCryptoKey(key, expected, usage), key;
599
+ }
600
+
601
+ // node_modules/jose/dist/webapi/lib/key_descriptor.js
602
+ function table(entries) {
603
+ const out = { __proto__: null };
604
+ for (const alg in entries)
605
+ out[alg] = { ...entries[alg], alg };
606
+ return out;
607
+ }
608
+
609
+ // node_modules/jose/dist/webapi/lib/jws_algorithms.js
610
+ var sig = [["verify"], ["sign"]];
611
+ function hmac(bits) {
612
+ const subtle = { name: "HMAC", hash: `SHA-${bits}` };
613
+ return { kty: ["oct"], secret: true, subtle, signing: subtle, usages: sig };
614
+ }
615
+ function rsa(bits, saltLength) {
616
+ const subtle = { name: saltLength ? "RSA-PSS" : "RSASSA-PKCS1-v1_5", hash: `SHA-${bits}` };
617
+ return {
618
+ kty: ["RSA"],
619
+ subtle,
620
+ signing: saltLength ? { ...subtle, saltLength } : subtle,
621
+ usages: sig,
622
+ minRsaBits: 2048
623
+ };
624
+ }
625
+ function ecdsa(crv, bits) {
626
+ return {
627
+ kty: ["EC"],
628
+ crv,
629
+ subtle: { name: "ECDSA", namedCurve: crv },
630
+ signing: { name: "ECDSA", hash: `SHA-${bits}` },
631
+ usages: sig
632
+ };
633
+ }
634
+ function eddsa() {
635
+ const subtle = { name: "Ed25519" };
636
+ return {
637
+ kty: ["OKP"],
638
+ crv: "Ed25519",
639
+ subtle,
640
+ signing: subtle,
641
+ usages: sig
642
+ };
643
+ }
644
+ function mldsa(bits) {
645
+ const subtle = { name: `ML-DSA-${bits}` };
646
+ return {
647
+ kty: ["AKP"],
648
+ subtle,
649
+ signing: subtle,
650
+ usages: sig
651
+ };
652
+ }
653
+ var JWS = table({
654
+ HS256: hmac(256),
655
+ HS384: hmac(384),
656
+ HS512: hmac(512),
657
+ RS256: rsa(256),
658
+ RS384: rsa(384),
659
+ RS512: rsa(512),
660
+ PS256: rsa(256, 32),
661
+ PS384: rsa(384, 48),
662
+ PS512: rsa(512, 64),
663
+ ES256: ecdsa("P-256", 256),
664
+ ES384: ecdsa("P-384", 384),
665
+ ES512: ecdsa("P-521", 512),
666
+ EdDSA: eddsa(),
667
+ Ed25519: eddsa(),
668
+ "ML-DSA-44": mldsa(44),
669
+ "ML-DSA-65": mldsa(65),
670
+ "ML-DSA-87": mldsa(87)
671
+ });
672
+ function jwsAlgorithm(alg) {
673
+ const entry = typeof alg == "string" ? JWS[alg] : undefined;
674
+ if (!entry)
675
+ throw new JOSENotSupported(`alg ${alg} is not supported either by JOSE or your javascript runtime`);
676
+ return entry;
677
+ }
678
+
679
+ // node_modules/jose/dist/webapi/lib/jws_verify.js
680
+ function prepareVerify(options) {
681
+ return [options && validateAlgorithms("algorithms", options.algorithms), options?.crit];
682
+ }
683
+ function parseProtectedHeader(encodedProtected) {
684
+ return encodedProtected === undefined ? {} : parseJoseHeader(encodedProtected, JWSInvalid, "JWS Protected Header is invalid");
685
+ }
686
+ function encodeCompactUnencodedPayload(payload) {
687
+ try {
688
+ return encode(payload);
689
+ } catch {
690
+ throw new JWSInvalid("JWS Compact Serialization payload must use only ASCII characters");
691
+ }
692
+ }
693
+ async function verifySignature(jws, shared, key, encodeUnencodedPayload, parsedProtected) {
694
+ const { protected: encodedProtected, header, payload: inputPayload } = jws, parsedProt = parsedProtected ?? parseProtectedHeader(encodedProtected);
695
+ if (!isDisjoint(parsedProt, header))
696
+ throw new JWSInvalid("JWS Protected and JWS Unprotected Header Parameter names must be disjoint");
697
+ const joseHeader = { ...parsedProt, ...header }, b64 = validateB64(parsedProt, validateCrit(JWSInvalid, JWS_RECOGNIZED, shared[1], parsedProt, joseHeader)), { alg } = joseHeader;
698
+ if (typeof alg != "string" || !alg)
699
+ throw new JWSInvalid('JWS "alg" (Algorithm) Header Parameter missing or invalid');
700
+ if (shared[0] && !shared[0].has(alg))
701
+ throw new JOSEAlgNotAllowed('"alg" (Algorithm) Header Parameter value not allowed');
702
+ if (b64) {
703
+ if (typeof inputPayload != "string")
704
+ throw new JWSInvalid("JWS Payload must be a string");
705
+ } else if (typeof inputPayload != "string" && !(inputPayload instanceof Uint8Array))
706
+ throw new JWSInvalid("JWS Payload must be a string or an Uint8Array instance");
707
+ const signingPayload = b64 || typeof inputPayload != "string" ? inputPayload : encodeUnencodedPayload(inputPayload);
708
+ let resolvedKey = false;
709
+ typeof key == "function" && (key = await key(parsedProt, jws), resolvedKey = true);
710
+ 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");
711
+ entry.minRsaBits && checkModulusLength(entry.alg, cryptoKey);
712
+ let verified = false;
713
+ try {
714
+ verified = await crypto.subtle.verify(entry.signing, cryptoKey, signature, data);
715
+ } catch {}
716
+ if (!verified)
717
+ throw new JWSSignatureVerificationFailed;
718
+ const result = { payload: typeof signingPayload == "string" ? decodeBase64url(signingPayload, "payload", JWSInvalid) : signingPayload };
719
+ return encodedProtected !== undefined && (result.protectedHeader = parsedProt), header !== undefined && (result.unprotectedHeader = header), resolvedKey ? [{ ...result, key: k }, b64] : [result, b64];
720
+ }
721
+ async function verifyCompact(jws, shared, key) {
722
+ if (jws instanceof Uint8Array && (jws = decoder2.decode(jws)), typeof jws != "string")
723
+ throw new JWSInvalid("Compact JWS must be a string or Uint8Array");
724
+ const { 0: protectedHeader, 1: payload, 2: signature, length } = jws.split(".");
725
+ if (length !== 3)
726
+ throw new JWSInvalid("Invalid Compact JWS");
727
+ return verifySignature({ payload, protected: protectedHeader, signature }, shared, key, encodeCompactUnencodedPayload);
728
+ }
729
+
730
+ // node_modules/jose/dist/webapi/lib/jwt_claims_set.js
731
+ var epoch = (date) => Math.floor(date.getTime() / 1000);
732
+ var multipliers = {
733
+ s: 1,
734
+ m: 60,
735
+ h: 3600,
736
+ d: 86400,
737
+ w: 604800,
738
+ y: 31557600
739
+ };
740
+ 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;
741
+ var checkFailed = "check_failed";
742
+ function invalidDuration() {
743
+ throw new TypeError("Invalid time period format");
744
+ }
745
+ function secs(str) {
746
+ typeof str != "string" && invalidDuration();
747
+ const matched = REGEX.exec(str);
748
+ (!matched || matched[4] && matched[1]) && invalidDuration();
749
+ const value = parseFloat(matched[2]), numericDate2 = Math.round(value * multipliers[matched[3][0].toLowerCase()]);
750
+ return Number.isFinite(numericDate2) || invalidDuration(), matched[1] === "-" || matched[4] === "ago" ? -numericDate2 : numericDate2;
751
+ }
752
+ function validateInput(label, input) {
753
+ if (!Number.isFinite(input))
754
+ throw new TypeError(`Invalid ${label} input`);
755
+ return input;
756
+ }
757
+ var normalizeTyp = (value) => {
758
+ const normalized = value.toLowerCase();
759
+ return value.includes("/") ? normalized : `application/${normalized}`;
760
+ };
761
+ var checkAudiencePresence = (audPayload, audOption) => typeof audPayload == "string" ? audOption.includes(audPayload) : Array.isArray(audPayload) ? audOption.some((aud) => audPayload.includes(aud)) : false;
762
+ function validateNumericDate(payload, claim, required = false) {
763
+ const value = payload[claim];
764
+ if (!(value === undefined && !required)) {
765
+ if (typeof value != "number")
766
+ throw new JWTClaimValidationFailed(`"${claim}" claim must be a number`, payload, claim, "invalid");
767
+ return value;
768
+ }
769
+ }
770
+ function unexpectedClaim(payload, claim) {
771
+ throw new JWTClaimValidationFailed(`unexpected "${claim}" claim value`, payload, claim, checkFailed);
772
+ }
773
+ function validateClaimsSet(protectedHeader, encodedPayload, options = {}) {
774
+ let payload;
775
+ try {
776
+ payload = JSON.parse(strictDecoder.decode(encodedPayload));
777
+ } catch {}
778
+ if (!isObject(payload))
779
+ throw new JWTInvalid("JWT Claims Set must be a top-level JSON object");
780
+ const { typ } = options;
781
+ if (typ !== undefined && (typeof protectedHeader.typ != "string" || normalizeTyp(protectedHeader.typ) !== normalizeTyp(typ)))
782
+ throw new JWTClaimValidationFailed('unexpected "typ" JWT header value', payload, "typ", checkFailed);
783
+ const { requiredClaims = [], issuer, subject, audience, maxTokenAge } = options, presenceCheck = [...requiredClaims];
784
+ maxTokenAge !== undefined && presenceCheck.push("iat"), audience !== undefined && presenceCheck.push("aud"), subject !== undefined && presenceCheck.push("sub"), issuer !== undefined && presenceCheck.push("iss");
785
+ for (const claim of new Set(presenceCheck.reverse()))
786
+ if (!Object.hasOwn(payload, claim))
787
+ throw new JWTClaimValidationFailed(`missing required "${claim}" claim`, payload, claim, "missing");
788
+ 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");
789
+ const { clockTolerance } = options;
790
+ let tolerance = 0;
791
+ if (typeof clockTolerance == "string")
792
+ tolerance = secs(clockTolerance);
793
+ else if (clockTolerance !== undefined) {
794
+ if (typeof clockTolerance != "number")
795
+ throw new TypeError("Invalid clockTolerance option type");
796
+ tolerance = clockTolerance;
797
+ }
798
+ validateInput("clockTolerance option", tolerance);
799
+ const { currentDate } = options, now = validateInput("currentDate option", epoch(currentDate === undefined ? /* @__PURE__ */ new Date : currentDate)), iat = validateNumericDate(payload, "iat", maxTokenAge !== undefined), nbf = validateNumericDate(payload, "nbf");
800
+ if (nbf !== undefined && nbf > now + tolerance)
801
+ throw new JWTClaimValidationFailed('"nbf" claim timestamp check failed', payload, "nbf", checkFailed);
802
+ const exp = validateNumericDate(payload, "exp");
803
+ if (exp !== undefined && exp <= now - tolerance)
804
+ throw new JWTExpired('"exp" claim timestamp check failed', payload, "exp", checkFailed);
805
+ if (maxTokenAge !== undefined) {
806
+ const age = now - iat, max = validateInput("maxTokenAge option", typeof maxTokenAge == "number" ? maxTokenAge : secs(maxTokenAge));
807
+ if (age - tolerance > max)
808
+ throw new JWTExpired('"iat" claim timestamp check failed (too far in the past)', payload, "iat", checkFailed);
809
+ if (age < -tolerance)
810
+ throw new JWTClaimValidationFailed('"iat" claim timestamp check failed (it should be in the past)', payload, "iat", checkFailed);
811
+ }
812
+ return payload;
813
+ }
814
+
815
+ // node_modules/jose/dist/webapi/jwt/verify.js
816
+ async function jwtVerify(jwt, key, options) {
817
+ const [verified, b64] = await verifyCompact(jwt, prepareVerify(options), key);
818
+ if (!b64)
819
+ throw new JWTInvalid("JWTs MUST NOT use unencoded payload");
820
+ const payload = validateClaimsSet(verified.protectedHeader, verified.payload, options);
821
+ return { ...verified, payload };
822
+ }
823
+
824
+ // node_modules/jose/dist/webapi/jwks/local.js
825
+ function isUsableJWK(jwk, entry, alg, kid) {
826
+ const { kty, key_ops: keyOps, ext, kid: jwkKid, alg: jwkAlg, use, crv } = jwk;
827
+ 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);
828
+ }
829
+ async function importWithAlgCache(cache, jwk, entry) {
830
+ const cached = cache.get(jwk) || cache.set(jwk, {}).get(jwk), { alg } = entry;
831
+ if (cached[alg] === undefined) {
832
+ const pending = jwkToKey(entry, jwk, true).then((key) => {
833
+ if (key.type !== "public")
834
+ throw new JWKSInvalid("JSON Web Key Set members must be public keys");
835
+ return cached[alg] = key, key;
836
+ }).catch((error) => {
837
+ throw cached[alg] === pending && delete cached[alg], error;
838
+ });
839
+ cached[alg] = pending;
840
+ }
841
+ return cached[alg];
842
+ }
843
+ function createLocalJWKSet(jwks) {
844
+ let snapshot;
845
+ try {
846
+ snapshot = structuredClone(jwks);
847
+ } catch {}
848
+ if (!isJwkSet(snapshot))
849
+ throw new JWKSInvalid("JSON Web Key Set malformed");
850
+ const metadata = snapshot.keys.map((jwk) => {
851
+ const normalized = snapshotJwk(jwk);
852
+ return Array.isArray(normalized.key_ops) && (normalized.key_ops = [...normalized.key_ops]), normalized;
853
+ }), cached = /* @__PURE__ */ new WeakMap;
854
+ return Object.defineProperty(async (protectedHeader, token) => {
855
+ const { alg, kid } = { ...protectedHeader, ...token?.header }, entry = typeof alg == "string" ? JWS[alg] : undefined;
856
+ if (!entry || entry.secret)
857
+ throw new JOSENotSupported('Unsupported "alg" value for a JSON Web Key Set');
858
+ const candidates = snapshot.keys.filter((_, index) => isUsableJWK(metadata[index], entry, alg, kid)), { 0: jwk, length } = candidates;
859
+ if (!length)
860
+ throw new JWKSNoMatchingKey;
861
+ if (length !== 1) {
862
+ const error = new JWKSMultipleMatchingKeys;
863
+ throw error[Symbol.asyncIterator] = async function* () {
864
+ for (const jwk2 of candidates)
865
+ try {
866
+ yield await importWithAlgCache(cached, jwk2, entry);
867
+ } catch {}
868
+ }, error;
869
+ }
870
+ return importWithAlgCache(cached, jwk, entry);
871
+ }, "jwks", {
872
+ value: () => structuredClone(snapshot)
873
+ });
874
+ }
875
+
876
+ // node_modules/jose/dist/webapi/jwks/remote.js
877
+ function isCloudflareWorkers() {
878
+ return typeof WebSocketPair < "u" || typeof navigator < "u" && navigator.userAgent === "Cloudflare-Workers" || typeof EdgeRuntime < "u" && EdgeRuntime === "vercel";
879
+ }
880
+ var USER_AGENT;
881
+ (typeof navigator > "u" || !navigator.userAgent?.startsWith?.("Mozilla/5.0 ")) && (USER_AGENT = "jose/v6.2.12");
882
+ var customFetch = /* @__PURE__ */ Symbol();
883
+ async function fetchJwks(url, headers, signal, fetchImpl = fetch) {
884
+ const response = await fetchImpl(url, {
885
+ method: "GET",
886
+ signal,
887
+ redirect: "manual",
888
+ headers
889
+ }).catch((err) => {
890
+ throw err.name === "TimeoutError" ? new JWKSTimeout : err;
891
+ });
892
+ if (response.status !== 200)
893
+ throw new JOSEError("Expected 200 OK from the JSON Web Key Set HTTP response");
894
+ try {
895
+ return await response.json();
896
+ } catch {
897
+ throw new JOSEError("Failed to parse the JSON Web Key Set HTTP response as JSON");
898
+ }
899
+ }
900
+ var jwksCache = /* @__PURE__ */ Symbol();
901
+ function isFreshFor(timestamp, duration) {
902
+ return Number.isFinite(timestamp) && Date.now() < timestamp + duration;
903
+ }
904
+ function validateDuration(value, fallback, option) {
905
+ if (Number.isNaN(value))
906
+ throw new TypeError(`"${option}" option must not be NaN`);
907
+ return typeof value == "number" ? value : fallback;
908
+ }
909
+ function createRemoteJWKSet(url, options) {
910
+ if (!(url instanceof URL))
911
+ throw new TypeError("url must be an instance of URL");
912
+ const href = new URL(url.href).href, opts = options ?? {}, timeoutOption = opts.timeoutDuration;
913
+ if (typeof timeoutOption == "number" && (!Number.isInteger(timeoutOption) || timeoutOption < 0))
914
+ throw new TypeError('"timeoutDuration" option must be a non-negative integer');
915
+ const timeoutDuration = typeof timeoutOption == "number" ? timeoutOption : 5000, cooldownDuration = validateDuration(opts.cooldownDuration, 30000, "cooldownDuration"), cacheMaxAge = validateDuration(opts.cacheMaxAge, 600000, "cacheMaxAge"), headers = new Headers(opts.headers);
916
+ USER_AGENT && !headers.has("User-Agent") && headers.set("User-Agent", USER_AGENT), headers.has("accept") || headers.set("accept", "application/json, application/jwk-set+json");
917
+ const fetchImpl = opts[customFetch], cache = opts[jwksCache];
918
+ let jwksTimestamp, pendingFetch, reloadSequence = 0, appliedSequence = 0, local;
919
+ if (cache && typeof cache == "object") {
920
+ const { uat, jwks } = cache;
921
+ isFreshFor(uat, cacheMaxAge) && isJwkSet(jwks) && (jwksTimestamp = uat, local = createLocalJWKSet(jwks));
922
+ }
923
+ const reload = async () => {
924
+ if (pendingFetch && isCloudflareWorkers() && (pendingFetch = undefined), !pendingFetch) {
925
+ const sequence = ++reloadSequence, current = pendingFetch = fetchJwks(href, headers, AbortSignal.timeout(timeoutDuration), fetchImpl).then((json) => {
926
+ const next = createLocalJWKSet(json);
927
+ if (sequence <= appliedSequence)
928
+ return;
929
+ local = next;
930
+ const updatedAt = Date.now();
931
+ cache && (cache.uat = updatedAt, cache.jwks = json), jwksTimestamp = updatedAt, appliedSequence = sequence;
932
+ }).finally(() => {
933
+ pendingFetch === current && (pendingFetch = undefined);
934
+ });
935
+ }
936
+ await pendingFetch;
937
+ };
938
+ return Object.defineProperties(async (protectedHeader, token) => {
939
+ (!local || !isFreshFor(jwksTimestamp, cacheMaxAge)) && await reload();
940
+ try {
941
+ return await local(protectedHeader, token);
942
+ } catch (err) {
943
+ if (err instanceof JWKSNoMatchingKey && !isFreshFor(jwksTimestamp, cooldownDuration))
944
+ return await reload(), local(protectedHeader, token);
945
+ throw err;
946
+ }
947
+ }, {
948
+ coolingDown: {
949
+ get: () => isFreshFor(jwksTimestamp, cooldownDuration),
950
+ enumerable: true
951
+ },
952
+ fresh: {
953
+ get: () => isFreshFor(jwksTimestamp, cacheMaxAge),
954
+ enumerable: true
955
+ },
956
+ reload: {
957
+ value: reload,
958
+ enumerable: true
959
+ },
960
+ reloading: {
961
+ get: () => !!pendingFetch,
962
+ enumerable: true
963
+ },
964
+ jwks: {
965
+ value: () => local?.jwks(),
966
+ enumerable: true
967
+ }
968
+ });
969
+ }
970
+
250
971
  // src/runtime/auth/oauth.ts
251
972
  class OAuthAccountIneligibleError extends Error {
252
973
  code;
@@ -255,12 +976,22 @@ class OAuthAccountIneligibleError extends Error {
255
976
  this.code = code;
256
977
  }
257
978
  }
979
+
980
+ class OAuthIdentityLinkError extends Error {
981
+ code;
982
+ constructor(code) {
983
+ super(code);
984
+ this.code = code;
985
+ }
986
+ }
258
987
  var PRESETS = {
259
988
  google: {
260
989
  authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
261
990
  tokenUrl: "https://oauth2.googleapis.com/token",
262
991
  userInfoUrl: "https://openidconnect.googleapis.com/v1/userinfo",
263
992
  scopes: "openid email profile",
993
+ idTokenIssuer: "https://accounts.google.com",
994
+ idTokenJwksUrl: "https://www.googleapis.com/oauth2/v3/certs",
264
995
  profileMap: (r) => ({
265
996
  id: typeof r["sub"] === "string" ? r["sub"] : "",
266
997
  ...typeof r["email"] === "string" ? { email: r["email"] } : {},
@@ -269,6 +1000,20 @@ var PRESETS = {
269
1000
  metadata: { avatar_url: r["picture"], full_name: r["name"] }
270
1001
  })
271
1002
  },
1003
+ apple: {
1004
+ authorizeUrl: "https://appleid.apple.com/auth/authorize",
1005
+ tokenUrl: "https://appleid.apple.com/auth/token",
1006
+ userInfoUrl: "https://appleid.apple.com/auth/userinfo",
1007
+ scopes: "name email",
1008
+ idTokenIssuer: "https://appleid.apple.com",
1009
+ idTokenJwksUrl: "https://appleid.apple.com/auth/keys",
1010
+ profileMap: (r) => ({
1011
+ id: typeof r["sub"] === "string" ? r["sub"] : "",
1012
+ ...typeof r["email"] === "string" ? { email: r["email"] } : {},
1013
+ emailVerified: r["email_verified"] === true || r["email_verified"] === "true",
1014
+ ...typeof r["name"] === "string" ? { name: r["name"] } : {}
1015
+ })
1016
+ },
272
1017
  github: {
273
1018
  authorizeUrl: "https://github.com/login/oauth/authorize",
274
1019
  tokenUrl: "https://github.com/login/oauth/access_token",
@@ -302,7 +1047,10 @@ function resolveProvider(name, cfg) {
302
1047
  id: String(r["id"] ?? r["sub"]),
303
1048
  ...typeof r["email"] === "string" ? { email: r["email"] } : {},
304
1049
  ...typeof r["name"] === "string" ? { name: r["name"] } : {}
305
- }))
1050
+ })),
1051
+ ...(cfg.idTokenIssuer ?? preset.idTokenIssuer) === undefined ? {} : { idTokenIssuer: cfg.idTokenIssuer ?? preset.idTokenIssuer },
1052
+ ...(cfg.idTokenJwksUrl ?? preset.idTokenJwksUrl) === undefined ? {} : { idTokenJwksUrl: cfg.idTokenJwksUrl ?? preset.idTokenJwksUrl },
1053
+ ...(cfg.idTokenAudience ?? preset.idTokenAudience) === undefined ? {} : { idTokenAudience: cfg.idTokenAudience ?? preset.idTokenAudience }
306
1054
  };
307
1055
  }
308
1056
 
@@ -314,6 +1062,7 @@ class OAuthService {
314
1062
  uriAllowList;
315
1063
  enforceRedirectAllowList;
316
1064
  providers = new Map;
1065
+ idTokenKeySets = new Map;
317
1066
  constructor(db, apiUrl, siteUrl, configs, fetchImpl = fetch, uriAllowList = [], enforceRedirectAllowList = false) {
318
1067
  this.db = db;
319
1068
  this.apiUrl = apiUrl;
@@ -322,31 +1071,99 @@ class OAuthService {
322
1071
  this.uriAllowList = uriAllowList;
323
1072
  this.enforceRedirectAllowList = enforceRedirectAllowList;
324
1073
  for (const [name, cfg] of Object.entries(configs)) {
325
- this.providers.set(name, resolveProvider(name, cfg));
1074
+ const provider = resolveProvider(name, cfg);
1075
+ this.providers.set(name, provider);
1076
+ if (provider.idTokenJwksUrl) {
1077
+ this.idTokenKeySets.set(name, createRemoteJWKSet(new URL(provider.idTokenJwksUrl), {
1078
+ timeoutDuration: 5000,
1079
+ cooldownDuration: 30000,
1080
+ cacheMaxAge: 10 * 60000,
1081
+ [customFetch]: async (url, options) => this.fetchImpl(url, options)
1082
+ }));
1083
+ }
326
1084
  }
327
1085
  }
328
1086
  has(name) {
329
1087
  return this.providers.has(name);
330
1088
  }
1089
+ async signInWithIdToken(providerName, token, accessToken, nonce) {
1090
+ const profile = await this.verifyIdToken(providerName, token, accessToken, nonce);
1091
+ if (!profile)
1092
+ return null;
1093
+ return this.upsertUser(providerName, profile);
1094
+ }
1095
+ async linkIdentityWithIdToken(providerName, token, userId, accessToken, nonce) {
1096
+ const profile = await this.verifyIdToken(providerName, token, accessToken, nonce);
1097
+ if (!profile)
1098
+ return false;
1099
+ await this.linkIdentityToUser(providerName, profile, userId);
1100
+ return true;
1101
+ }
1102
+ async signInWithProfile(provider, profile) {
1103
+ return this.upsertUser(provider, profile);
1104
+ }
1105
+ async verifyIdToken(providerName, token, accessToken, nonce) {
1106
+ const provider = this.providers.get(providerName);
1107
+ const keySet = this.idTokenKeySets.get(providerName);
1108
+ if (!provider?.idTokenIssuer || !keySet || !token)
1109
+ return null;
1110
+ let payload;
1111
+ let algorithm;
1112
+ try {
1113
+ const verified = await jwtVerify(token, keySet, {
1114
+ issuer: provider.idTokenIssuer,
1115
+ audience: provider.idTokenAudience ?? provider.clientId,
1116
+ algorithms: ["RS256", "ES256"]
1117
+ });
1118
+ payload = verified.payload;
1119
+ algorithm = verified.protectedHeader.alg ?? "";
1120
+ } catch {
1121
+ return null;
1122
+ }
1123
+ if (typeof payload.sub !== "string" || payload.sub.length === 0)
1124
+ return null;
1125
+ if (payload["nonce"] !== undefined && (typeof payload["nonce"] !== "string" || payload["nonce"] !== nonce))
1126
+ return null;
1127
+ const acceptedAudiences = provider.idTokenAudience ?? provider.clientId;
1128
+ if (Array.isArray(payload.aud) && payload.aud.length > 1 && (typeof payload["azp"] !== "string" || (Array.isArray(acceptedAudiences) ? !acceptedAudiences.includes(payload["azp"]) : payload["azp"] !== acceptedAudiences)))
1129
+ return null;
1130
+ if (typeof payload["at_hash"] === "string") {
1131
+ if (!accessToken || await oidcAtHash(accessToken, algorithm) !== payload["at_hash"])
1132
+ return null;
1133
+ }
1134
+ let profile;
1135
+ try {
1136
+ profile = provider.profileMap(payload);
1137
+ } catch {
1138
+ return null;
1139
+ }
1140
+ if (!profile.id)
1141
+ return null;
1142
+ return profile;
1143
+ }
331
1144
  callbackUrl() {
332
1145
  return `${this.apiUrl}/auth/v1/callback`;
333
1146
  }
334
- async authorize(url) {
1147
+ async authorize(url, linkUserId) {
335
1148
  const providerName = url.searchParams.get("provider") ?? "";
336
1149
  const provider = this.providers.get(providerName);
337
1150
  if (!provider) {
338
- return redirect(`${this.siteUrl}#error=provider_not_enabled&error_description=${encodeURIComponent(`provider ${providerName} is not configured`)}`);
1151
+ return redirectWithFragment(this.siteUrl, {
1152
+ error: "provider_not_enabled",
1153
+ error_description: `provider ${providerName} is not configured`
1154
+ });
339
1155
  }
340
1156
  const providerState = randomToken(16);
341
1157
  const scopes = url.searchParams.get("scopes") || provider.scopes;
342
1158
  const redirectTo = resolveRedirect(url.searchParams.get("redirect_to"), this.siteUrl, this.uriAllowList, this.enforceRedirectAllowList);
343
- await this.db.query(`insert into auth.flow_state (provider, provider_state, redirect_to, code_challenge, code_challenge_method, expires_at)
344
- values ($1, $2, $3, $4, $5, now() + interval '10 minutes')`, [
1159
+ await this.db.query(`insert into auth.flow_state (provider, provider_state, redirect_to, code_challenge, code_challenge_method, link_user_id, expires_at)
1160
+ values ($1, $2, $3, $4, $5, $6, now() + interval '10 minutes')`, [
345
1161
  providerName,
346
1162
  providerState,
347
1163
  redirectTo,
348
1164
  url.searchParams.get("code_challenge"),
349
- url.searchParams.get("code_challenge_method")
1165
+ url.searchParams.get("code_challenge_method"),
1166
+ linkUserId ?? null
350
1167
  ]);
351
1168
  const p = new URLSearchParams({
352
1169
  client_id: provider.clientId,
@@ -355,53 +1172,96 @@ class OAuthService {
355
1172
  scope: scopes,
356
1173
  state: providerState
357
1174
  });
358
- return redirect(`${provider.authorizeUrl}?${p}`);
1175
+ const reserved = new Set([
1176
+ "provider",
1177
+ "redirect_to",
1178
+ "scopes",
1179
+ "code_challenge",
1180
+ "code_challenge_method",
1181
+ "skip_http_redirect",
1182
+ "client_id",
1183
+ "redirect_uri",
1184
+ "response_type",
1185
+ "scope",
1186
+ "state"
1187
+ ]);
1188
+ for (const [key, value] of url.searchParams) {
1189
+ if (!reserved.has(key))
1190
+ p.append(key, value);
1191
+ }
1192
+ const providerUrl = `${provider.authorizeUrl}?${p}`;
1193
+ if (url.searchParams.get("skip_http_redirect") === "true") {
1194
+ return Response.json({ url: providerUrl });
1195
+ }
1196
+ return redirect(providerUrl);
359
1197
  }
360
1198
  async callback(url, createSession) {
361
1199
  const code = url.searchParams.get("code");
362
1200
  const state = url.searchParams.get("state");
363
1201
  if (!code || !state)
364
- return redirect(`${this.siteUrl}#error=invalid_request`);
1202
+ return redirectWithFragment(this.siteUrl, { error: "invalid_request" });
365
1203
  const res = await this.db.query(`delete from auth.flow_state where provider_state = $1 and expires_at > now() returning *`, [state]);
366
1204
  const flow = res.rows[0];
367
1205
  if (!flow)
368
- return redirect(`${this.siteUrl}#error=invalid_state`);
1206
+ return redirectWithFragment(this.siteUrl, { error: "invalid_state" });
369
1207
  const provider = this.providers.get(flow.provider);
370
1208
  if (!provider)
371
- return redirect(`${flow.redirect_to}#error=provider_not_enabled`);
1209
+ return redirectWithFragment(flow.redirect_to, { error: "provider_not_enabled" });
372
1210
  let profile;
373
1211
  try {
374
1212
  const token = await this.exchangeCode(provider, code);
375
1213
  profile = await this.fetchProfile(provider, token);
376
1214
  } catch {
377
- return redirect(`${flow.redirect_to}#error=server_error&error_description=${encodeURIComponent("OAuth provider request failed")}`);
1215
+ return redirectWithFragment(flow.redirect_to, {
1216
+ error: "server_error",
1217
+ error_description: "OAuth provider request failed"
1218
+ });
378
1219
  }
379
1220
  let userId;
380
1221
  try {
381
- userId = await this.upsertUser(provider.name, profile);
1222
+ if (flow.link_user_id) {
1223
+ await this.linkIdentityToUser(provider.name, profile, flow.link_user_id);
1224
+ userId = flow.link_user_id;
1225
+ } else {
1226
+ userId = await this.upsertUser(provider.name, profile);
1227
+ }
382
1228
  } catch (e) {
383
1229
  if (e instanceof OAuthAccountIneligibleError) {
384
- return redirect(`${flow.redirect_to}#error=access_denied&error_code=${e.code}`);
1230
+ return redirectWithFragment(flow.redirect_to, { error: "access_denied", error_code: e.code });
1231
+ }
1232
+ if (e instanceof OAuthIdentityLinkError) {
1233
+ return redirectWithFragment(flow.redirect_to, { error: "access_denied", error_code: e.code });
385
1234
  }
386
- return redirect(`${flow.redirect_to}#error=server_error&error_description=${encodeURIComponent("OAuth callback failed")}`);
1235
+ return redirectWithFragment(flow.redirect_to, {
1236
+ error: "server_error",
1237
+ error_description: "OAuth callback failed"
1238
+ });
387
1239
  }
388
1240
  if (flow.code_challenge) {
389
1241
  const authCode = randomToken(24);
390
1242
  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)
391
1243
  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]);
392
- return redirect(`${flow.redirect_to}?code=${authCode}`);
1244
+ return redirectWithQuery(flow.redirect_to, { code: authCode });
393
1245
  }
394
1246
  let session;
395
1247
  try {
396
1248
  session = await createSession(userId);
397
1249
  } catch (e) {
398
1250
  if (e instanceof OAuthAccountIneligibleError) {
399
- return redirect(`${flow.redirect_to}#error=access_denied&error_code=${e.code}`);
1251
+ return redirectWithFragment(flow.redirect_to, { error: "access_denied", error_code: e.code });
400
1252
  }
401
- return redirect(`${flow.redirect_to}#error=server_error&error_description=${encodeURIComponent("OAuth callback failed")}`);
1253
+ return redirectWithFragment(flow.redirect_to, {
1254
+ error: "server_error",
1255
+ error_description: "OAuth callback failed"
1256
+ });
402
1257
  }
403
- const hash = `#access_token=${session.access_token}&refresh_token=${session.refresh_token}&expires_in=${session.expires_in}&token_type=bearer&provider_token=`;
404
- return redirect(`${flow.redirect_to}${hash}`);
1258
+ return redirectWithFragment(flow.redirect_to, {
1259
+ access_token: session.access_token,
1260
+ refresh_token: session.refresh_token,
1261
+ expires_in: String(session.expires_in),
1262
+ token_type: "bearer",
1263
+ provider_token: ""
1264
+ });
405
1265
  }
406
1266
  async exchangePkce(authCode, verifier) {
407
1267
  const res = await this.db.query(`select * from auth.flow_state where auth_code = $1 and expires_at > now()`, [authCode]);
@@ -456,6 +1316,20 @@ class OAuthService {
456
1316
  async upsertUser(provider, profile) {
457
1317
  return this.db.transaction((query) => this.upsertUserInTransaction(query, provider, profile));
458
1318
  }
1319
+ async linkIdentityToUser(provider, profile, userId) {
1320
+ await this.db.transaction(async (query) => {
1321
+ await this.assertUserEligible(query, userId);
1322
+ const identityData = JSON.stringify({ sub: profile.id, email: profile.email, ...profile.metadata });
1323
+ const inserted = await query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
1324
+ values ($1, $2, $3, $4::jsonb)
1325
+ on conflict (provider, provider_id) do nothing
1326
+ returning user_id`, [userId, provider, profile.id, identityData]);
1327
+ if (inserted.rows.length === 0) {
1328
+ throw new OAuthIdentityLinkError("identity_already_exists");
1329
+ }
1330
+ await this.syncUserProviders(query, userId);
1331
+ });
1332
+ }
459
1333
  async upsertUserInTransaction(query, provider, profile) {
460
1334
  const existing = await query(`select user_id from auth.identities where provider = $1 and provider_id = $2`, [provider, profile.id]);
461
1335
  if (existing.rows[0]) {
@@ -466,11 +1340,12 @@ class OAuthService {
466
1340
  JSON.stringify({ sub: profile.id, email: profile.email, ...profile.metadata })
467
1341
  ]);
468
1342
  await query(`update auth.users set last_sign_in_at = now() where id = $1`, [existing.rows[0].user_id]);
1343
+ await this.syncUserProviders(query, existing.rows[0].user_id);
469
1344
  return existing.rows[0].user_id;
470
1345
  }
471
1346
  let userId;
472
- const email = profile.email?.toLowerCase().trim() ?? null;
473
- const verified = !!profile.email && profile.emailVerified === true;
1347
+ const email = typeof profile.email === "string" ? profile.email.toLowerCase().trim() || null : null;
1348
+ const verified = email !== null && profile.emailVerified === true;
474
1349
  if (email && verified) {
475
1350
  const byEmail = await query(`select id from auth.users where lower(email) = $1`, [email]);
476
1351
  userId = byEmail.rows[0]?.id;
@@ -514,8 +1389,10 @@ class OAuthService {
514
1389
  values ($1, $2, $3, $4::jsonb)
515
1390
  on conflict (provider, provider_id) do nothing
516
1391
  returning user_id`, [userId, provider, profile.id, identityData]);
517
- if (linked.rows[0])
1392
+ if (linked.rows[0]) {
1393
+ await this.syncUserProviders(query, linked.rows[0].user_id);
518
1394
  return linked.rows[0].user_id;
1395
+ }
519
1396
  const raced = await query(`select user_id from auth.identities where provider = $1 and provider_id = $2`, [provider, profile.id]);
520
1397
  const racedUserId = raced.rows[0]?.user_id;
521
1398
  if (!racedUserId)
@@ -527,8 +1404,27 @@ class OAuthService {
527
1404
  await query(`update auth.identities set last_sign_in_at = now(), identity_data = $3
528
1405
  where provider = $1 and provider_id = $2`, [provider, profile.id, identityData]);
529
1406
  await query(`update auth.users set last_sign_in_at = now() where id = $1`, [racedUserId]);
1407
+ await this.syncUserProviders(query, racedUserId);
530
1408
  return racedUserId;
531
1409
  }
1410
+ async syncUserProviders(query, userId) {
1411
+ await query(`update auth.users
1412
+ set raw_app_meta_data = jsonb_set(
1413
+ coalesce(raw_app_meta_data, '{}'::jsonb),
1414
+ '{providers}',
1415
+ (
1416
+ select jsonb_agg(provider order by provider)
1417
+ from (
1418
+ select distinct provider
1419
+ from auth.identities
1420
+ where user_id = $1
1421
+ ) as providers
1422
+ ),
1423
+ true
1424
+ ),
1425
+ updated_at = now()
1426
+ where id = $1`, [userId]);
1427
+ }
532
1428
  async assertUserEligible(query, userId) {
533
1429
  const users = await query(`select deleted_at is not null as is_deleted,
534
1430
  coalesce(banned_until > now(), false) as is_banned
@@ -540,9 +1436,27 @@ class OAuthService {
540
1436
  throw new OAuthAccountIneligibleError("user_banned");
541
1437
  }
542
1438
  }
1439
+ async function oidcAtHash(accessToken, algorithm) {
1440
+ const hash = algorithm === "RS256" || algorithm === "ES256" ? "SHA-256" : null;
1441
+ if (!hash)
1442
+ return null;
1443
+ const digest = new Uint8Array(await crypto.subtle.digest(hash, new TextEncoder().encode(accessToken)));
1444
+ return base64url(digest.subarray(0, digest.length / 2));
1445
+ }
543
1446
  function redirect(location) {
544
1447
  return new Response(null, { status: 303, headers: { location } });
545
1448
  }
1449
+ function redirectWithQuery(location, params) {
1450
+ const target = new URL(location);
1451
+ for (const [key, value] of Object.entries(params))
1452
+ target.searchParams.set(key, value);
1453
+ return redirect(target.toString());
1454
+ }
1455
+ function redirectWithFragment(location, params) {
1456
+ const target = new URL(location);
1457
+ target.hash = new URLSearchParams(params).toString();
1458
+ return redirect(target.toString());
1459
+ }
546
1460
  function base64url(bytes) {
547
1461
  let bin = "";
548
1462
  for (const b of bytes)
@@ -1046,6 +1960,7 @@ var DEFAULT_AUTH_SETTINGS = {
1046
1960
  anonymousUsers: true,
1047
1961
  autoconfirm: true,
1048
1962
  minPasswordLength: 6,
1963
+ requirePasswordReauthentication: false,
1049
1964
  disabledProviders: [],
1050
1965
  otpLength: 6,
1051
1966
  otpExpirySeconds: 3600,
@@ -1055,7 +1970,13 @@ var DEFAULT_AUTH_SETTINGS = {
1055
1970
  smsTemplate: "Your one-time code is {{ .Code }}",
1056
1971
  maxEnrolledFactors: 10,
1057
1972
  totpEnrollEnabled: true,
1058
- totpVerifyEnabled: true
1973
+ totpVerifyEnabled: true,
1974
+ recoveryCodesEnrollEnabled: true,
1975
+ recoveryCodesVerifyEnabled: true,
1976
+ recoveryCodesCount: 10,
1977
+ recoveryCodeLength: 16,
1978
+ recoveryCodesMaxVerifyAttempts: 5,
1979
+ recoveryCodesLockoutSeconds: 300
1059
1980
  };
1060
1981
  function sanitize(raw) {
1061
1982
  const s = { ...DEFAULT_AUTH_SETTINGS };
@@ -1068,6 +1989,9 @@ function sanitize(raw) {
1068
1989
  if (typeof raw["minPasswordLength"] === "number" && Number.isFinite(raw["minPasswordLength"])) {
1069
1990
  s.minPasswordLength = Math.max(4, Math.min(72, Math.floor(raw["minPasswordLength"])));
1070
1991
  }
1992
+ if (typeof raw["requirePasswordReauthentication"] === "boolean") {
1993
+ s.requirePasswordReauthentication = raw["requirePasswordReauthentication"];
1994
+ }
1071
1995
  if (Array.isArray(raw["disabledProviders"])) {
1072
1996
  s.disabledProviders = raw["disabledProviders"].filter((p) => typeof p === "string").slice(0, 50);
1073
1997
  }
@@ -1094,6 +2018,22 @@ function sanitize(raw) {
1094
2018
  s.totpEnrollEnabled = raw["totpEnrollEnabled"];
1095
2019
  if (typeof raw["totpVerifyEnabled"] === "boolean")
1096
2020
  s.totpVerifyEnabled = raw["totpVerifyEnabled"];
2021
+ if (typeof raw["recoveryCodesEnrollEnabled"] === "boolean")
2022
+ s.recoveryCodesEnrollEnabled = raw["recoveryCodesEnrollEnabled"];
2023
+ if (typeof raw["recoveryCodesVerifyEnabled"] === "boolean")
2024
+ s.recoveryCodesVerifyEnabled = raw["recoveryCodesVerifyEnabled"];
2025
+ if (typeof raw["recoveryCodesCount"] === "number" && Number.isFinite(raw["recoveryCodesCount"])) {
2026
+ s.recoveryCodesCount = Math.max(1, Math.min(50, Math.floor(raw["recoveryCodesCount"])));
2027
+ }
2028
+ if (typeof raw["recoveryCodeLength"] === "number" && Number.isFinite(raw["recoveryCodeLength"])) {
2029
+ s.recoveryCodeLength = Math.max(8, Math.min(64, Math.floor(raw["recoveryCodeLength"])));
2030
+ }
2031
+ if (typeof raw["recoveryCodesMaxVerifyAttempts"] === "number" && Number.isFinite(raw["recoveryCodesMaxVerifyAttempts"])) {
2032
+ s.recoveryCodesMaxVerifyAttempts = Math.max(1, Math.min(20, Math.floor(raw["recoveryCodesMaxVerifyAttempts"])));
2033
+ }
2034
+ if (typeof raw["recoveryCodesLockoutSeconds"] === "number" && Number.isFinite(raw["recoveryCodesLockoutSeconds"])) {
2035
+ s.recoveryCodesLockoutSeconds = Math.max(1, Math.min(86400, Math.floor(raw["recoveryCodesLockoutSeconds"])));
2036
+ }
1097
2037
  return s;
1098
2038
  }
1099
2039
  async function loadAuthSettings(db, defaults = {}) {
@@ -1232,6 +2172,140 @@ async function verifyTotp(secretBase32, code, window = 1, atMs = Date.now()) {
1232
2172
  return false;
1233
2173
  }
1234
2174
 
2175
+ // src/runtime/auth/web3.ts
2176
+ var BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
2177
+ var CLOCK_SKEW_SECONDS = 60;
2178
+ function decodeBase58(value) {
2179
+ if (!value)
2180
+ return null;
2181
+ const bytes = [];
2182
+ for (const character of value) {
2183
+ const digit = BASE58_ALPHABET.indexOf(character);
2184
+ if (digit < 0)
2185
+ return null;
2186
+ let carry = digit;
2187
+ for (let index = 0;index < bytes.length; index += 1) {
2188
+ carry += bytes[index] * 58;
2189
+ bytes[index] = carry & 255;
2190
+ carry >>= 8;
2191
+ }
2192
+ while (carry > 0) {
2193
+ bytes.push(carry & 255);
2194
+ carry >>= 8;
2195
+ }
2196
+ }
2197
+ let leadingZeros = 0;
2198
+ while (leadingZeros < value.length && value[leadingZeros] === "1")
2199
+ leadingZeros += 1;
2200
+ return new Uint8Array([...new Array(leadingZeros).fill(0), ...bytes.reverse()]);
2201
+ }
2202
+ function encodeBase58(value) {
2203
+ if (value.length === 0)
2204
+ return "";
2205
+ const digits = [];
2206
+ for (const byte of value) {
2207
+ let carry = byte;
2208
+ for (let index = 0;index < digits.length; index += 1) {
2209
+ carry += digits[index] * 256;
2210
+ digits[index] = carry % 58;
2211
+ carry = Math.floor(carry / 58);
2212
+ }
2213
+ while (carry > 0) {
2214
+ digits.push(carry % 58);
2215
+ carry = Math.floor(carry / 58);
2216
+ }
2217
+ }
2218
+ let leadingZeros = 0;
2219
+ while (leadingZeros < value.length && value[leadingZeros] === 0)
2220
+ leadingZeros += 1;
2221
+ return `${"1".repeat(leadingZeros)}${digits.reverse().map((digit) => BASE58_ALPHABET[digit]).join("")}`;
2222
+ }
2223
+ function field(lines, name) {
2224
+ const prefix = `${name}: `;
2225
+ const line = lines.find((entry) => entry.startsWith(prefix));
2226
+ return line?.slice(prefix.length);
2227
+ }
2228
+ function unixSeconds(value) {
2229
+ if (!value)
2230
+ return;
2231
+ const time = Date.parse(value);
2232
+ return Number.isFinite(time) ? Math.floor(time / 1000) : undefined;
2233
+ }
2234
+ function parseSolanaSignInMessage(message) {
2235
+ if (!message || message.length > 16384)
2236
+ return null;
2237
+ const lines = message.split(`
2238
+ `);
2239
+ const header = lines[0]?.match(/^(.+) wants you to sign in with your Solana account:$/);
2240
+ const address = lines[1]?.trim();
2241
+ const uri = field(lines, "URI");
2242
+ const version = field(lines, "Version");
2243
+ const issuedAt = unixSeconds(field(lines, "Issued At"));
2244
+ if (!header || !address || !uri || version !== "1" || issuedAt === undefined)
2245
+ return null;
2246
+ const publicKey = decodeBase58(address);
2247
+ if (!publicKey || publicKey.length !== 32 || encodeBase58(publicKey) !== address)
2248
+ return null;
2249
+ let parsedUri;
2250
+ try {
2251
+ parsedUri = new URL(uri);
2252
+ } catch {
2253
+ return null;
2254
+ }
2255
+ if (parsedUri.host !== header[1])
2256
+ return null;
2257
+ const expirationTime = unixSeconds(field(lines, "Expiration Time"));
2258
+ const notBefore = unixSeconds(field(lines, "Not Before"));
2259
+ if (field(lines, "Expiration Time") && expirationTime === undefined)
2260
+ return null;
2261
+ if (field(lines, "Not Before") && notBefore === undefined)
2262
+ return null;
2263
+ const now = Math.floor(Date.now() / 1000);
2264
+ if (issuedAt > now + CLOCK_SKEW_SECONDS)
2265
+ return null;
2266
+ if (expirationTime !== undefined && expirationTime <= now)
2267
+ return null;
2268
+ if (notBefore !== undefined && notBefore > now + CLOCK_SKEW_SECONDS)
2269
+ return null;
2270
+ return {
2271
+ address,
2272
+ domain: header[1],
2273
+ uri,
2274
+ issuedAt,
2275
+ ...expirationTime === undefined ? {} : { expirationTime },
2276
+ ...notBefore === undefined ? {} : { notBefore },
2277
+ ...field(lines, "Nonce") === undefined ? {} : { nonce: field(lines, "Nonce") }
2278
+ };
2279
+ }
2280
+ async function verifySolanaSignIn(message, signature) {
2281
+ const parsed = parseSolanaSignInMessage(message);
2282
+ if (!parsed)
2283
+ return null;
2284
+ const publicKey = decodeBase58(parsed.address);
2285
+ if (!publicKey)
2286
+ return null;
2287
+ let signatureBytes;
2288
+ try {
2289
+ const normalized = signature.replace(/-/g, "+").replace(/_/g, "/");
2290
+ const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
2291
+ signatureBytes = Uint8Array.from(Buffer.from(padded, "base64"));
2292
+ } catch {
2293
+ return null;
2294
+ }
2295
+ if (signatureBytes.length !== 64)
2296
+ return null;
2297
+ try {
2298
+ const publicKeyBuffer = Uint8Array.from(publicKey).buffer;
2299
+ const signatureBuffer = Uint8Array.from(signatureBytes).buffer;
2300
+ const messageBuffer = new TextEncoder().encode(message).buffer;
2301
+ const key = await crypto.subtle.importKey("raw", publicKeyBuffer, "Ed25519", false, ["verify"]);
2302
+ const valid = await crypto.subtle.verify("Ed25519", key, signatureBuffer, messageBuffer);
2303
+ return valid ? parsed : null;
2304
+ } catch {
2305
+ return null;
2306
+ }
2307
+ }
2308
+
1235
2309
  // src/runtime/auth/handler.ts
1236
2310
  function authError(status, errorCode, msg) {
1237
2311
  return json(status, { code: status, error_code: errorCode, msg });
@@ -1242,6 +2316,12 @@ function normalizeEmailInput(value) {
1242
2316
  const email = value.trim().toLowerCase();
1243
2317
  return email || null;
1244
2318
  }
2319
+ function base64url2(bytes) {
2320
+ let binary = "";
2321
+ for (const byte of bytes)
2322
+ binary += String.fromCharCode(byte);
2323
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
2324
+ }
1245
2325
  function randomOtp(length) {
1246
2326
  const n = Math.max(6, Math.min(10, Math.floor(length)));
1247
2327
  let code = "";
@@ -1267,9 +2347,41 @@ function normalizePhone(value) {
1267
2347
  const phone = value.trim();
1268
2348
  return /^\+[1-9]\d{7,14}$/.test(phone) ? phone : null;
1269
2349
  }
2350
+ function banDurationSeconds(value) {
2351
+ if (value === "none")
2352
+ return null;
2353
+ if (typeof value !== "string")
2354
+ return;
2355
+ const match = value.trim().match(/^(\d+(?:\.\d+)?)(ns|us|\u00B5s|ms|s|m|h)$/);
2356
+ if (!match)
2357
+ return;
2358
+ const amount = Number(match[1]);
2359
+ const multipliers = { ns: 0.000000001, us: 0.000001, "\xB5s": 0.000001, ms: 0.001, s: 1, m: 60, h: 3600 };
2360
+ const multiplier = multipliers[match[2]];
2361
+ const seconds = amount * multiplier;
2362
+ return Number.isFinite(seconds) ? seconds : undefined;
2363
+ }
1270
2364
  function recordValue(value) {
1271
2365
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
1272
2366
  }
2367
+ var RECOVERY_CODE_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
2368
+ function normalizeRecoveryCode(value) {
2369
+ return typeof value === "string" ? value.replace(/[\s-]/g, "").toLowerCase() : "";
2370
+ }
2371
+ function randomRecoveryCode(length) {
2372
+ let code = "";
2373
+ while (code.length < length) {
2374
+ const bytes = crypto.getRandomValues(new Uint8Array(Math.max(16, length - code.length)));
2375
+ for (const byte of bytes) {
2376
+ if (byte >= 224)
2377
+ continue;
2378
+ code += RECOVERY_CODE_ALPHABET[byte % RECOVERY_CODE_ALPHABET.length];
2379
+ if (code.length === length)
2380
+ break;
2381
+ }
2382
+ }
2383
+ return code;
2384
+ }
1273
2385
  function databaseDiagnosticCode(error) {
1274
2386
  const candidate = typeof error === "object" && error !== null && "code" in error ? error.code : null;
1275
2387
  return typeof candidate === "string" && /^[0-9A-Z]{5}$/.test(candidate) ? candidate : "database_error";
@@ -1293,12 +2405,21 @@ async function sendSmsUntilAbort(sender, message, signal) {
1293
2405
  signal.removeEventListener("abort", rejectForAbort);
1294
2406
  }
1295
2407
  }
1296
- function json(status, body) {
2408
+ function json(status, body, extraHeaders = {}) {
1297
2409
  return new Response(status === 204 ? null : JSON.stringify(body), {
1298
2410
  status,
1299
- headers: { "content-type": "application/json; charset=utf-8" }
2411
+ headers: { "content-type": "application/json; charset=utf-8", ...extraHeaders }
1300
2412
  });
1301
2413
  }
2414
+ function redirect2(location) {
2415
+ return new Response(null, { status: 303, headers: { location } });
2416
+ }
2417
+ function redirectWithQuery2(location, params) {
2418
+ const target = new URL(location);
2419
+ for (const [key, value] of Object.entries(params))
2420
+ target.searchParams.set(key, value);
2421
+ return redirect2(target.toString());
2422
+ }
1302
2423
  function iso(v) {
1303
2424
  if (v === null || v === undefined)
1304
2425
  return null;
@@ -1395,26 +2516,61 @@ class AuthHandler {
1395
2516
  return await this.getUser(req);
1396
2517
  if (path === "user" && method === "PUT")
1397
2518
  return await this.updateUser(req);
2519
+ if (/^user\/identities\/[^/]+$/.test(path) && method === "DELETE") {
2520
+ return await this.unlinkIdentity(req, decodeURIComponent(path.split("/")[2]));
2521
+ }
2522
+ if (path === "reauthenticate" && (method === "GET" || method === "POST"))
2523
+ return await this.reauthenticate(req);
1398
2524
  if (path === "logout" && method === "POST")
1399
2525
  return await this.logout(req, url);
1400
2526
  if (path === "otp" && method === "POST")
1401
2527
  return await this.sendOtp(req);
1402
2528
  if (path === "recover" && method === "POST")
1403
2529
  return this.limit("recover", req) ?? await this.sendRecovery(req);
1404
- if (["magiclink", "resend"].includes(path) && method === "POST")
2530
+ if (path === "magiclink" && method === "POST")
1405
2531
  return await this.sendOtp(req);
2532
+ if (path === "resend" && method === "POST")
2533
+ return await this.sendOtp(req, true);
1406
2534
  if (path === "verify" && method === "POST")
1407
2535
  return this.limit("verify", req) ?? await this.verifyToken(req);
1408
2536
  if (path === "verify" && method === "GET")
1409
2537
  return await this.verifyLink(url);
2538
+ if (path === "sso" && method === "POST")
2539
+ return await this.sso(req);
2540
+ if (path === "invite" && method === "POST") {
2541
+ if (ctx.role !== "service_role") {
2542
+ return authError(403, "insufficient_permissions", "Admin endpoints require the service_role key");
2543
+ }
2544
+ return await this.generateAdminMagicLink(req, true, "invite");
2545
+ }
1410
2546
  if (path === "factors" && method === "POST")
1411
2547
  return await this.enrollFactor(req);
2548
+ if (path === "factors/recovery-codes" && method === "GET")
2549
+ return await this.recoveryCodesStatus(req);
2550
+ if (path === "factors/recovery-codes" && method === "POST")
2551
+ return await this.generateRecoveryCodes(req);
2552
+ if (path === "factors/recovery-codes" && method === "DELETE")
2553
+ return await this.deleteRecoveryCodes(req);
2554
+ if (path === "factors/recovery-codes/verify" && method === "POST")
2555
+ return await this.verifyRecoveryCode(req);
2556
+ if (path === "factors/recovery-codes/regenerate" && method === "POST")
2557
+ return await this.regenerateRecoveryCodes(req);
1412
2558
  if (/^factors\/[^/]+\/challenge$/.test(path) && method === "POST")
1413
2559
  return await this.challengeFactor(req, path.split("/")[1]);
1414
2560
  if (/^factors\/[^/]+\/verify$/.test(path) && method === "POST")
1415
2561
  return await this.verifyFactor(req, path.split("/")[1]);
1416
2562
  if (/^factors\/[^/]+$/.test(path) && method === "DELETE")
1417
2563
  return await this.unenrollFactor(req, path.split("/")[1]);
2564
+ if (path === "user/identities/authorize" && method === "GET") {
2565
+ const user = await this.userFromBearer(req);
2566
+ if (!user)
2567
+ return authError(401, "no_authorization", "Invalid or expired token");
2568
+ const provider = url.searchParams.get("provider") ?? "";
2569
+ if (provider && this.settings.disabledProviders.includes(provider)) {
2570
+ return authError(422, "provider_disabled", `Sign-ins with ${provider} are disabled`);
2571
+ }
2572
+ return await this.oauth.authorize(url, user.id);
2573
+ }
1418
2574
  if (path === "authorize" && method === "GET") {
1419
2575
  const provider = url.searchParams.get("provider") ?? "";
1420
2576
  if (provider && this.settings.disabledProviders.includes(provider)) {
@@ -1449,15 +2605,26 @@ class AuthHandler {
1449
2605
  return callbackUrl;
1450
2606
  }
1451
2607
  async signup(req) {
1452
- const body = await req.json().catch(() => ({}));
2608
+ let body;
2609
+ try {
2610
+ const parsed = await req.json();
2611
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
2612
+ return authError(400, "validation_failed", "Signup body must be a JSON object");
2613
+ }
2614
+ body = parsed;
2615
+ } catch {
2616
+ return authError(400, "validation_failed", "Signup body must be valid JSON");
2617
+ }
1453
2618
  if (!("email" in body) && !("password" in body)) {
1454
2619
  if (!this.settings.anonymousUsers) {
1455
2620
  return authError(422, "anonymous_provider_disabled", "Anonymous sign-ins are disabled");
1456
2621
  }
1457
2622
  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)
1458
- values ('authenticated', 'authenticated', '{}', $1, true, now())
2623
+ values ('authenticated', 'authenticated', '{"provider":"anonymous","providers":["anonymous"]}', $1, true, now())
1459
2624
  returning *`, [JSON.stringify(body.data ?? {})]);
1460
- return json(200, await this.sessionFor(res.rows[0]));
2625
+ return json(200, await this.sessionFor(res.rows[0], undefined, {
2626
+ amr: [{ method: "anonymous", timestamp: Math.floor(Date.now() / 1000) }]
2627
+ }));
1461
2628
  }
1462
2629
  if (this.settings.disableSignup) {
1463
2630
  return authError(422, "signup_disabled", "Signups not allowed for this instance");
@@ -1492,31 +2659,64 @@ class AuthHandler {
1492
2659
  return authError(422, "user_already_exists", "User already registered");
1493
2660
  await this.audit("user_signedup", { actorId: newUser.id, actorEmail: email });
1494
2661
  if (!autoconfirm) {
1495
- await this.issueToken(email, "otp", false, "confirm");
2662
+ await this.issueToken(email, "otp", false, "confirm", {}, new URL(req.url).searchParams.get("redirect_to"), body.code_challenge, body.code_challenge_method);
1496
2663
  return json(200, this.userJson(newUser));
1497
2664
  }
1498
2665
  return json(200, await this.sessionFor(newUser));
1499
2666
  }
2667
+ async sso(req) {
2668
+ const body = await req.json().catch(() => ({}));
2669
+ const providerId = typeof body.provider_id === "string" ? body.provider_id.trim().toLowerCase() : "";
2670
+ const domain = typeof body.domain === "string" ? body.domain.trim().toLowerCase() : "";
2671
+ const providers = this.config.oauthProviders ?? {};
2672
+ const provider = providerId && providers[providerId] ? providerId : Object.entries(providers).find(([, config]) => domain && (config.ssoDomains ?? []).some((item) => item.toLowerCase() === domain))?.[0] ?? "";
2673
+ if (!provider)
2674
+ return authError(404, "sso_provider_not_found", "SSO provider not found");
2675
+ const authorizeUrl = new URL(req.url);
2676
+ authorizeUrl.pathname = authorizeUrl.pathname.replace(/\/sso$/, "/authorize");
2677
+ authorizeUrl.search = "";
2678
+ authorizeUrl.searchParams.set("provider", provider);
2679
+ authorizeUrl.searchParams.set("skip_http_redirect", String(body.skip_http_redirect === true));
2680
+ for (const [key, value] of [
2681
+ ["redirect_to", body.redirect_to],
2682
+ ["code_challenge", body.code_challenge],
2683
+ ["code_challenge_method", body.code_challenge_method]
2684
+ ]) {
2685
+ if (value)
2686
+ authorizeUrl.searchParams.set(key, value);
2687
+ }
2688
+ return this.oauth.authorize(authorizeUrl);
2689
+ }
1500
2690
  async token(req, url) {
1501
2691
  const grantType = url.searchParams.get("grant_type");
1502
2692
  const body = await req.json().catch(() => ({}));
1503
2693
  if (grantType === "password") {
1504
- const email = (body.email ?? "").toLowerCase().trim();
2694
+ const email = body.email === undefined ? null : normalizeEmailInput(body.email);
2695
+ if (body.email !== undefined && !email)
2696
+ return authError(400, "validation_failed", "email must not be empty");
2697
+ const phone = body.phone === undefined ? null : normalizePhone(body.phone);
2698
+ if (body.phone !== undefined && !phone)
2699
+ return authError(400, "validation_failed", "phone must be a valid E.164 number");
2700
+ if (!email && !phone)
2701
+ return authError(400, "validation_failed", "email or phone is required");
1505
2702
  const res = await this.db.query(`select *, coalesce(banned_until > now(), false) as is_banned
1506
- from auth.users where lower(email) = $1 and deleted_at is null`, [email]);
2703
+ from auth.users
2704
+ where (($1::text is not null and lower(email) = $1)
2705
+ or ($2::text is not null and phone = $2))
2706
+ and deleted_at is null`, [email, phone]);
1507
2707
  const user = res.rows[0];
1508
2708
  if (user?.encrypted_password && user.is_banned) {
1509
2709
  return authError(400, "user_banned", "User is banned");
1510
2710
  }
1511
2711
  if (!user || !user.encrypted_password || !await verifyPassword(body.password ?? "", user.encrypted_password)) {
1512
- await this.audit("login_failed", { actorEmail: email, traits: { grant_type: "password" } });
2712
+ await this.audit("login_failed", { actorEmail: email ?? phone, traits: { grant_type: "password" } });
1513
2713
  return authError(400, "invalid_credentials", "Invalid login credentials");
1514
2714
  }
1515
2715
  const response = await this.db.transaction(async (query) => {
1516
2716
  const currentResult = await query(`select *, coalesce(banned_until > now(), false) as is_banned
1517
2717
  from auth.users where id = $1 for update`, [user.id]);
1518
2718
  const current = currentResult.rows[0];
1519
- if (!current || current.deleted_at !== null || !current.encrypted_password || current.email?.toLowerCase().trim() !== email) {
2719
+ if (!current || current.deleted_at !== null || !current.encrypted_password || (email ? current.email?.toLowerCase().trim() !== email : current.phone !== phone)) {
1520
2720
  return authError(400, "invalid_credentials", "Invalid login credentials");
1521
2721
  }
1522
2722
  if (current.is_banned) {
@@ -1525,7 +2725,10 @@ class AuthHandler {
1525
2725
  if (current.encrypted_password !== user.encrypted_password) {
1526
2726
  return authError(400, "invalid_credentials", "Invalid login credentials");
1527
2727
  }
1528
- if (!this.settings.autoconfirm && !current.email_confirmed_at) {
2728
+ if (phone && !current.phone_confirmed_at) {
2729
+ return authError(400, "phone_not_confirmed", "Phone not confirmed");
2730
+ }
2731
+ if (email && !this.settings.autoconfirm && !current.email_confirmed_at) {
1529
2732
  return authError(400, "email_not_confirmed", "Email not confirmed");
1530
2733
  }
1531
2734
  const signedIn = await query(`update auth.users set last_sign_in_at = now() where id = $1 returning *`, [current.id]);
@@ -1536,7 +2739,7 @@ class AuthHandler {
1536
2739
  return json(200, await this.sessionFor(updated, undefined, undefined, query));
1537
2740
  });
1538
2741
  if (response.ok) {
1539
- await this.audit("login", { actorId: user.id, actorEmail: email, traits: { grant_type: "password" } });
2742
+ await this.audit("login", { actorId: user.id, actorEmail: user.email ?? user.phone, traits: { grant_type: "password" } });
1540
2743
  }
1541
2744
  return response;
1542
2745
  }
@@ -1551,11 +2754,72 @@ class AuthHandler {
1551
2754
  const verifier = body.code_verifier;
1552
2755
  if (!authCode || !verifier)
1553
2756
  return authError(400, "validation_failed", "auth_code and code_verifier required");
2757
+ const emailClaim = await this.exchangeEmailPkce(authCode, verifier);
2758
+ if (emailClaim) {
2759
+ return json(200, await this.sessionForEmailPkce(emailClaim.userId, emailClaim.provider));
2760
+ }
1554
2761
  const userId = await this.oauth.exchangePkce(authCode, verifier);
1555
2762
  if (!userId)
1556
2763
  return authError(403, "flow_state_not_found", "invalid or expired auth code");
1557
2764
  return json(200, await this.sessionForOAuth(userId));
1558
2765
  }
2766
+ if (grantType === "id_token") {
2767
+ const provider = typeof body.provider === "string" ? body.provider.toLowerCase() : "";
2768
+ const idToken = body.id_token;
2769
+ if (!provider || !idToken)
2770
+ return authError(400, "validation_failed", "provider and id_token required");
2771
+ if (String(body.link_identity) === "true" || body.link_identity === "1") {
2772
+ const current = await this.userFromBearer(req);
2773
+ if (!current)
2774
+ return authError(401, "no_authorization", "This endpoint requires a Bearer token");
2775
+ try {
2776
+ const linked = await this.oauth.linkIdentityWithIdToken(provider, idToken, current.id, body.access_token, body.nonce);
2777
+ if (!linked)
2778
+ return authError(400, "invalid_credentials", "Invalid ID token");
2779
+ } catch (error) {
2780
+ if (error instanceof OAuthIdentityLinkError) {
2781
+ return authError(422, error.code, "Identity is already linked");
2782
+ }
2783
+ throw error;
2784
+ }
2785
+ return this.sessionForOAuth(current.id).then((session) => json(200, session));
2786
+ }
2787
+ const userId = await this.oauth.signInWithIdToken(provider, idToken, body.access_token, body.nonce);
2788
+ if (!userId)
2789
+ return authError(400, "invalid_credentials", "Invalid ID token");
2790
+ return this.db.transaction(async (query) => {
2791
+ const result = await query(`select * from auth.users where id = $1 and deleted_at is null
2792
+ and (banned_until is null or banned_until <= now()) for update`, [userId]);
2793
+ const user = result.rows[0];
2794
+ if (!user)
2795
+ return authError(400, "invalid_credentials", "Invalid ID token");
2796
+ return json(200, await this.sessionFor(user, undefined, {
2797
+ amr: [{ method: "oauth", timestamp: Math.floor(Date.now() / 1000) }]
2798
+ }, query));
2799
+ });
2800
+ }
2801
+ if (grantType === "web3") {
2802
+ if (body.chain !== "solana" || !body.message || !body.signature) {
2803
+ return authError(400, "validation_failed", "chain, message and signature are required");
2804
+ }
2805
+ const wallet = await verifySolanaSignIn(body.message, body.signature);
2806
+ if (!wallet)
2807
+ return authError(400, "invalid_credentials", "Invalid Web3 credentials");
2808
+ const userId = await this.oauth.signInWithProfile("solana", {
2809
+ id: wallet.address,
2810
+ metadata: { address: wallet.address, chain: "solana" }
2811
+ });
2812
+ return this.db.transaction(async (query) => {
2813
+ const result = await query(`select * from auth.users where id = $1 and deleted_at is null
2814
+ and (banned_until is null or banned_until <= now()) for update`, [userId]);
2815
+ const user = result.rows[0];
2816
+ if (!user)
2817
+ return authError(400, "invalid_credentials", "Invalid Web3 credentials");
2818
+ return json(200, await this.sessionFor(user, undefined, {
2819
+ amr: [{ method: "web3", timestamp: Math.floor(Date.now() / 1000) }]
2820
+ }, query));
2821
+ });
2822
+ }
1559
2823
  return authError(400, "invalid_grant", `unsupported grant_type: ${grantType}`);
1560
2824
  }
1561
2825
  async getUser(req) {
@@ -1564,7 +2828,44 @@ class AuthHandler {
1564
2828
  return authError(401, "no_authorization", "Invalid or expired token");
1565
2829
  return json(200, this.userJson(user, await this.getUserFactors(user.id), await this.getUserIdentities(user.id)));
1566
2830
  }
2831
+ async unlinkIdentity(req, identityId) {
2832
+ const user = await this.userFromBearer(req);
2833
+ if (!user)
2834
+ return authError(401, "no_authorization", "Invalid or expired token");
2835
+ return this.db.transaction(async (query) => {
2836
+ const current = (await query(`select * from auth.users where id = $1 and deleted_at is null
2837
+ and (banned_until is null or banned_until <= now()) for update`, [user.id])).rows[0];
2838
+ if (!current)
2839
+ return authError(401, "no_authorization", "Invalid or expired token");
2840
+ const identity = (await query(`select id from auth.identities where id = $1 and user_id = $2`, [identityId, user.id])).rows[0];
2841
+ if (!identity)
2842
+ return authError(404, "identity_not_found", "Identity not found");
2843
+ const count = await query(`select count(*)::int as count from auth.identities where user_id = $1`, [user.id]);
2844
+ if ((count.rows[0]?.count ?? 0) <= 1) {
2845
+ return authError(422, "cannot_unlink_identity", "Cannot unlink the only identity");
2846
+ }
2847
+ await query(`delete from auth.identities where id = $1 and user_id = $2`, [identity.id, user.id]);
2848
+ await query(`update auth.users
2849
+ set raw_app_meta_data = jsonb_set(
2850
+ coalesce(raw_app_meta_data, '{}'::jsonb),
2851
+ '{providers}',
2852
+ (
2853
+ select jsonb_agg(provider order by provider)
2854
+ from (
2855
+ select distinct provider
2856
+ from auth.identities
2857
+ where user_id = $1
2858
+ ) as providers
2859
+ ),
2860
+ true
2861
+ ),
2862
+ updated_at = now()
2863
+ where id = $1`, [user.id]);
2864
+ return json(200, {});
2865
+ });
2866
+ }
1567
2867
  async updateUser(req) {
2868
+ const claims = await this.claimsFromBearer(req);
1568
2869
  const user = await this.userFromBearer(req);
1569
2870
  if (!user)
1570
2871
  return authError(401, "no_authorization", "Invalid or expired token");
@@ -1575,8 +2876,17 @@ class AuthHandler {
1575
2876
  if (body.email !== undefined && !requestedEmail) {
1576
2877
  return authError(400, "validation_failed", "email must not be empty");
1577
2878
  }
2879
+ if (body.phone !== undefined) {
2880
+ if (requestedEmail !== undefined)
2881
+ return authError(400, "validation_failed", "email and phone cannot be updated together");
2882
+ const phone = normalizePhone(body.phone);
2883
+ if (!phone)
2884
+ return authError(400, "validation_failed", "phone must be a valid E.164 number");
2885
+ return this.requestPhoneChange(req, user, phone);
2886
+ }
2887
+ const requiresReauthentication = Boolean(body.password) && this.settings.requirePasswordReauthentication && await this.sessionRequiresReauthentication(claims);
1578
2888
  if (requestedEmail && !this.settings.autoconfirm) {
1579
- return authError(501, "email_change_confirmation_not_supported", "Email changes that require confirmation are not supported by SupaCloud Lite V1");
2889
+ return this.requestEmailChange(req, user, requestedEmail, body);
1580
2890
  }
1581
2891
  if (body.password) {
1582
2892
  if (body.password.length < this.settings.minPasswordLength) {
@@ -1599,6 +2909,14 @@ class AuthHandler {
1599
2909
  return authError(401, "no_authorization", "Invalid or expired token");
1600
2910
  const transactionSets = [...sets];
1601
2911
  const transactionParams = [...params];
2912
+ if (requiresReauthentication) {
2913
+ if (!body.nonce) {
2914
+ return authError(400, "reauthentication_needed", "Password update requires reauthentication");
2915
+ }
2916
+ if (!await this.consumeReauthentication(query, current, body.nonce)) {
2917
+ return authError(422, "reauthentication_not_valid", "Nonce has expired or is invalid");
2918
+ }
2919
+ }
1602
2920
  const upgradingAnon = (current.is_anonymous ?? false) && requestedEmail !== undefined;
1603
2921
  if (requestedEmail) {
1604
2922
  const email = requestedEmail;
@@ -1622,7 +2940,7 @@ class AuthHandler {
1622
2940
  if (!updated)
1623
2941
  return authError(401, "no_authorization", "Invalid or expired token");
1624
2942
  if (requestedEmail)
1625
- await this.ensureEmailIdentity(updated, query);
2943
+ await this.ensureEmailIdentity(updated, query, upgradingAnon);
1626
2944
  return json(200, await this.userJsonWithRelations(updated, query));
1627
2945
  });
1628
2946
  } catch (error) {
@@ -1633,6 +2951,161 @@ class AuthHandler {
1633
2951
  }
1634
2952
  return response;
1635
2953
  }
2954
+ async reauthenticate(req) {
2955
+ const user = await this.userFromBearer(req);
2956
+ if (!user)
2957
+ return authError(401, "no_authorization", "Invalid or expired token");
2958
+ const prepared = await this.db.transaction(async (query) => {
2959
+ const currentResult = await query(`select * from auth.users where id = $1 and deleted_at is null
2960
+ and (banned_until is null or banned_until <= now()) for update`, [user.id]);
2961
+ const current = currentResult.rows[0];
2962
+ if (!current)
2963
+ return authError(401, "no_authorization", "Invalid or expired token");
2964
+ const email = current.email_confirmed_at && current.email ? current.email : null;
2965
+ const phone = !email && current.phone_confirmed_at && current.phone ? current.phone : null;
2966
+ if (!email && !phone) {
2967
+ if (current.email && !current.email_confirmed_at) {
2968
+ return authError(422, "email_not_confirmed", "Please verify your email first.");
2969
+ }
2970
+ if (current.phone && !current.phone_confirmed_at) {
2971
+ return authError(422, "phone_not_confirmed", "Please verify your phone first.");
2972
+ }
2973
+ return authError(400, "validation_failed", "Reauthentication requires the user to have an email or a phone number");
2974
+ }
2975
+ const contact = email ?? phone;
2976
+ const sender = this.config.smsSender;
2977
+ if (!email && !sender) {
2978
+ return authError(422, "phone_provider_disabled", "Phone sign-ins are disabled");
2979
+ }
2980
+ 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);
2981
+ if (limited)
2982
+ return limited;
2983
+ const code = randomOtp(this.settings.otpLength);
2984
+ const tokenDigest = `hmac-sha256:v1:${await keyedDigest(this.config.jwtSecret, "supacloud-lite:reauthentication-otp:v1", `${contact}\x00${code}`)}`;
2985
+ const issuanceId = crypto.randomUUID();
2986
+ await query(`delete from auth.one_time_tokens where user_id = $1 and token_type = 'reauthentication'`, [current.id]);
2987
+ await query(`insert into auth.one_time_tokens (id, user_id, email, phone, token_type, token, expires_at)
2988
+ values ($1, $2, $3, $4, 'reauthentication', $5, now() + $6::interval)`, [issuanceId, current.id, email, phone, tokenDigest, `${this.settings.otpExpirySeconds} seconds`]);
2989
+ const updated = (await query(`update auth.users
2990
+ set reauthentication_token = $1, reauthentication_sent_at = now(), updated_at = now()
2991
+ where id = $2 returning *`, [tokenDigest, current.id])).rows[0];
2992
+ return updated ? { user: updated, email, phone, code, issuanceId, tokenDigest } : null;
2993
+ });
2994
+ if (prepared instanceof Response)
2995
+ return prepared;
2996
+ if (!prepared)
2997
+ return authError(401, "no_authorization", "Invalid or expired token");
2998
+ try {
2999
+ if (prepared.email) {
3000
+ await this.config.mailer.send({
3001
+ to: prepared.email,
3002
+ subject: "Your reauthentication code",
3003
+ text: `Your reauthentication code is ${prepared.code}`
3004
+ });
3005
+ } else {
3006
+ const smsSender = this.config.smsSender;
3007
+ if (!smsSender || !prepared.phone)
3008
+ return authError(422, "phone_provider_disabled", "Phone sign-ins are disabled");
3009
+ await this.sendPhoneOtp(smsSender, prepared.phone, this.settings.smsTemplate.replace(/\{\{\s*\.Code\s*\}\}/g, prepared.code));
3010
+ }
3011
+ } catch {
3012
+ await this.db.query(`delete from auth.one_time_tokens where id = $1 and token = $2`, [prepared.issuanceId, prepared.tokenDigest]);
3013
+ await this.db.query(`update auth.users
3014
+ set reauthentication_token = '', reauthentication_sent_at = null
3015
+ where id = $1 and reauthentication_token = $2`, [prepared.user.id, prepared.tokenDigest]);
3016
+ return authError(502, prepared.email ? "email_provider_failed" : "sms_provider_failed", "Unable to send the verification code");
3017
+ }
3018
+ return json(200, {});
3019
+ }
3020
+ async sessionRequiresReauthentication(claims) {
3021
+ if (!claims?.sub || typeof claims.session_id !== "string" || !claims.session_id)
3022
+ return true;
3023
+ const result = await this.db.query(`select min(created_at) as started_at
3024
+ from auth.refresh_tokens where session_id = $1 and user_id = $2`, [claims.session_id, claims.sub]);
3025
+ const startedAt = timestampMs(result.rows[0]?.started_at ?? null);
3026
+ return startedAt === null || Date.now() - startedAt >= 24 * 60 * 60 * 1000;
3027
+ }
3028
+ async consumeReauthentication(query, user, nonce) {
3029
+ const email = user.email_confirmed_at && user.email ? user.email : null;
3030
+ const phone = !email && user.phone_confirmed_at && user.phone ? user.phone : null;
3031
+ const contact = email ?? phone;
3032
+ if (!contact || !nonce)
3033
+ return false;
3034
+ const tokenDigest = `hmac-sha256:v1:${await keyedDigest(this.config.jwtSecret, "supacloud-lite:reauthentication-otp:v1", `${contact}\x00${nonce}`)}`;
3035
+ const candidate = await query(`select id from auth.one_time_tokens
3036
+ where user_id = $1 and token_type = 'reauthentication'
3037
+ and token = $2 and expires_at > now() and attempts < $3
3038
+ limit 1`, [user.id, tokenDigest, AuthHandler.MAX_OTP_ATTEMPTS]);
3039
+ if (candidate.rows.length === 0) {
3040
+ await query(`update auth.one_time_tokens set attempts = attempts + 1
3041
+ where user_id = $1 and token_type = 'reauthentication' and expires_at > now()`, [user.id]);
3042
+ await query(`delete from auth.one_time_tokens
3043
+ where user_id = $1 and token_type = 'reauthentication' and attempts >= $2`, [user.id, AuthHandler.MAX_OTP_ATTEMPTS]);
3044
+ return false;
3045
+ }
3046
+ const claimed = await query(`delete from auth.one_time_tokens
3047
+ where id = $1 and user_id = $2 and token = $3 and expires_at > now()
3048
+ returning id`, [candidate.rows[0].id, user.id, tokenDigest]);
3049
+ if (claimed.rows.length === 0)
3050
+ return false;
3051
+ const cleared = await query(`update auth.users
3052
+ set reauthentication_token = '', reauthentication_sent_at = null
3053
+ where id = $1 and reauthentication_token = $2
3054
+ returning id`, [user.id, tokenDigest]);
3055
+ return cleared.rows.length > 0;
3056
+ }
3057
+ async requestEmailChange(req, user, newEmail, body) {
3058
+ const url = new URL(req.url);
3059
+ const redirectTo = resolveRedirect(url.searchParams.get("redirect_to"), this.config.siteUrl, this.config.uriAllowList, this.config.enforceRedirectAllowList);
3060
+ const challenge = typeof body.code_challenge === "string" && body.code_challenge ? body.code_challenge : null;
3061
+ const challengeMethod = challenge ? body.code_challenge_method?.toLowerCase() || "s256" : null;
3062
+ const prepared = await this.db.transaction(async (query) => {
3063
+ const currentResult = await query(`select * from auth.users where id = $1 and deleted_at is null
3064
+ and (banned_until is null or banned_until <= now()) for update`, [user.id]);
3065
+ const current = currentResult.rows[0];
3066
+ if (!current)
3067
+ return authError(401, "no_authorization", "Invalid or expired token");
3068
+ if (current.email?.toLowerCase().trim() === newEmail)
3069
+ return json(200, await this.userJsonWithRelations(current, query));
3070
+ const clash = await query(`select id from auth.users where lower(email) = $1 and id <> $2`, [newEmail, current.id]);
3071
+ if (clash.rows.length > 0)
3072
+ return authError(422, "email_exists", "A user with this email address has already been registered");
3073
+ const code = randomOtp(this.settings.otpLength);
3074
+ const linkToken = randomToken(24);
3075
+ await query(`delete from auth.one_time_tokens where user_id = $1 and token_type = 'email_change'`, [current.id]);
3076
+ await query(`insert into auth.one_time_tokens (user_id, email, token_type, token, expires_at)
3077
+ values ($1, $2, 'email_change', $3, now() + $4::interval),
3078
+ ($1, $2, 'email_change', $5, now() + $4::interval)`, [current.id, newEmail, code, `${this.settings.otpExpirySeconds} seconds`, linkToken]);
3079
+ await query(`update auth.users
3080
+ set email_change = $1, email_change_sent_at = now(), email_change_confirm_status = 0, updated_at = now()
3081
+ where id = $2`, [newEmail, current.id]);
3082
+ await query(`delete from auth.flow_state
3083
+ where provider = 'email-change' and user_id = $1 and auth_code is null`, [current.id]);
3084
+ if (challenge) {
3085
+ await query(`insert into auth.flow_state
3086
+ (provider, provider_state, redirect_to, code_challenge, code_challenge_method, user_id, expires_at)
3087
+ values ('email-change', $1, $2, $3, $4, $5, now() + interval '10 minutes')`, [linkToken, redirectTo, challenge, challengeMethod, current.id]);
3088
+ }
3089
+ const updated = (await query(`select * from auth.users where id = $1`, [current.id])).rows[0];
3090
+ if (!updated)
3091
+ return authError(401, "no_authorization", "Invalid or expired token");
3092
+ return { user: updated, linkToken, code, redirectTo };
3093
+ });
3094
+ if (prepared instanceof Response)
3095
+ return prepared;
3096
+ const actionUrl = new URL(`${this.config.apiUrl}/auth/v1/verify`);
3097
+ actionUrl.searchParams.set("token", prepared.linkToken);
3098
+ actionUrl.searchParams.set("type", "email_change");
3099
+ actionUrl.searchParams.set("redirect_to", prepared.redirectTo);
3100
+ await this.config.mailer.send({
3101
+ to: newEmail,
3102
+ subject: "Confirm your email change",
3103
+ text: `Confirm your new email address with this link: ${actionUrl}
3104
+
3105
+ Or use code ${prepared.code}`
3106
+ });
3107
+ return json(200, await this.userJsonWithRelations(prepared.user));
3108
+ }
1636
3109
  async logout(req, url) {
1637
3110
  const claims = await this.claimsFromBearer(req);
1638
3111
  if (claims?.sub) {
@@ -1655,9 +3128,12 @@ class AuthHandler {
1655
3128
  }
1656
3129
  return new Response(null, { status: 204 });
1657
3130
  }
1658
- async issueToken(email, tokenType, createUser, flavor = "login") {
3131
+ async issueToken(email, tokenType, createUser, flavor = "login", metadata = {}, requestedRedirect, codeChallenge, codeChallengeMethod) {
1659
3132
  const normalized = email.toLowerCase().trim();
1660
3133
  const expiry = `${this.settings.otpExpirySeconds} seconds`;
3134
+ const normalizedChallenge = typeof codeChallenge === "string" && codeChallenge ? codeChallenge : null;
3135
+ const normalizedChallengeMethod = normalizedChallenge ? codeChallengeMethod?.toLowerCase() || "s256" : null;
3136
+ const resolvedRedirect = resolveRedirect(requestedRedirect, this.config.siteUrl, this.config.uriAllowList, this.config.enforceRedirectAllowList);
1661
3137
  const prepared = await this.db.transaction(async (query) => {
1662
3138
  let user = (await query(`select * from auth.users where lower(email) = $1 for update`, [normalized])).rows[0];
1663
3139
  if (!user) {
@@ -1666,9 +3142,9 @@ class AuthHandler {
1666
3142
  if (this.settings.disableSignup)
1667
3143
  return { kind: "signup_disabled" };
1668
3144
  const inserted = await query(`insert into auth.users (aud, role, email, raw_app_meta_data, raw_user_meta_data)
1669
- values ('authenticated', 'authenticated', $1, '{"provider":"email","providers":["email"]}', '{}')
3145
+ values ('authenticated', 'authenticated', $1, '{"provider":"email","providers":["email"]}', $2::jsonb)
1670
3146
  on conflict (email) do nothing
1671
- returning *`, [normalized]);
3147
+ returning *`, [normalized, JSON.stringify(metadata)]);
1672
3148
  user = inserted.rows[0];
1673
3149
  if (!user) {
1674
3150
  user = (await query(`select * from auth.users where lower(email) = $1 for update`, [normalized])).rows[0];
@@ -1682,6 +3158,20 @@ class AuthHandler {
1682
3158
  await query(`delete from auth.one_time_tokens where lower(email) = $1 and token_type = any($2::text[])`, [normalized, tokenTypes]);
1683
3159
  await query(`insert into auth.one_time_tokens (user_id, email, token_type, token, expires_at)
1684
3160
  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]);
3161
+ if (normalizedChallenge) {
3162
+ await query(`delete from auth.flow_state
3163
+ where provider in ('email', 'email-recovery') and user_id = $1 and auth_code is null`, [user.id]);
3164
+ await query(`insert into auth.flow_state
3165
+ (provider, provider_state, redirect_to, code_challenge, code_challenge_method, user_id, expires_at)
3166
+ values ($1, $2, $3, $4, $5, $6, now() + interval '10 minutes')`, [
3167
+ tokenType === "recovery" ? "email-recovery" : "email",
3168
+ linkToken,
3169
+ resolvedRedirect,
3170
+ normalizedChallenge,
3171
+ normalizedChallengeMethod,
3172
+ user.id
3173
+ ]);
3174
+ }
1685
3175
  return { kind: "issued", user, code, linkToken };
1686
3176
  });
1687
3177
  if (prepared.kind === "unknown")
@@ -1691,7 +3181,13 @@ class AuthHandler {
1691
3181
  }
1692
3182
  const { user, code, linkToken } = prepared;
1693
3183
  const kind = tokenType === "otp" ? "magiclink" : tokenType;
1694
- const link = `${this.config.apiUrl}/auth/v1/verify?token=${linkToken}&type=${kind}`;
3184
+ const linkUrl = new URL(`${this.config.apiUrl}/auth/v1/verify`);
3185
+ linkUrl.searchParams.set("token", linkToken);
3186
+ linkUrl.searchParams.set("type", kind);
3187
+ if (requestedRedirect) {
3188
+ linkUrl.searchParams.set("redirect_to", resolvedRedirect);
3189
+ }
3190
+ const link = linkUrl.toString();
1695
3191
  await this.config.mailer.send({
1696
3192
  to: normalized,
1697
3193
  subject: tokenType === "recovery" ? "Reset your password" : flavor === "confirm" ? "Confirm your email" : "Your login code",
@@ -1703,25 +3199,90 @@ Or enter the code ${code}` : `Your one-time code is ${code}
1703
3199
 
1704
3200
  Or sign in with this link: ${link}`
1705
3201
  });
1706
- return json(200, {});
1707
- }
1708
- async sendOtp(req) {
1709
- const body = await req.json().catch(() => ({}));
1710
- if (body.email && body.phone)
1711
- return authError(400, "validation_failed", "email and phone are mutually exclusive");
1712
- if (body.phone !== undefined) {
1713
- if (body.channel !== undefined && body.channel !== "sms") {
1714
- return authError(422, "unsupported_channel", "Only the sms phone channel is supported");
1715
- }
1716
- const phone = normalizePhone(body.phone);
1717
- if (!phone)
1718
- return authError(400, "validation_failed", "phone must be a valid E.164 number");
1719
- return this.limit("sms", req) ?? this.issuePhoneToken(phone, body.create_user !== false, recordValue(body.data));
3202
+ return json(200, {});
3203
+ }
3204
+ async sendOtp(req, resend = false) {
3205
+ const body = await req.json().catch(() => ({}));
3206
+ if (body.email && body.phone)
3207
+ return authError(400, "validation_failed", "email and phone are mutually exclusive");
3208
+ if (body.phone !== undefined) {
3209
+ if (body.channel !== undefined && body.channel !== "sms") {
3210
+ return authError(422, "unsupported_channel", "Only the sms phone channel is supported");
3211
+ }
3212
+ const phone = normalizePhone(body.phone);
3213
+ if (!phone)
3214
+ return authError(400, "validation_failed", "phone must be a valid E.164 number");
3215
+ if (resend && body.type === "phone_change") {
3216
+ const user = await this.userFromBearer(req);
3217
+ if (!user || user.phone_change !== phone)
3218
+ return json(200, {});
3219
+ return this.requestPhoneChange(req, user, phone);
3220
+ }
3221
+ return this.limit("sms", req) ?? this.issuePhoneToken(phone, body.create_user !== false, recordValue(body.data));
3222
+ }
3223
+ const email = normalizeEmailInput(body.email);
3224
+ if (!email)
3225
+ return authError(400, "validation_failed", "email or phone is required");
3226
+ if (resend && body.type === "email_change") {
3227
+ return this.resendEmailChange(req, email, body);
3228
+ }
3229
+ const signupResend = resend && body.type === "signup";
3230
+ 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);
3231
+ }
3232
+ async resendEmailChange(req, newEmail, body) {
3233
+ const result = await this.db.query(`select * from auth.users
3234
+ where lower(email_change) = $1 and deleted_at is null
3235
+ and (banned_until is null or banned_until <= now())`, [newEmail]);
3236
+ const user = result.rows[0];
3237
+ if (!user)
3238
+ return json(200, {});
3239
+ return this.requestEmailChange(req, user, newEmail, body);
3240
+ }
3241
+ async requestPhoneChange(req, user, newPhone) {
3242
+ const sender = this.config.smsSender;
3243
+ if (!this.settings.smsEnabled || !sender) {
3244
+ return authError(422, "phone_provider_disabled", "Phone sign-ins are disabled");
3245
+ }
3246
+ const fingerprint = await keyedDigest(this.config.jwtSecret, "supacloud-lite:phone-fingerprint:v1", newPhone);
3247
+ const limited = this.limitKey("sms", `phone:${fingerprint}`) ?? this.limit("sms", req);
3248
+ if (limited)
3249
+ return limited;
3250
+ const token = randomOtp(this.settings.otpLength);
3251
+ const tokenDigest = `hmac-sha256:v1:${await keyedDigest(this.config.jwtSecret, "supacloud-lite:phone-otp:v1", `${newPhone}\x00${token}`)}`;
3252
+ const issuanceId = crypto.randomUUID();
3253
+ const prepared = await this.db.transaction(async (query) => {
3254
+ const currentResult = await query(`select * from auth.users where id = $1 and deleted_at is null
3255
+ and (banned_until is null or banned_until <= now()) for update`, [user.id]);
3256
+ const current = currentResult.rows[0];
3257
+ if (!current)
3258
+ return null;
3259
+ if (current.phone === newPhone)
3260
+ return { user: current, unchanged: true };
3261
+ const clash = await query(`select id from auth.users where phone = $1 and id <> $2`, [newPhone, current.id]);
3262
+ if (clash.rows.length > 0)
3263
+ return { conflict: true };
3264
+ await query(`delete from auth.one_time_tokens where user_id = $1 and token_type = 'phone_change'`, [current.id]);
3265
+ await query(`insert into auth.one_time_tokens (id, user_id, phone, token_type, token, expires_at)
3266
+ values ($1, $2, $3, 'phone_change', $4, now() + $5::interval)`, [issuanceId, current.id, newPhone, tokenDigest, `${this.settings.otpExpirySeconds} seconds`]);
3267
+ const updated = (await query(`update auth.users
3268
+ set phone_change = $1, phone_change_token = $2, phone_change_sent_at = now(), updated_at = now()
3269
+ where id = $3 returning *`, [newPhone, tokenDigest, current.id])).rows[0];
3270
+ return updated ? { user: updated, unchanged: false } : null;
3271
+ });
3272
+ if (!prepared)
3273
+ return authError(401, "no_authorization", "Invalid or expired token");
3274
+ if ("conflict" in prepared)
3275
+ return authError(422, "phone_exists", "A user with this phone number has already been registered");
3276
+ if (prepared.unchanged)
3277
+ return json(200, await this.userJsonWithRelations(prepared.user));
3278
+ try {
3279
+ await this.sendPhoneOtp(sender, newPhone, this.settings.smsTemplate.replace(/\{\{\s*\.Code\s*\}\}/g, token));
3280
+ } catch {
3281
+ await this.db.query(`delete from auth.one_time_tokens where id = $1 and token = $2`, [issuanceId, tokenDigest]);
3282
+ await this.db.query(`update auth.users set phone_change = null, phone_change_token = '', phone_change_sent_at = null where id = $1`, [user.id]);
3283
+ return authError(502, "sms_provider_failed", "Unable to send the verification code");
1720
3284
  }
1721
- const email = normalizeEmailInput(body.email);
1722
- if (!email)
1723
- return authError(400, "validation_failed", "email or phone is required");
1724
- return this.limit("otp", req) ?? this.issueToken(email, "otp", body.create_user !== false);
3285
+ return json(200, await this.userJsonWithRelations(prepared.user));
1725
3286
  }
1726
3287
  async issuePhoneToken(phone, createUser, metadata) {
1727
3288
  const sender = this.config.smsSender;
@@ -1775,7 +3336,7 @@ Or sign in with this link: ${link}`
1775
3336
  const resolved = await this.phoneUser(query, request);
1776
3337
  if (resolved === "unknown" || resolved === "signup_disabled")
1777
3338
  return { state: resolved };
1778
- await this.createPhoneIdentity(query, resolved);
3339
+ await this.ensurePhoneIdentity(resolved, query);
1779
3340
  await query(`delete from auth.one_time_tokens where phone = $1 and token_type = 'sms'`, [request.phone]);
1780
3341
  await query(`insert into auth.one_time_tokens (id, user_id, phone, token_type, token, expires_at)
1781
3342
  values ($1, $2, $3, 'sms', $4, now() + $5::interval)`, [request.issuanceId, resolved.id, request.phone, request.tokenDigest, request.expiry]);
@@ -1819,11 +3380,6 @@ Or sign in with this link: ${link}`
1819
3380
  from auth.users where phone = $1 for update`, [phone]);
1820
3381
  return users.rows[0];
1821
3382
  }
1822
- createPhoneIdentity(query, user) {
1823
- return query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
1824
- values ($1, 'phone', $2, $3::jsonb)
1825
- 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 })]);
1826
- }
1827
3383
  async deliverPhoneOtp(issuance, sender, phone, body) {
1828
3384
  try {
1829
3385
  await this.sendPhoneOtp(sender, phone, body);
@@ -1878,10 +3434,21 @@ Or sign in with this link: ${link}`
1878
3434
  const email = normalizeEmailInput(body.email);
1879
3435
  if (!email)
1880
3436
  return authError(400, "validation_failed", "email is required");
1881
- return this.issueToken(email, "recovery", false);
3437
+ return this.issueToken(email, "recovery", false, "login", {}, new URL(req.url).searchParams.get("redirect_to"), body.code_challenge, body.code_challenge_method);
1882
3438
  }
1883
3439
  static MAX_OTP_ATTEMPTS = 5;
1884
- async redeem(token, types, email) {
3440
+ async redeem(token, types, email, sessionOptions) {
3441
+ const redemption = {
3442
+ token,
3443
+ tokenTypes: `{${types.join(",")}}`,
3444
+ email: email?.toLowerCase().trim() ?? null
3445
+ };
3446
+ return this.db.transaction(async (query) => {
3447
+ const user = await this.claimEmailToken(query, redemption);
3448
+ return user ? this.sessionFor(user, undefined, sessionOptions, query) : null;
3449
+ });
3450
+ }
3451
+ async redeemUser(token, types, email) {
1885
3452
  const redemption = {
1886
3453
  token,
1887
3454
  tokenTypes: `{${types.join(",")}}`,
@@ -1889,7 +3456,7 @@ Or sign in with this link: ${link}`
1889
3456
  };
1890
3457
  return this.db.transaction(async (query) => {
1891
3458
  const user = await this.claimEmailToken(query, redemption);
1892
- return user ? this.sessionFor(user, undefined, undefined, query) : null;
3459
+ return user;
1893
3460
  });
1894
3461
  }
1895
3462
  async claimEmailToken(query, redemption) {
@@ -1941,6 +3508,20 @@ Or sign in with this link: ${link}`
1941
3508
  const token = body.token ?? body.token_hash;
1942
3509
  if (!token)
1943
3510
  return authError(400, "validation_failed", "token or token_hash is required");
3511
+ if (body.type === "phone_change") {
3512
+ const phone = normalizePhone(body.phone);
3513
+ if (!phone || !body.token || !/^\d{6,10}$/.test(body.token)) {
3514
+ return authError(400, "validation_failed", "phone, token, and type=phone_change are required");
3515
+ }
3516
+ const fingerprint = await keyedDigest(this.config.jwtSecret, "supacloud-lite:phone-fingerprint:v1", phone);
3517
+ const limited = this.limitKey("verify", `phone:${fingerprint}`);
3518
+ if (limited)
3519
+ return limited;
3520
+ const session = await this.redeemPhoneChange(phone, body.token);
3521
+ if (!session)
3522
+ return authError(403, "otp_expired", "Token has expired or is invalid");
3523
+ return json(200, session);
3524
+ }
1944
3525
  if (body.type === "sms" || body.phone !== undefined) {
1945
3526
  const phone = normalizePhone(body.phone);
1946
3527
  if (body.type !== "sms" || !phone || !body.token || !/^\d{6,10}$/.test(body.token)) {
@@ -1961,8 +3542,22 @@ Or sign in with this link: ${link}`
1961
3542
  return authError(403, "otp_expired", "Token has expired or is invalid");
1962
3543
  return json(200, session);
1963
3544
  }
1964
- const types = body.type === "recovery" ? ["recovery"] : body.type === "magiclink" ? ["magiclink"] : ["otp", "magiclink"];
1965
- const session = await this.redeem(token, types, body.email);
3545
+ if (body.type === "email_change") {
3546
+ const result = await this.redeemEmailChange(token, body.email);
3547
+ if (!result)
3548
+ return authError(403, "otp_expired", "Token has expired or is invalid");
3549
+ if ("pending" in result) {
3550
+ return json(200, {
3551
+ msg: "Confirmation link accepted. Please proceed to confirm link sent to the other email",
3552
+ code: "200"
3553
+ });
3554
+ }
3555
+ return json(200, result);
3556
+ }
3557
+ const types = body.type === "recovery" ? ["recovery"] : body.type === "magiclink" ? ["magiclink"] : body.type === "signup" || body.type === "invite" ? [body.type] : ["otp", "magiclink"];
3558
+ const session = await this.redeem(token, types, body.email, {
3559
+ amr: [{ method: body.type === "recovery" ? "recovery" : "otp", timestamp: Math.floor(Date.now() / 1000) }]
3560
+ });
1966
3561
  if (!session)
1967
3562
  return authError(403, "otp_expired", "Token has expired or is invalid");
1968
3563
  return json(200, session);
@@ -2010,24 +3605,199 @@ Or sign in with this link: ${link}`
2010
3605
  const user = users.rows[0];
2011
3606
  if (!user)
2012
3607
  return null;
2013
- await query(`insert into auth.identities (user_id, provider, provider_id, identity_data, last_sign_in_at)
2014
- values ($1, 'phone', $2, $3::jsonb, now())
2015
- on conflict (provider, provider_id) do update
2016
- 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 })]);
3608
+ await this.ensurePhoneIdentity(user, query);
3609
+ await query(`update auth.identities
3610
+ set last_sign_in_at = now(), updated_at = now()
3611
+ where user_id = $1 and provider = 'phone' and provider_id = $2`, [user.id, phone]);
2017
3612
  return this.sessionFor(user, undefined, { amr: [{ method: "otp", timestamp: Math.floor(Date.now() / 1000) }] }, query);
2018
3613
  });
2019
3614
  }
3615
+ async redeemPhoneChange(phone, code) {
3616
+ const tokenDigest = `hmac-sha256:v1:${await keyedDigest(this.config.jwtSecret, "supacloud-lite:phone-otp:v1", `${phone}\x00${code}`)}`;
3617
+ return this.db.transaction(async (query) => {
3618
+ const candidate = (await query(`select id, user_id from auth.one_time_tokens
3619
+ where phone = $1 and token_type = 'phone_change' and token = $2 and expires_at > now()
3620
+ limit 1`, [phone, tokenDigest])).rows[0];
3621
+ if (!candidate)
3622
+ return null;
3623
+ const currentResult = await query(`select * from auth.users where id = $1 and phone_change = $2 and deleted_at is null
3624
+ and (banned_until is null or banned_until <= now()) for update`, [candidate.user_id, phone]);
3625
+ const current = currentResult.rows[0];
3626
+ if (!current)
3627
+ return null;
3628
+ const clash = await query(`select id from auth.users where phone = $1 and id <> $2`, [phone, current.id]);
3629
+ if (clash.rows.length > 0)
3630
+ return null;
3631
+ const claimed = await query(`delete from auth.one_time_tokens where id = $1 and token = $2 returning id`, [candidate.id, tokenDigest]);
3632
+ if (claimed.rows.length === 0)
3633
+ return null;
3634
+ await query(`delete from auth.identities where user_id = $1 and provider = 'phone'`, [current.id]);
3635
+ const updated = (await query(`update auth.users
3636
+ set phone = $1, phone_confirmed_at = now(),
3637
+ phone_change = null, phone_change_token = '', phone_change_sent_at = null,
3638
+ updated_at = now()
3639
+ where id = $2 and deleted_at is null
3640
+ and (banned_until is null or banned_until <= now())
3641
+ returning *`, [phone, current.id])).rows[0];
3642
+ if (!updated)
3643
+ return null;
3644
+ await this.ensurePhoneIdentity(updated, query);
3645
+ return this.sessionFor(updated, undefined, { amr: [{ method: "otp", timestamp: Math.floor(Date.now() / 1000) }] }, query);
3646
+ });
3647
+ }
2020
3648
  async verifyLink(url) {
2021
3649
  const token = url.searchParams.get("token") ?? "";
2022
3650
  const type = url.searchParams.get("type") ?? "magiclink";
2023
3651
  const redirectTo = resolveRedirect(url.searchParams.get("redirect_to"), this.config.siteUrl, this.config.uriAllowList, this.config.enforceRedirectAllowList);
2024
- const session = await this.redeem(token, [type]);
3652
+ const flow = (await this.db.query(`select id, provider, redirect_to, code_challenge, code_challenge_method
3653
+ from auth.flow_state
3654
+ where provider in ('email', 'email-recovery', 'email-change')
3655
+ and provider_state = $1 and auth_code is null and expires_at > now()`, [token])).rows[0];
3656
+ const emailChange = type === "email_change" ? await this.redeemEmailChangeUser(token) : null;
3657
+ if (emailChange === "pending") {
3658
+ return redirectWithQuery2(redirectTo, {
3659
+ message: "Confirmation link accepted. Please proceed to confirm link sent to the other email"
3660
+ });
3661
+ }
3662
+ let user = null;
3663
+ let session = null;
3664
+ if (type === "email_change") {
3665
+ user = emailChange;
3666
+ } else if (flow) {
3667
+ user = await this.redeemUser(token, [type]);
3668
+ } else {
3669
+ session = await this.redeem(token, [type], undefined, {
3670
+ amr: [{ method: type === "recovery" ? "recovery" : "otp", timestamp: Math.floor(Date.now() / 1000) }]
3671
+ });
3672
+ }
3673
+ if (type === "email_change" || flow) {
3674
+ if (!user) {
3675
+ return new Response(null, { status: 303, headers: { location: redirectTo + "#error=access_denied&error_code=otp_expired" } });
3676
+ }
3677
+ if (flow) {
3678
+ const authCode = randomToken(24);
3679
+ const claimed = await this.db.query(`update auth.flow_state
3680
+ set auth_code = $1, user_id = $2, expires_at = now() + interval '5 minutes'
3681
+ where id = $3 and auth_code is null and expires_at > now()
3682
+ returning redirect_to`, [authCode, user.id, flow.id]);
3683
+ const target = claimed.rows[0]?.redirect_to;
3684
+ if (target)
3685
+ return redirectWithQuery2(target, { code: authCode });
3686
+ }
3687
+ session = await this.sessionFor(user, undefined, {
3688
+ amr: [{ method: type === "recovery" ? "recovery" : "otp", timestamp: Math.floor(Date.now() / 1000) }]
3689
+ });
3690
+ }
2025
3691
  if (!session) {
2026
- return new Response(null, { status: 303, headers: { location: `${redirectTo}#error=access_denied&error_code=otp_expired` } });
3692
+ return new Response(null, { status: 303, headers: { location: redirectTo + "#error=access_denied&error_code=otp_expired" } });
2027
3693
  }
2028
3694
  const hash = `#access_token=${session.access_token}&refresh_token=${session.refresh_token}&expires_in=${session.expires_in}&token_type=bearer&type=${type}`;
2029
3695
  return new Response(null, { status: 303, headers: { location: `${redirectTo}${hash}` } });
2030
3696
  }
3697
+ async exchangeEmailPkce(authCode, verifier) {
3698
+ return this.db.transaction(async (query) => {
3699
+ const flow = (await query(`select id, provider, redirect_to, code_challenge, code_challenge_method
3700
+ from auth.flow_state
3701
+ where provider in ('email', 'email-recovery', 'email-change')
3702
+ and auth_code = $1 and expires_at > now()`, [authCode])).rows[0];
3703
+ if (!flow)
3704
+ return null;
3705
+ if (flow.code_challenge_method === "s256" || flow.code_challenge_method === "S256") {
3706
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
3707
+ const challenge = base64url2(new Uint8Array(digest));
3708
+ if (challenge !== flow.code_challenge)
3709
+ return null;
3710
+ } else if (flow.code_challenge !== verifier) {
3711
+ return null;
3712
+ }
3713
+ const claimed = await query(`delete from auth.flow_state where id = $1 and auth_code = $2 and expires_at > now()
3714
+ returning user_id`, [flow.id, authCode]);
3715
+ const userId = claimed.rows[0]?.user_id;
3716
+ return userId ? { userId, provider: flow.provider } : null;
3717
+ });
3718
+ }
3719
+ async redeemEmailChangeUser(token, email) {
3720
+ return this.db.transaction((query) => this.claimEmailChange(query, token, email));
3721
+ }
3722
+ async redeemEmailChange(token, email) {
3723
+ return this.db.transaction(async (query) => {
3724
+ const user = await this.claimEmailChange(query, token, email);
3725
+ if (user === "pending")
3726
+ return { pending: true };
3727
+ return user ? this.sessionFor(user, undefined, {
3728
+ amr: [{ method: "otp", timestamp: Math.floor(Date.now() / 1000) }]
3729
+ }, query) : null;
3730
+ });
3731
+ }
3732
+ async claimEmailChange(query, token, email) {
3733
+ const candidate = (await query(`select id, user_id, email, token_type from auth.one_time_tokens
3734
+ where token = $1 and token_type in ('email_change', 'email_change_current', 'email_change_new')
3735
+ and expires_at > now()
3736
+ and ($2::text is null or lower(email) = lower($2))
3737
+ limit 1`, [token, email?.toLowerCase().trim() ?? null])).rows[0];
3738
+ if (!candidate)
3739
+ return null;
3740
+ const currentResult = await query(`select * from auth.users where id = $1 and email_change is not null and deleted_at is null
3741
+ and (banned_until is null or banned_until <= now()) for update`, [candidate.user_id]);
3742
+ const current = currentResult.rows[0];
3743
+ if (!current)
3744
+ return null;
3745
+ 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())
3746
+ return null;
3747
+ const targetEmail = candidate.token_type === "email_change_current" ? current.email_change : candidate.email;
3748
+ if (!targetEmail)
3749
+ return null;
3750
+ const clash = await query(`select id from auth.users where lower(email) = lower($1) and id <> $2`, [targetEmail, candidate.user_id]);
3751
+ if (clash.rows.length > 0)
3752
+ return null;
3753
+ const needsSecondConfirmation = candidate.token_type !== "email_change";
3754
+ if (needsSecondConfirmation && (current.email_change_confirm_status ?? 0) === 0) {
3755
+ const deleted = await query(`delete from auth.one_time_tokens
3756
+ where user_id = $1 and token_type = $2 returning id`, [candidate.user_id, candidate.token_type]);
3757
+ if (deleted.rows.length === 0)
3758
+ return null;
3759
+ const updated = (await query(`update auth.users
3760
+ set email_change_confirm_status = 1,
3761
+ email_change_token_current = case when $1 = 'email_change_current' then '' else email_change_token_current end,
3762
+ email_change_token_new = case when $1 = 'email_change_new' then '' else email_change_token_new end,
3763
+ updated_at = now()
3764
+ where id = $2
3765
+ returning *`, [candidate.token_type, candidate.user_id])).rows[0];
3766
+ return updated ? "pending" : null;
3767
+ }
3768
+ const deleted = await query(`delete from auth.one_time_tokens where user_id = $1
3769
+ and token_type = $2 returning id`, [candidate.user_id, candidate.token_type]);
3770
+ if (deleted.rows.length === 0)
3771
+ return null;
3772
+ const updated = (await query(`update auth.users
3773
+ set email = $1, email_confirmed_at = now(),
3774
+ email_change = null, email_change_sent_at = null,
3775
+ email_change_token_current = '', email_change_token_new = '',
3776
+ email_change_confirm_status = 0,
3777
+ is_anonymous = false,
3778
+ raw_app_meta_data = case when is_anonymous
3779
+ then coalesce(raw_app_meta_data, '{}'::jsonb) || '{"provider":"email","providers":["email"]}'::jsonb
3780
+ else raw_app_meta_data end,
3781
+ updated_at = now()
3782
+ where id = $2
3783
+ returning *`, [targetEmail, candidate.user_id])).rows[0];
3784
+ if (!updated)
3785
+ return null;
3786
+ await this.ensureEmailIdentity(updated, query);
3787
+ return updated;
3788
+ }
3789
+ async sessionForEmailPkce(userId, provider) {
3790
+ return this.db.transaction(async (query) => {
3791
+ const result = await query(`select * from auth.users where id = $1 and deleted_at is null
3792
+ and (banned_until is null or banned_until <= now()) for update`, [userId]);
3793
+ const user = result.rows[0];
3794
+ if (!user)
3795
+ throw new OAuthAccountIneligibleError("user_not_found");
3796
+ return this.sessionFor(user, undefined, {
3797
+ amr: [{ method: provider === "email-recovery" ? "recovery" : "otp", timestamp: Math.floor(Date.now() / 1000) }]
3798
+ }, query);
3799
+ });
3800
+ }
2031
3801
  async admin(req, ctx, path, method) {
2032
3802
  if (ctx.role !== "service_role") {
2033
3803
  return authError(403, "insufficient_permissions", "Admin endpoints require the service_role key");
@@ -2060,38 +3830,96 @@ Or sign in with this link: ${link}`
2060
3830
  return json(200, { id: factorMatch[2] });
2061
3831
  }
2062
3832
  if (path === "admin/users" && method === "GET") {
2063
- const res = await this.db.query(`select * from auth.users order by created_at desc limit 1000`);
2064
- return json(200, { users: res.rows.map((user) => this.userJson(user)), aud: "authenticated" });
3833
+ const requestUrl = new URL(req.url);
3834
+ const pageValue = requestUrl.searchParams.get("page");
3835
+ const perPageValue = requestUrl.searchParams.get("per_page");
3836
+ const page = pageValue === null ? 1 : Number(pageValue);
3837
+ const perPage = perPageValue === null ? 50 : Number(perPageValue);
3838
+ if (!Number.isInteger(page) || page < 1 || !Number.isInteger(perPage) || perPage < 1 || perPage > 1000) {
3839
+ return authError(400, "validation_failed", "page must be a positive integer and per_page must be between 1 and 1000");
3840
+ }
3841
+ const total = Number((await this.db.query(`select count(*)::int as count from auth.users`)).rows[0]?.count ?? 0);
3842
+ 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]);
3843
+ const lastPage = Math.max(1, Math.ceil(total / perPage));
3844
+ const linkUrl = (targetPage) => {
3845
+ const target = new URL(requestUrl);
3846
+ target.searchParams.set("page", String(targetPage));
3847
+ target.searchParams.set("per_page", String(perPage));
3848
+ return target.toString();
3849
+ };
3850
+ const links = [
3851
+ `<${linkUrl(1)}>; rel="first"`,
3852
+ `<${linkUrl(lastPage)}>; rel="last"`,
3853
+ ...page > 1 ? [`<${linkUrl(page - 1)}>; rel="prev"`] : [],
3854
+ ...page < lastPage ? [`<${linkUrl(page + 1)}>; rel="next"`] : []
3855
+ ];
3856
+ return json(200, { users: res.rows.map((user) => this.userJson(user)), aud: "authenticated" }, { "x-total-count": String(total), link: links.join(", ") });
2065
3857
  }
2066
3858
  if (path === "admin/users" && method === "POST") {
2067
3859
  const body = await req.json().catch(() => ({}));
2068
3860
  const email = normalizeEmailInput(body.email);
2069
- if (!email)
2070
- return authError(400, "validation_failed", "email is required");
3861
+ if (body.email !== undefined && !email)
3862
+ return authError(400, "validation_failed", "email must not be empty");
3863
+ const phone = body.phone === undefined ? null : normalizePhone(body.phone);
3864
+ if (body.phone !== undefined && !phone) {
3865
+ return authError(400, "validation_failed", "phone must be a valid E.164 number");
3866
+ }
3867
+ if (!email && !phone)
3868
+ return authError(400, "validation_failed", "email or phone is required");
3869
+ if (body.phone_confirm !== undefined && typeof body.phone_confirm !== "boolean") {
3870
+ return authError(400, "validation_failed", "phone_confirm must be a boolean");
3871
+ }
3872
+ const banSeconds = body.ban_duration === undefined ? null : banDurationSeconds(body.ban_duration);
3873
+ if (banSeconds === undefined)
3874
+ return authError(400, "validation_failed", "ban_duration is invalid");
2071
3875
  const hashed = body.password ? await hashPassword(body.password) : null;
2072
3876
  const created = await this.db.transaction(async (query) => {
2073
- const existing = await query(`select id from auth.users where lower(email) = $1 for update`, [email.toLowerCase().trim()]);
2074
- if (existing.rows.length > 0)
2075
- return null;
3877
+ if (email) {
3878
+ const existing = await query(`select id from auth.users where lower(email) = $1 for update`, [email]);
3879
+ if (existing.rows.length > 0)
3880
+ return { error: "email_exists" };
3881
+ }
3882
+ if (phone) {
3883
+ const phoneExisting = await query(`select id from auth.users where phone = $1 for update`, [phone]);
3884
+ if (phoneExisting.rows.length > 0)
3885
+ return { error: "phone_exists" };
3886
+ }
2076
3887
  const res = await query(`insert into auth.users
2077
- (aud, role, email, encrypted_password, email_confirmed_at, raw_app_meta_data, raw_user_meta_data)
2078
- values ('authenticated', 'authenticated', $1, $2, case when $3 then now() else null end, $4, $5)
2079
- on conflict (email) do nothing
3888
+ (aud, role, email, encrypted_password, email_confirmed_at, phone, phone_confirmed_at,
3889
+ banned_until, raw_app_meta_data, raw_user_meta_data)
3890
+ values ('authenticated', 'authenticated', $1, $2, case when $3 then now() else null end,
3891
+ $4, case when $5 then now() else null end,
3892
+ case when $6::double precision is null then null
3893
+ else now() + ($6::double precision * interval '1 second') end, $7, $8)
3894
+ on conflict do nothing
2080
3895
  returning *`, [
2081
- email.toLowerCase().trim(),
3896
+ email,
2082
3897
  hashed,
2083
- body.email_confirm ?? true,
2084
- JSON.stringify({ provider: "email", providers: ["email"], ...body.app_metadata ?? {} }),
3898
+ body.email_confirm ?? false,
3899
+ phone,
3900
+ body.phone_confirm === true,
3901
+ banSeconds,
3902
+ JSON.stringify({
3903
+ provider: email ? "email" : "phone",
3904
+ providers: email && phone ? ["email", "phone"] : [email ? "email" : "phone"],
3905
+ ...body.app_metadata ?? {}
3906
+ }),
2085
3907
  JSON.stringify(body.user_metadata ?? {})
2086
3908
  ]);
2087
3909
  const user = res.rows[0];
2088
3910
  if (!user)
2089
3911
  return null;
2090
- await this.ensureEmailIdentity(user, query);
3912
+ if (email)
3913
+ await this.ensureEmailIdentity(user, query);
3914
+ if (phone)
3915
+ await this.ensurePhoneIdentity(user, query);
2091
3916
  return this.userJsonWithRelations(user, query);
2092
3917
  });
2093
3918
  if (!created)
2094
3919
  return authError(422, "email_exists", "A user with this email address has already been registered");
3920
+ if ("error" in created) {
3921
+ 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");
3922
+ }
2095
3923
  return json(200, created);
2096
3924
  }
2097
3925
  if (idMatch && method === "GET") {
@@ -2115,6 +3943,32 @@ Or sign in with this link: ${link}`
2115
3943
  params.push(await hashPassword(body.password));
2116
3944
  sets.push(`encrypted_password = $${params.length}`);
2117
3945
  }
3946
+ const requestedPhone = body.phone === undefined ? undefined : normalizePhone(body.phone);
3947
+ if (body.phone !== undefined && !requestedPhone) {
3948
+ return authError(400, "validation_failed", "phone must be a valid E.164 number");
3949
+ }
3950
+ if (body.phone_confirm !== undefined && typeof body.phone_confirm !== "boolean") {
3951
+ return authError(400, "validation_failed", "phone_confirm must be a boolean");
3952
+ }
3953
+ if (body.ban_duration !== undefined) {
3954
+ const seconds = banDurationSeconds(body.ban_duration);
3955
+ if (seconds === undefined)
3956
+ return authError(400, "validation_failed", "ban_duration is invalid");
3957
+ if (seconds === null) {
3958
+ sets.push("banned_until = null");
3959
+ } else {
3960
+ params.push(seconds);
3961
+ sets.push(`banned_until = now() + ($${params.length} * interval '1 second')`);
3962
+ }
3963
+ }
3964
+ if (requestedPhone) {
3965
+ params.push(requestedPhone);
3966
+ sets.push(`phone = $${params.length}`);
3967
+ }
3968
+ if (body.phone_confirm === true)
3969
+ sets.push("phone_confirmed_at = now()");
3970
+ if (body.phone_confirm === false)
3971
+ sets.push("phone_confirmed_at = null");
2118
3972
  if (body.user_metadata) {
2119
3973
  params.push(JSON.stringify(body.user_metadata));
2120
3974
  sets.push(`raw_user_meta_data = $${params.length}::jsonb`);
@@ -2124,12 +3978,15 @@ Or sign in with this link: ${link}`
2124
3978
  sets.push(`raw_app_meta_data = coalesce(raw_app_meta_data, '{}'::jsonb) || $${params.length}::jsonb`);
2125
3979
  }
2126
3980
  if (body.email_confirm === true)
2127
- sets.push(`email_confirmed_at = now()`);
3981
+ sets.push("email_confirmed_at = now()");
3982
+ if (body.email_confirm === false)
3983
+ sets.push("email_confirmed_at = null");
2128
3984
  if (sets.length === 0)
2129
3985
  return authError(400, "validation_failed", "nothing to update");
2130
3986
  params.push(idMatch[1]);
2131
3987
  let updated;
2132
3988
  let emailConflict = false;
3989
+ let phoneConflict = false;
2133
3990
  try {
2134
3991
  updated = await this.db.transaction(async (query) => {
2135
3992
  if (requestedEmail) {
@@ -2139,77 +3996,157 @@ Or sign in with this link: ${link}`
2139
3996
  return null;
2140
3997
  }
2141
3998
  }
3999
+ if (requestedPhone) {
4000
+ const clash = await query(`select id from auth.users where phone = $1 and id <> $2`, [requestedPhone, idMatch[1]]);
4001
+ if (clash.rows.length > 0) {
4002
+ phoneConflict = true;
4003
+ return null;
4004
+ }
4005
+ }
2142
4006
  const res = await query(`update auth.users set ${sets.join(", ")}, updated_at = now() where id = $${params.length} returning *`, params);
2143
4007
  const user = res.rows[0];
2144
4008
  if (!user)
2145
4009
  return null;
2146
4010
  if (requestedEmail)
2147
4011
  await this.ensureEmailIdentity(user, query);
4012
+ if (requestedPhone || body.phone_confirm !== undefined) {
4013
+ if (requestedPhone) {
4014
+ await query(`delete from auth.identities
4015
+ where user_id = $1 and provider = 'phone' and provider_id <> $2`, [user.id, requestedPhone]);
4016
+ }
4017
+ await this.ensurePhoneIdentity(user, query);
4018
+ }
2148
4019
  return this.userJsonWithRelations(user, query);
2149
4020
  });
2150
4021
  } catch (error) {
2151
4022
  if (databaseDiagnosticCode(error) === "23505" && requestedEmail) {
2152
4023
  return authError(422, "email_exists", "A user with this email address has already been registered");
2153
4024
  }
4025
+ if (databaseDiagnosticCode(error) === "23505" && requestedPhone) {
4026
+ return authError(422, "phone_exists", "A user with this phone number has already been registered");
4027
+ }
2154
4028
  throw error;
2155
4029
  }
2156
4030
  if (emailConflict)
2157
4031
  return authError(422, "email_exists", "A user with this email address has already been registered");
4032
+ if (phoneConflict)
4033
+ return authError(422, "phone_exists", "A user with this phone number has already been registered");
2158
4034
  if (!updated)
2159
4035
  return authError(404, "user_not_found", "User not found");
2160
4036
  return json(200, updated);
2161
4037
  }
2162
4038
  if (idMatch && method === "DELETE") {
2163
- return await this.eraseUser(idMatch[1]);
4039
+ return await this.eraseUser(idMatch[1], req);
2164
4040
  }
2165
4041
  return authError(404, "not_found", `unknown admin endpoint`);
2166
4042
  }
2167
- async generateAdminMagicLink(req) {
4043
+ async generateAdminMagicLink(req, sendEmail = false, forcedType) {
2168
4044
  const body = await req.json().catch(() => ({}));
2169
- if (body.type !== "magiclink") {
2170
- return authError(422, "unsupported_link_type", "Only magiclink generation is supported");
4045
+ if (forcedType)
4046
+ body.type = forcedType;
4047
+ if (!["signup", "invite", "magiclink", "recovery", "email_change_current", "email_change_new"].includes(body.type ?? "")) {
4048
+ return authError(422, "unsupported_link_type", "Unsupported email action link type");
2171
4049
  }
2172
4050
  const email = normalizeEmailInput(body.email);
2173
4051
  if (!email)
2174
4052
  return authError(400, "validation_failed", "email is required");
4053
+ const newEmail = body.new_email === undefined ? null : normalizeEmailInput(body.new_email);
4054
+ if (body.type === "email_change_current" || body.type === "email_change_new") {
4055
+ if (!newEmail)
4056
+ return authError(400, "validation_failed", "new_email is required");
4057
+ if (newEmail === email)
4058
+ return authError(422, "validation_failed", "new_email must differ from email");
4059
+ }
4060
+ if (body.type === "signup") {
4061
+ if (!body.password) {
4062
+ return authError(400, "validation_failed", "password is required for signup links");
4063
+ }
4064
+ if (body.password.length < this.settings.minPasswordLength) {
4065
+ return authError(422, "weak_password", `Password should be at least ${this.settings.minPasswordLength} characters.`);
4066
+ }
4067
+ }
2175
4068
  const emailOtp = randomOtp(this.settings.otpLength);
2176
4069
  const hashedToken = await sha256Hex(randomToken(32));
2177
4070
  const expiry = `${this.settings.otpExpirySeconds} seconds`;
4071
+ const passwordHash = body.type === "signup" ? await hashPassword(body.password) : null;
2178
4072
  const requestUrl = new URL(req.url);
2179
4073
  const redirectTo = resolveRedirect(requestUrl.searchParams.get("redirect_to") ?? body.redirect_to, this.config.siteUrl, this.config.uriAllowList, this.config.enforceRedirectAllowList);
2180
4074
  const user = await this.db.transaction(async (query) => {
2181
4075
  const existing = await query(`select * from auth.users where lower(email) = $1 for update`, [email]);
2182
4076
  let linkedUser = existing.rows[0];
4077
+ if (!linkedUser && (body.type === "recovery" || body.type === "email_change_current" || body.type === "email_change_new")) {
4078
+ return null;
4079
+ }
4080
+ if (linkedUser && (body.type === "signup" || body.type === "invite")) {
4081
+ return { error: "email_exists" };
4082
+ }
4083
+ if (linkedUser && (body.type === "email_change_current" || body.type === "email_change_new")) {
4084
+ const duplicate = await query(`select id from auth.users where lower(email) = $1 and id <> $2`, [newEmail, linkedUser.id]);
4085
+ if (duplicate.rows.length > 0)
4086
+ return { error: "email_exists" };
4087
+ const tokenType = body.type;
4088
+ await query(`delete from auth.one_time_tokens where user_id = $1 and token_type = $2`, [linkedUser.id, tokenType]);
4089
+ await query(`insert into auth.one_time_tokens (user_id, email, token_type, token, expires_at)
4090
+ values ($1, $2, $3, $4, now() + $5::interval),
4091
+ ($1, $2, $3, $6, now() + $5::interval)`, [linkedUser.id, tokenType === "email_change_current" ? email : newEmail, tokenType, emailOtp, expiry, hashedToken]);
4092
+ const tokenColumn = tokenType === "email_change_current" ? "email_change_token_current" : "email_change_token_new";
4093
+ await query(`update auth.users
4094
+ set email_change = $1, email_change_sent_at = now(), email_change_confirm_status = 0,
4095
+ ${tokenColumn} = $2, updated_at = now()
4096
+ where id = $3`, [newEmail, hashedToken, linkedUser.id]);
4097
+ return (await query(`select * from auth.users where id = $1`, [linkedUser.id])).rows[0];
4098
+ }
2183
4099
  if (!linkedUser) {
2184
4100
  const result = await query(`insert into auth.users
2185
- (aud, role, email, raw_app_meta_data, raw_user_meta_data)
2186
- values ('authenticated', 'authenticated', $1, '{"provider":"email","providers":["email"]}', $2::jsonb)
4101
+ (aud, role, email, encrypted_password, invited_at, raw_app_meta_data, raw_user_meta_data)
4102
+ values ('authenticated', 'authenticated', $1, $2, case when $3 then now() else null end,
4103
+ '{"provider":"email","providers":["email"]}', $4::jsonb)
2187
4104
  on conflict (email) do update set email = excluded.email
2188
- returning *`, [email, JSON.stringify(body.data ?? {})]);
4105
+ returning *`, [email, passwordHash, body.type === "invite", JSON.stringify(body.data ?? {})]);
2189
4106
  linkedUser = result.rows[0];
2190
4107
  }
2191
- await query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
2192
- values ($1::uuid, 'email', $1::text, $2::jsonb)
2193
- on conflict (provider, provider_id) do nothing`, [linkedUser.id, JSON.stringify({ sub: linkedUser.id, email })]);
4108
+ await this.ensureEmailIdentity(linkedUser, query);
2194
4109
  await query(`delete from auth.one_time_tokens
2195
- where lower(email) = $1 and token_type = any($2::text[])`, [email, "{otp,magiclink}"]);
4110
+ where lower(email) = $1 and token_type = any($2::text[])`, [email, body.type === "magiclink" ? "{otp,magiclink}" : `{${body.type}}`]);
2196
4111
  await query(`insert into auth.one_time_tokens (user_id, email, token_type, token, expires_at)
2197
- values ($1, $2, 'otp', $3, now() + $5::interval),
2198
- ($1, $2, 'magiclink', $4, now() + $5::interval)`, [linkedUser.id, email, emailOtp, hashedToken, expiry]);
4112
+ values ($1, $2, $3, $4, now() + $7::interval),
4113
+ ($1, $2, $5, $6, now() + $7::interval)`, [
4114
+ linkedUser.id,
4115
+ email,
4116
+ body.type === "magiclink" ? "otp" : body.type,
4117
+ emailOtp,
4118
+ body.type === "magiclink" ? "magiclink" : body.type,
4119
+ hashedToken,
4120
+ expiry
4121
+ ]);
2199
4122
  return linkedUser;
2200
4123
  });
4124
+ if (!user)
4125
+ return authError(404, "user_not_found", "User not found");
4126
+ if ("error" in user)
4127
+ return authError(422, user.error, "A user with this email address has already been registered");
2201
4128
  const actionUrl = new URL(`${this.config.apiUrl}/auth/v1/verify`);
2202
4129
  actionUrl.searchParams.set("token", hashedToken);
2203
- actionUrl.searchParams.set("type", "magiclink");
4130
+ actionUrl.searchParams.set("type", body.type === "email_change_current" || body.type === "email_change_new" ? "email_change" : body.type);
2204
4131
  actionUrl.searchParams.set("redirect_to", redirectTo);
2205
- await this.audit("user_magiclink_requested", { actorId: user.id, actorEmail: email, type: "admin" });
4132
+ if (sendEmail) {
4133
+ await this.audit("user_invited", { actorId: user.id, actorEmail: email, type: "admin" });
4134
+ await this.config.mailer.send({
4135
+ to: email,
4136
+ subject: "You have been invited",
4137
+ text: `Accept your invitation with this link: ${actionUrl}`
4138
+ });
4139
+ return json(200, { user: this.userJson(user, [], await this.getUserIdentities(user.id)) });
4140
+ }
4141
+ 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";
4142
+ await this.audit(auditAction, { actorId: user.id, actorEmail: email, type: "admin" });
2206
4143
  return json(200, {
2207
4144
  ...this.userJson(user, [], await this.getUserIdentities(user.id)),
2208
4145
  action_link: actionUrl.toString(),
2209
4146
  email_otp: emailOtp,
2210
4147
  hashed_token: hashedToken,
2211
4148
  redirect_to: redirectTo,
2212
- verification_type: "magiclink"
4149
+ verification_type: body.type
2213
4150
  });
2214
4151
  }
2215
4152
  async audit(action, opts = {}) {
@@ -2256,17 +4193,46 @@ Or sign in with this link: ${link}`
2256
4193
  mfa_factors: factors.rows
2257
4194
  });
2258
4195
  }
2259
- async eraseUser(userId) {
4196
+ async eraseUser(userId, req) {
4197
+ const body = await req.json().catch(() => ({}));
4198
+ const shouldSoftDelete = body.should_soft_delete === true;
4199
+ const userResult = await this.db.query(`select * from auth.users where id = $1`, [userId]);
4200
+ const user = userResult.rows[0];
4201
+ if (!user)
4202
+ return authError(404, "user_not_found", "User not found");
2260
4203
  const before = await this.db.query(`select
2261
4204
  (select count(*) from auth.identities where user_id = $1)::int as identities,
2262
4205
  (select count(*) from auth.refresh_tokens where user_id = $1)::int as sessions,
2263
4206
  (select count(*) from auth.mfa_factors where user_id = $1)::int as factors`, [userId]);
4207
+ if (shouldSoftDelete) {
4208
+ const deleted = await this.db.transaction(async (query) => {
4209
+ const updated = await query(`update auth.users set deleted_at = coalesce(deleted_at, now()), updated_at = now()
4210
+ where id = $1 returning *`, [userId]);
4211
+ if (updated.rows.length === 0)
4212
+ return null;
4213
+ await query(`update auth.refresh_tokens set revoked = true, updated_at = now() where user_id = $1`, [userId]);
4214
+ await query(`delete from auth.one_time_tokens where user_id = $1`, [userId]);
4215
+ await query(`delete from auth.flow_state where user_id = $1`, [userId]);
4216
+ return updated.rows[0];
4217
+ });
4218
+ if (!deleted)
4219
+ return authError(404, "user_not_found", "User not found");
4220
+ await this.audit("user_deleted", { actorId: userId, type: "admin", traits: { erased: false, soft_deleted: true } });
4221
+ const c = before.rows[0];
4222
+ return json(200, {
4223
+ user: this.userJson(deleted),
4224
+ erased: false,
4225
+ user_id: userId,
4226
+ cascaded: { identities: c.identities, sessions: c.sessions, mfa_factors: c.factors }
4227
+ });
4228
+ }
2264
4229
  const del = await this.db.query(`delete from auth.users where id = $1 returning id`, [userId]);
2265
4230
  if (del.rows.length === 0)
2266
4231
  return authError(404, "user_not_found", "User not found");
2267
4232
  const c = before.rows[0];
2268
4233
  await this.audit("user_deleted", { actorId: userId, type: "admin", traits: { erased: true } });
2269
4234
  return json(200, {
4235
+ user: this.userJson(user),
2270
4236
  erased: true,
2271
4237
  user_id: userId,
2272
4238
  cascaded: { identities: c.identities, sessions: c.sessions, mfa_factors: c.factors }
@@ -2336,9 +4302,12 @@ Or sign in with this link: ${link}`
2336
4302
  and (banned_until is null or banned_until <= now()) for update`, [user.id]);
2337
4303
  if (!currentResult.rows[0])
2338
4304
  return authError(401, "no_authorization", "Invalid or expired token");
2339
- const fr = await query(`select id from auth.mfa_factors where id = $1 and user_id = $2`, [factorId, user.id]);
4305
+ const fr = await query(`select id, factor_type from auth.mfa_factors where id = $1 and user_id = $2`, [factorId, user.id]);
2340
4306
  if (fr.rows.length === 0)
2341
4307
  return authError(404, "mfa_factor_not_found", "MFA factor not found");
4308
+ if (fr.rows[0].factor_type === "recovery_code") {
4309
+ return authError(422, "validation_failed", "Recovery code factors use the recovery-codes API");
4310
+ }
2342
4311
  const ins = await query(`insert into auth.mfa_challenges (factor_id, expires_at) values ($1, $2) returning id, expires_at`, [factorId, expiresAt]);
2343
4312
  const c = ins.rows[0];
2344
4313
  return json(200, {
@@ -2356,10 +4325,13 @@ Or sign in with this link: ${link}`
2356
4325
  return authError(422, "mfa_totp_verify_disabled", "TOTP verification is disabled");
2357
4326
  }
2358
4327
  const body = await req.json().catch(() => ({}));
2359
- const fr = await this.db.query(`select id, secret, status from auth.mfa_factors where id = $1 and user_id = $2`, [factorId, user.id]);
4328
+ 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]);
2360
4329
  const factor = fr.rows[0];
2361
4330
  if (!factor)
2362
4331
  return authError(404, "mfa_factor_not_found", "MFA factor not found");
4332
+ if (factor.factor_type === "recovery_code") {
4333
+ return authError(422, "validation_failed", "Recovery code factors use the recovery-codes API");
4334
+ }
2363
4335
  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]);
2364
4336
  const challenge = cr.rows[0];
2365
4337
  if (!challenge)
@@ -2379,10 +4351,10 @@ Or sign in with this link: ${link}`
2379
4351
  const currentUser = currentUsers.rows[0];
2380
4352
  if (!currentUser)
2381
4353
  return null;
2382
- const currentFactors = await query(`select id, secret, status from auth.mfa_factors
4354
+ const currentFactors = await query(`select id, secret, status, factor_type from auth.mfa_factors
2383
4355
  where id = $1 and user_id = $2 for update`, [factorId, user.id]);
2384
4356
  const currentFactor = currentFactors.rows[0];
2385
- if (!currentFactor || !await verifyTotp(currentFactor.secret, body.code ?? ""))
4357
+ if (!currentFactor || currentFactor.factor_type === "recovery_code" || !await verifyTotp(currentFactor.secret, body.code ?? ""))
2386
4358
  return null;
2387
4359
  const claimed = await query(`update auth.mfa_challenges set verified_at = now()
2388
4360
  where id = $1 and factor_id = $2 and verified_at is null and expires_at >= now()
@@ -2405,18 +4377,212 @@ Or sign in with this link: ${link}`
2405
4377
  }
2406
4378
  return json(200, session);
2407
4379
  }
4380
+ async recoveryCodesStatus(req) {
4381
+ const user = await this.userFromBearer(req);
4382
+ if (!user)
4383
+ return authError(401, "no_authorization", "This endpoint requires a Bearer token");
4384
+ const result = await this.db.query(`select f.id, count(c.id)::int as total,
4385
+ count(c.id) filter (where c.consumed_at is null)::int as remaining
4386
+ from auth.mfa_recovery_code_sets s
4387
+ join auth.mfa_factors f on f.id = s.mfa_factor_id
4388
+ left join auth.mfa_recovery_codes c on c.mfa_recovery_code_set_id = s.id
4389
+ where s.user_id = $1
4390
+ group by f.id`, [user.id]);
4391
+ const row = result.rows[0];
4392
+ if (!row)
4393
+ return authError(404, "mfa_factor_not_found", "The user has not enrolled recovery codes");
4394
+ return json(200, { id: row.id, type: "recovery_code", total: row.total, remaining: row.remaining });
4395
+ }
4396
+ async generateRecoveryCodes(req) {
4397
+ const user = await this.userFromBearer(req);
4398
+ if (!user)
4399
+ return authError(401, "no_authorization", "This endpoint requires a Bearer token");
4400
+ if (!this.settings.recoveryCodesEnrollEnabled) {
4401
+ return authError(422, "mfa_recovery_codes_enroll_not_enabled", "MFA enroll is disabled for recovery codes");
4402
+ }
4403
+ if (!await this.requireAal2(req))
4404
+ return authError(403, "insufficient_aal", "AAL2 required to generate recovery codes");
4405
+ const body = await req.json().catch(() => ({}));
4406
+ const friendlyName = typeof body.friendly_name === "string" && body.friendly_name.trim() ? body.friendly_name.trim().slice(0, 255) : "Recovery codes";
4407
+ const codes = Array.from({ length: this.settings.recoveryCodesCount }, () => randomRecoveryCode(this.settings.recoveryCodeLength));
4408
+ const hashes = await Promise.all(codes.map((code) => hashPassword(code)));
4409
+ return this.db.transaction(async (query) => {
4410
+ const current = (await query(`select * from auth.users where id = $1 and deleted_at is null
4411
+ and (banned_until is null or banned_until <= now()) for update`, [user.id])).rows[0];
4412
+ if (!current)
4413
+ return authError(401, "no_authorization", "Invalid or expired token");
4414
+ const existing = await query(`select 1 from auth.mfa_recovery_code_sets where user_id = $1`, [user.id]);
4415
+ if (existing.rows.length > 0) {
4416
+ return authError(422, "mfa_verified_factor_exists", "Recovery codes are already enrolled for this user, use regenerate to rotate them");
4417
+ }
4418
+ const verified = await query(`select count(*)::int as count from auth.mfa_factors
4419
+ where user_id = $1 and status = 'verified' and factor_type <> 'recovery_code'`, [user.id]);
4420
+ if ((verified.rows[0]?.count ?? 0) < 1) {
4421
+ return authError(422, "mfa_recovery_codes_sole_factor", "At least one other verified factor is required to generate recovery codes");
4422
+ }
4423
+ const duplicate = await query(`select 1 from auth.mfa_factors where user_id = $1 and friendly_name = $2`, [user.id, friendlyName]);
4424
+ if (duplicate.rows.length > 0) {
4425
+ return authError(422, "mfa_factor_name_conflict", "A factor with this friendly name already exists");
4426
+ }
4427
+ const factors = await query(`select count(*)::int as count from auth.mfa_factors where user_id = $1`, [user.id]);
4428
+ if ((factors.rows[0]?.count ?? 0) >= this.settings.maxEnrolledFactors) {
4429
+ return authError(422, "too_many_enrolled_mfa_factors", "Maximum number of enrolled MFA factors reached");
4430
+ }
4431
+ const factor = (await query(`insert into auth.mfa_factors (user_id, friendly_name, factor_type, status)
4432
+ values ($1, $2, 'recovery_code', 'verified') returning id`, [user.id, friendlyName])).rows[0];
4433
+ 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];
4434
+ for (const hash of hashes) {
4435
+ await query(`insert into auth.mfa_recovery_codes (mfa_recovery_code_set_id, code_hash) values ($1, $2)`, [set.id, hash]);
4436
+ }
4437
+ return json(200, {
4438
+ id: factor.id,
4439
+ type: "recovery_code",
4440
+ friendly_name: friendlyName,
4441
+ total: codes.length,
4442
+ codes
4443
+ });
4444
+ });
4445
+ }
4446
+ async regenerateRecoveryCodes(req) {
4447
+ const user = await this.userFromBearer(req);
4448
+ if (!user)
4449
+ return authError(401, "no_authorization", "This endpoint requires a Bearer token");
4450
+ if (!this.settings.recoveryCodesEnrollEnabled) {
4451
+ return authError(422, "mfa_recovery_codes_enroll_not_enabled", "MFA enroll is disabled for recovery codes");
4452
+ }
4453
+ if (!await this.requireAal2(req))
4454
+ return authError(403, "insufficient_aal", "AAL2 required to regenerate recovery codes");
4455
+ const codes = Array.from({ length: this.settings.recoveryCodesCount }, () => randomRecoveryCode(this.settings.recoveryCodeLength));
4456
+ const hashes = await Promise.all(codes.map((code) => hashPassword(code)));
4457
+ return this.db.transaction(async (query) => {
4458
+ const current = (await query(`select * from auth.users where id = $1 and deleted_at is null
4459
+ and (banned_until is null or banned_until <= now()) for update`, [user.id])).rows[0];
4460
+ if (!current)
4461
+ return authError(401, "no_authorization", "Invalid or expired token");
4462
+ 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];
4463
+ if (!set)
4464
+ return authError(404, "mfa_factor_not_found", "The user has not enrolled recovery codes");
4465
+ 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];
4466
+ if (!factor)
4467
+ return authError(404, "mfa_factor_not_found", "The user has not enrolled recovery codes");
4468
+ await query(`delete from auth.mfa_recovery_codes where mfa_recovery_code_set_id = $1`, [set.id]);
4469
+ for (const hash of hashes) {
4470
+ await query(`insert into auth.mfa_recovery_codes (mfa_recovery_code_set_id, code_hash) values ($1, $2)`, [set.id, hash]);
4471
+ }
4472
+ await query(`update auth.mfa_recovery_code_sets
4473
+ set failed_verification_count = 0, verification_locked_until = null, updated_at = now()
4474
+ where id = $1`, [set.id]);
4475
+ return json(200, {
4476
+ id: factor.id,
4477
+ type: "recovery_code",
4478
+ friendly_name: factor.friendly_name ?? "Recovery codes",
4479
+ total: codes.length,
4480
+ codes
4481
+ });
4482
+ });
4483
+ }
4484
+ async deleteRecoveryCodes(req) {
4485
+ const user = await this.userFromBearer(req);
4486
+ if (!user)
4487
+ return authError(401, "no_authorization", "This endpoint requires a Bearer token");
4488
+ if (!await this.requireAal2(req))
4489
+ return authError(403, "insufficient_aal", "AAL2 required to delete recovery codes");
4490
+ return this.db.transaction(async (query) => {
4491
+ const current = (await query(`select * from auth.users where id = $1 and deleted_at is null
4492
+ and (banned_until is null or banned_until <= now()) for update`, [user.id])).rows[0];
4493
+ if (!current)
4494
+ return authError(401, "no_authorization", "Invalid or expired token");
4495
+ 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];
4496
+ if (!factor)
4497
+ return authError(404, "mfa_factor_not_found", "The user has not enrolled recovery codes");
4498
+ await query(`delete from auth.mfa_recovery_codes
4499
+ where mfa_recovery_code_set_id in (select id from auth.mfa_recovery_code_sets where user_id = $1)`, [user.id]);
4500
+ await query(`delete from auth.mfa_recovery_code_sets where user_id = $1`, [user.id]);
4501
+ await query(`delete from auth.mfa_factors where id = $1 and user_id = $2`, [factor.id, user.id]);
4502
+ return json(200, { id: factor.id });
4503
+ });
4504
+ }
4505
+ async verifyRecoveryCode(req) {
4506
+ const user = await this.userFromBearer(req);
4507
+ if (!user)
4508
+ return authError(401, "no_authorization", "This endpoint requires a Bearer token");
4509
+ if (!this.settings.recoveryCodesVerifyEnabled) {
4510
+ return authError(422, "mfa_recovery_codes_verify_not_enabled", "MFA verification is disabled for recovery codes");
4511
+ }
4512
+ const body = await req.json().catch(() => ({}));
4513
+ const code = normalizeRecoveryCode(body.code);
4514
+ if (!code)
4515
+ return authError(400, "validation_failed", "Code needs to be non-empty");
4516
+ return this.db.transaction(async (query) => {
4517
+ const current = (await query(`select * from auth.users where id = $1 and deleted_at is null
4518
+ and (banned_until is null or banned_until <= now()) for update`, [user.id])).rows[0];
4519
+ if (!current)
4520
+ return authError(401, "no_authorization", "Invalid or expired token");
4521
+ const set = (await query(`select id, failed_verification_count, verification_locked_until
4522
+ from auth.mfa_recovery_code_sets where user_id = $1 for update`, [user.id])).rows[0];
4523
+ if (!set)
4524
+ return authError(422, "mfa_verification_failed", "Invalid recovery code entered");
4525
+ if (set.verification_locked_until && new Date(set.verification_locked_until).getTime() > Date.now()) {
4526
+ return authError(429, "mfa_recovery_codes_locked", "Too many failed verification attempts, try again later");
4527
+ }
4528
+ const entries = await query(`select id, code_hash from auth.mfa_recovery_codes
4529
+ where mfa_recovery_code_set_id = $1 and consumed_at is null`, [set.id]);
4530
+ let matched = null;
4531
+ for (const entry of entries.rows) {
4532
+ if (await verifyPassword(code, entry.code_hash) && matched === null)
4533
+ matched = entry.id;
4534
+ }
4535
+ if (!matched) {
4536
+ const failures = set.failed_verification_count + 1;
4537
+ await query(`update auth.mfa_recovery_code_sets
4538
+ set failed_verification_count = $1::int,
4539
+ verification_locked_until = case when $1::int >= $2::int
4540
+ then now() + $3::interval else verification_locked_until end,
4541
+ updated_at = now()
4542
+ where id = $4::uuid`, [failures, this.settings.recoveryCodesMaxVerifyAttempts, `${this.settings.recoveryCodesLockoutSeconds} seconds`, set.id]);
4543
+ return authError(422, "mfa_verification_failed", "Invalid recovery code entered");
4544
+ }
4545
+ const consumed = await query(`update auth.mfa_recovery_codes set consumed_at = now()
4546
+ where id = $1 and consumed_at is null returning id`, [matched]);
4547
+ if (consumed.rows.length === 0)
4548
+ return authError(422, "mfa_verification_failed", "Invalid recovery code entered");
4549
+ await query(`update auth.mfa_recovery_code_sets
4550
+ set failed_verification_count = 0, verification_locked_until = null, updated_at = now()
4551
+ where id = $1`, [set.id]);
4552
+ const session = await this.sessionFor(current, undefined, {
4553
+ aal: "aal2",
4554
+ amr: [
4555
+ { method: "password", timestamp: Math.floor(Date.now() / 1000) },
4556
+ { method: "mfa/recovery_code", timestamp: Math.floor(Date.now() / 1000) }
4557
+ ]
4558
+ }, query);
4559
+ return json(200, session);
4560
+ });
4561
+ }
4562
+ async requireAal2(req) {
4563
+ const claims = await this.claimsFromBearer(req);
4564
+ return claims?.aal === "aal2";
4565
+ }
2408
4566
  async unenrollFactor(req, factorId) {
2409
4567
  const user = await this.userFromBearer(req);
2410
4568
  if (!user)
2411
4569
  return authError(401, "no_authorization", "This endpoint requires a Bearer token");
4570
+ const hasAal2 = await this.requireAal2(req);
2412
4571
  return this.db.transaction(async (query) => {
2413
4572
  const currentResult = await query(`select * from auth.users where id = $1 and deleted_at is null
2414
4573
  and (banned_until is null or banned_until <= now()) for update`, [user.id]);
2415
4574
  if (!currentResult.rows[0])
2416
4575
  return authError(401, "no_authorization", "Invalid or expired token");
2417
- const del = await query(`delete from auth.mfa_factors where id = $1 and user_id = $2 returning id`, [factorId, user.id]);
2418
- if (del.rows.length === 0)
4576
+ 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]);
4577
+ if (factor.rows.length === 0)
2419
4578
  return authError(404, "mfa_factor_not_found", "MFA factor not found");
4579
+ if (factor.rows[0].factor_type === "recovery_code") {
4580
+ return authError(422, "validation_failed", "Recovery code factors use the recovery-codes API");
4581
+ }
4582
+ if (factor.rows[0].status === "verified" && !hasAal2) {
4583
+ return authError(403, "insufficient_aal", "AAL2 required to unenroll a verified MFA factor");
4584
+ }
4585
+ const del = await query(`delete from auth.mfa_factors where id = $1 and user_id = $2 returning id`, [factorId, user.id]);
2420
4586
  return json(200, { id: factorId });
2421
4587
  });
2422
4588
  }
@@ -2446,13 +4612,45 @@ Or sign in with this link: ${link}`
2446
4612
  updated_at: iso(r.updated_at)
2447
4613
  }));
2448
4614
  }
2449
- async ensureEmailIdentity(user, query = (sql, params) => this.db.query(sql, params)) {
4615
+ async ensureEmailIdentity(user, query = (sql, params) => this.db.query(sql, params), syncProviders = true) {
2450
4616
  if (!user.email)
2451
4617
  return;
2452
4618
  await query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
2453
4619
  values ($1::uuid, 'email', $1::text, $2::jsonb)
2454
4620
  on conflict (provider, provider_id) do update
2455
4621
  set identity_data = excluded.identity_data, updated_at = now()`, [user.id, JSON.stringify({ sub: user.id, email: user.email })]);
4622
+ if (syncProviders)
4623
+ await this.syncUserProviders(user, query);
4624
+ }
4625
+ async ensurePhoneIdentity(user, query = (sql, params) => this.db.query(sql, params)) {
4626
+ if (!user.phone)
4627
+ return;
4628
+ await query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
4629
+ values ($1::uuid, 'phone', $2, $3::jsonb)
4630
+ on conflict (provider, provider_id) do update
4631
+ 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) })]);
4632
+ await this.syncUserProviders(user, query);
4633
+ }
4634
+ async syncUserProviders(user, query = (sql, params) => this.db.query(sql, params)) {
4635
+ const updated = (await query(`update auth.users
4636
+ set raw_app_meta_data = jsonb_set(
4637
+ coalesce(raw_app_meta_data, '{}'::jsonb),
4638
+ '{providers}',
4639
+ (
4640
+ select jsonb_agg(provider order by provider)
4641
+ from (
4642
+ select distinct provider
4643
+ from auth.identities
4644
+ where user_id = $1
4645
+ ) as providers
4646
+ ),
4647
+ true
4648
+ ),
4649
+ updated_at = now()
4650
+ where id = $1
4651
+ returning *`, [user.id])).rows[0];
4652
+ if (updated)
4653
+ Object.assign(user, updated);
2456
4654
  }
2457
4655
  async userJsonWithRelations(user, query = (sql, params) => this.db.query(sql, params)) {
2458
4656
  return this.userJson(user, await this.getUserFactors(user.id, query), await this.getUserIdentities(user.id, query));
@@ -2538,6 +4736,14 @@ Or sign in with this link: ${link}`
2538
4736
  phone: u.phone ?? "",
2539
4737
  phone_confirmed_at: iso(u.phone_confirmed_at),
2540
4738
  confirmed_at: iso(u.email_confirmed_at ?? u.phone_confirmed_at),
4739
+ ...u.email_change ? {
4740
+ new_email: u.email_change,
4741
+ email_change_sent_at: iso(u.email_change_sent_at)
4742
+ } : {},
4743
+ ...u.phone_change ? {
4744
+ new_phone: u.phone_change,
4745
+ phone_change_sent_at: iso(u.phone_change_sent_at)
4746
+ } : {},
2541
4747
  last_sign_in_at: iso(u.last_sign_in_at),
2542
4748
  app_metadata: u.raw_app_meta_data ?? {},
2543
4749
  user_metadata: u.raw_user_meta_data ?? {},
@@ -3349,6 +5555,11 @@ create table if not exists auth.users (
3349
5555
  email text unique,
3350
5556
  encrypted_password text,
3351
5557
  email_confirmed_at timestamptz,
5558
+ email_change_token_new text default '',
5559
+ email_change text,
5560
+ email_change_sent_at timestamptz,
5561
+ email_change_token_current text default '',
5562
+ email_change_confirm_status smallint default 0,
3352
5563
  last_sign_in_at timestamptz,
3353
5564
  raw_app_meta_data jsonb default '{}'::jsonb,
3354
5565
  raw_user_meta_data jsonb default '{}'::jsonb,
@@ -3357,11 +5568,23 @@ create table if not exists auth.users (
3357
5568
  updated_at timestamptz default now(),
3358
5569
  phone text unique,
3359
5570
  phone_confirmed_at timestamptz,
5571
+ phone_change text default '',
5572
+ phone_change_token text default '',
5573
+ phone_change_sent_at timestamptz,
3360
5574
  banned_until timestamptz,
3361
5575
  deleted_at timestamptz,
3362
5576
  is_anonymous boolean default false
3363
5577
  );
3364
5578
 
5579
+ alter table auth.users add column if not exists email_change text;
5580
+ alter table auth.users add column if not exists email_change_sent_at timestamptz;
5581
+ alter table auth.users add column if not exists email_change_token_new text default '';
5582
+ alter table auth.users add column if not exists email_change_token_current text default '';
5583
+ alter table auth.users add column if not exists email_change_confirm_status smallint default 0;
5584
+ alter table auth.users add column if not exists phone_change text default '';
5585
+ alter table auth.users add column if not exists phone_change_token text default '';
5586
+ alter table auth.users add column if not exists phone_change_sent_at timestamptz;
5587
+
3365
5588
  create table if not exists auth.refresh_tokens (
3366
5589
  id bigserial primary key,
3367
5590
  token text unique not null,
@@ -3430,6 +5653,7 @@ create table if not exists auth.flow_state (
3430
5653
  created_at timestamptz default now(),
3431
5654
  expires_at timestamptz not null
3432
5655
  );
5656
+ alter table auth.flow_state add column if not exists link_user_id uuid;
3433
5657
 
3434
5658
  create table if not exists auth.mfa_factors (
3435
5659
  id uuid primary key default gen_random_uuid(),
@@ -3450,6 +5674,25 @@ create table if not exists auth.mfa_challenges (
3450
5674
  expires_at timestamptz not null
3451
5675
  );
3452
5676
 
5677
+ create table if not exists auth.mfa_recovery_code_sets (
5678
+ id uuid primary key default gen_random_uuid(),
5679
+ user_id uuid not null,
5680
+ mfa_factor_id uuid not null unique,
5681
+ failed_verification_count int not null default 0,
5682
+ verification_locked_until timestamptz,
5683
+ created_at timestamptz default now(),
5684
+ updated_at timestamptz default now(),
5685
+ unique (user_id)
5686
+ );
5687
+
5688
+ create table if not exists auth.mfa_recovery_codes (
5689
+ id uuid primary key default gen_random_uuid(),
5690
+ mfa_recovery_code_set_id uuid not null references auth.mfa_recovery_code_sets(id) on delete cascade,
5691
+ code_hash text not null,
5692
+ consumed_at timestamptz,
5693
+ created_at timestamptz default now()
5694
+ );
5695
+
3453
5696
  create table if not exists auth.audit_log_entries (
3454
5697
  id uuid primary key default gen_random_uuid(),
3455
5698
  payload jsonb,
@@ -3649,6 +5892,12 @@ create table if not exists auth.users (
3649
5892
  is_anonymous boolean default false
3650
5893
  );
3651
5894
 
5895
+ alter table auth.users add column if not exists email_change text;
5896
+ alter table auth.users add column if not exists email_change_sent_at timestamptz;
5897
+ alter table auth.users add column if not exists email_change_token_new text default '';
5898
+ alter table auth.users add column if not exists email_change_token_current text default '';
5899
+ alter table auth.users add column if not exists email_change_confirm_status smallint default 0;
5900
+
3652
5901
  create table if not exists auth.refresh_tokens (
3653
5902
  id bigint generated by default as identity primary key,
3654
5903
  token text unique not null,
@@ -3729,6 +5978,7 @@ create table if not exists auth.flow_state (
3729
5978
  created_at timestamptz default now(),
3730
5979
  expires_at timestamptz not null
3731
5980
  );
5981
+ alter table auth.flow_state add column if not exists link_user_id uuid;
3732
5982
 
3733
5983
  create table if not exists auth.mfa_factors (
3734
5984
  id uuid primary key default gen_random_uuid(),
@@ -3749,6 +5999,25 @@ create table if not exists auth.mfa_challenges (
3749
5999
  expires_at timestamptz not null
3750
6000
  );
3751
6001
 
6002
+ create table if not exists auth.mfa_recovery_code_sets (
6003
+ id uuid primary key default gen_random_uuid(),
6004
+ user_id uuid not null references auth.users(id) on delete cascade,
6005
+ mfa_factor_id uuid not null unique references auth.mfa_factors(id) on delete cascade,
6006
+ failed_verification_count int not null default 0,
6007
+ verification_locked_until timestamptz,
6008
+ created_at timestamptz default now(),
6009
+ updated_at timestamptz default now(),
6010
+ unique (user_id)
6011
+ );
6012
+
6013
+ create table if not exists auth.mfa_recovery_codes (
6014
+ id uuid primary key default gen_random_uuid(),
6015
+ mfa_recovery_code_set_id uuid not null references auth.mfa_recovery_code_sets(id) on delete cascade,
6016
+ code_hash text not null,
6017
+ consumed_at timestamptz,
6018
+ created_at timestamptz default now()
6019
+ );
6020
+
3752
6021
  -- append-only security audit trail (GoTrue-compatible shape)
3753
6022
  create table if not exists auth.audit_log_entries (
3754
6023
  instance_id uuid default '00000000-0000-0000-0000-000000000000',
@@ -9248,7 +11517,7 @@ function imageTransformInputTooLarge(maxInputBytes) {
9248
11517
  message: `The source image exceeds the transformation limit (${maxInputBytes} bytes)`
9249
11518
  };
9250
11519
  }
9251
- function invalid(message) {
11520
+ function invalid2(message) {
9252
11521
  return { ok: false, status: 400, error: "InvalidTransformation", message };
9253
11522
  }
9254
11523
  function unsupported(message) {
@@ -9258,22 +11527,22 @@ function parsePositiveInteger(value, name) {
9258
11527
  if (value === null)
9259
11528
  return;
9260
11529
  if (!/^[1-9]\d*$/.test(value))
9261
- return invalid(`${name} must be a positive integer`);
11530
+ return invalid2(`${name} must be a positive integer`);
9262
11531
  const parsed = Number(value);
9263
11532
  if (!Number.isSafeInteger(parsed))
9264
- return invalid(`${name} is too large`);
11533
+ return invalid2(`${name} is too large`);
9265
11534
  if (parsed > MAX_TRANSFORM_DIMENSION)
9266
- return invalid(`${name} must be between 1 and ${MAX_TRANSFORM_DIMENSION}`);
11535
+ return invalid2(`${name} must be between 1 and ${MAX_TRANSFORM_DIMENSION}`);
9267
11536
  return parsed;
9268
11537
  }
9269
11538
  function parseQuality(value) {
9270
11539
  if (value === null)
9271
11540
  return;
9272
11541
  if (!/^\d+$/.test(value))
9273
- return invalid("quality must be an integer between 20 and 100");
11542
+ return invalid2("quality must be an integer between 20 and 100");
9274
11543
  const parsed = Number(value);
9275
11544
  if (!Number.isSafeInteger(parsed) || parsed < 20 || parsed > 100) {
9276
- return invalid("quality must be an integer between 20 and 100");
11545
+ return invalid2("quality must be an integer between 20 and 100");
9277
11546
  }
9278
11547
  return parsed;
9279
11548
  }
@@ -9288,7 +11557,7 @@ function parseImageTransform(query) {
9288
11557
  if (resizeValue === "cover")
9289
11558
  return unsupported("resize=cover is not supported by Bun.Image in this runtime");
9290
11559
  if (resizeValue !== null && resizeValue !== "contain" && resizeValue !== "fill") {
9291
- return invalid("resize must be one of contain or fill");
11560
+ return invalid2("resize must be one of contain or fill");
9292
11561
  }
9293
11562
  if (resizeValue === null && width !== undefined && height !== undefined) {
9294
11563
  return unsupported("resize defaults to cover when width and height are both set; specify contain or fill");
@@ -9297,13 +11566,13 @@ function parseImageTransform(query) {
9297
11566
  const formatValue = query.get("format");
9298
11567
  const format = formatValue === null ? "origin" : formatValue;
9299
11568
  if (format !== "origin" && format !== "jpeg" && format !== "png" && format !== "webp") {
9300
- return invalid("format must be one of origin, jpeg, png, or webp");
11569
+ return invalid2("format must be one of origin, jpeg, png, or webp");
9301
11570
  }
9302
11571
  const quality = parseQuality(query.get("quality"));
9303
11572
  if (typeof quality !== "number" && quality !== undefined)
9304
11573
  return quality;
9305
11574
  if (quality !== undefined && format === "png") {
9306
- return invalid("quality is supported only for jpeg and webp output");
11575
+ return invalid2("quality is supported only for jpeg and webp output");
9307
11576
  }
9308
11577
  return {
9309
11578
  ok: true,
@@ -9405,7 +11674,7 @@ async function transformImage(source, options, knownSourceSize, configuredLimits
9405
11674
  }
9406
11675
  const outputFormat = options.format === "origin" ? metadata.format : options.format;
9407
11676
  if (options.quality !== undefined && outputFormat !== "jpeg" && outputFormat !== "webp") {
9408
- return invalid("quality is supported only for jpeg and webp output");
11677
+ return invalid2("quality is supported only for jpeg and webp output");
9409
11678
  }
9410
11679
  const contentType = IMAGE_FORMAT_CONTENT_TYPES[outputFormat];
9411
11680
  if (!contentType) {
@@ -11323,7 +13592,7 @@ class GraphqlHandler {
11323
13592
  throw new ApiError(415, { message: "GraphQL requests require application/json" });
11324
13593
  }
11325
13594
  const body = JSON.parse(await boundedBody(request, this.maxBytes));
11326
- 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") {
13595
+ 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") {
11327
13596
  throw new ApiError(400, { message: "Invalid GraphQL request" });
11328
13597
  }
11329
13598
  operationName = body["operationName"] ?? undefined;
@@ -11378,7 +13647,7 @@ async function boundedBody(request, limit) {
11378
13647
  }
11379
13648
  return Buffer.concat(chunks).toString("utf8");
11380
13649
  }
11381
- function isObject(value) {
13650
+ function isObject2(value) {
11382
13651
  return value !== null && typeof value === "object" && !Array.isArray(value);
11383
13652
  }
11384
13653
  function isGraphqlEnvelope(value) {
@@ -12568,14 +14837,14 @@ class PgWireClient {
12568
14837
  if (code === 0) {} else if (code === 3) {
12569
14838
  if (!needPassword())
12570
14839
  return;
12571
- this.socket.write(message(112, cstring(opts.password ?? "")));
14840
+ this.socket.write(message2(112, cstring(opts.password ?? "")));
12572
14841
  } else if (code === 5) {
12573
14842
  if (!needPassword())
12574
14843
  return;
12575
14844
  const salt = payload.subarray(4, 8);
12576
14845
  const inner = md5Hex(Buffer.from((opts.password ?? "") + opts.user, "utf8"));
12577
14846
  const token = "md5" + md5Hex(Buffer.concat([Buffer.from(inner, "utf8"), salt]));
12578
- this.socket.write(message(112, cstring(token)));
14847
+ this.socket.write(message2(112, cstring(token)));
12579
14848
  } else if (code === 10) {
12580
14849
  if (!needPassword())
12581
14850
  return;
@@ -12587,7 +14856,7 @@ class PgWireClient {
12587
14856
  clientNonce = randomBytes(18).toString("base64");
12588
14857
  clientFirstBare = `n=,r=${clientNonce}`;
12589
14858
  const initial = Buffer.from(`n,,${clientFirstBare}`, "utf8");
12590
- this.socket.write(message(112, Buffer.concat([cstring("SCRAM-SHA-256"), int32(initial.length), initial])));
14859
+ this.socket.write(message2(112, Buffer.concat([cstring("SCRAM-SHA-256"), int32(initial.length), initial])));
12591
14860
  } else if (code === 11) {
12592
14861
  const serverFirst = payload.subarray(4).toString("utf8");
12593
14862
  const attrs = scramAttrs(serverFirst);
@@ -12602,15 +14871,15 @@ class PgWireClient {
12602
14871
  const salt = Buffer.from(attrs.s, "base64");
12603
14872
  const iterations = parseInt(attrs.i, 10);
12604
14873
  const saltedPassword = pbkdf2Sync(opts.password, salt, iterations, 32, "sha256");
12605
- const clientKey = hmac(saltedPassword, "Client Key");
14874
+ const clientKey = hmac2(saltedPassword, "Client Key");
12606
14875
  const storedKey = sha256(clientKey);
12607
14876
  const finalNoProof = `c=biws,r=${attrs.r}`;
12608
14877
  const authMessage = `${clientFirstBare},${serverFirst},${finalNoProof}`;
12609
- const clientSignature = hmac(storedKey, authMessage);
14878
+ const clientSignature = hmac2(storedKey, authMessage);
12610
14879
  const proof = xorBuffers(clientKey, clientSignature);
12611
- serverSignature = hmac(hmac(saltedPassword, "Server Key"), authMessage).toString("base64");
14880
+ serverSignature = hmac2(hmac2(saltedPassword, "Server Key"), authMessage).toString("base64");
12612
14881
  const clientFinal = `${finalNoProof},p=${proof.toString("base64")}`;
12613
- this.socket.write(message(112, Buffer.from(clientFinal, "utf8")));
14882
+ this.socket.write(message2(112, Buffer.from(clientFinal, "utf8")));
12614
14883
  } else if (code === 12) {
12615
14884
  const v = scramAttrs(payload.subarray(4).toString("utf8")).v;
12616
14885
  if (v && serverSignature && v !== serverSignature) {
@@ -12651,11 +14920,11 @@ class PgWireClient {
12651
14920
  return op;
12652
14921
  }
12653
14922
  async exec(sql) {
12654
- return this.run(() => this.socket.write(message(81, cstring(sql))));
14923
+ return this.run(() => this.socket.write(message2(81, cstring(sql))));
12655
14924
  }
12656
14925
  async query(sql, params = []) {
12657
14926
  const results = await this.run(() => {
12658
- const parse = message(80, Buffer.concat([cstring(""), cstring(sql), int16(0)]));
14927
+ const parse = message2(80, Buffer.concat([cstring(""), cstring(sql), int16(0)]));
12659
14928
  const paramBufs = [int16(0), int16(params.length)];
12660
14929
  for (const p of params) {
12661
14930
  if (p === null || p === undefined) {
@@ -12666,10 +14935,10 @@ class PgWireClient {
12666
14935
  }
12667
14936
  }
12668
14937
  paramBufs.push(int16(0));
12669
- const bind = message(66, Buffer.concat([cstring(""), cstring(""), ...paramBufs]));
12670
- const describe = message(68, Buffer.concat([Buffer.from("P"), cstring("")]));
12671
- const execute = message(69, Buffer.concat([cstring(""), int32(0)]));
12672
- const sync = message(83, Buffer.alloc(0));
14938
+ const bind = message2(66, Buffer.concat([cstring(""), cstring(""), ...paramBufs]));
14939
+ const describe = message2(68, Buffer.concat([Buffer.from("P"), cstring("")]));
14940
+ const execute = message2(69, Buffer.concat([cstring(""), int32(0)]));
14941
+ const sync = message2(83, Buffer.alloc(0));
12673
14942
  this.socket.write(Buffer.concat([parse, bind, describe, execute, sync]));
12674
14943
  });
12675
14944
  return results[0] ?? { rows: [] };
@@ -12678,7 +14947,7 @@ class PgWireClient {
12678
14947
  return new Promise((resolve) => {
12679
14948
  if (this.closed)
12680
14949
  return resolve();
12681
- this.socket.write(message(88, Buffer.alloc(0)));
14950
+ this.socket.write(message2(88, Buffer.alloc(0)));
12682
14951
  this.socket.end(() => resolve());
12683
14952
  });
12684
14953
  }
@@ -12782,7 +15051,7 @@ class PgWireClient {
12782
15051
  }
12783
15052
  }
12784
15053
  }
12785
- var hmac = (key, data) => createHmac("sha256", key).update(data, "utf8").digest();
15054
+ var hmac2 = (key, data) => createHmac("sha256", key).update(data, "utf8").digest();
12786
15055
  var sha256 = (b) => createHash("sha256").update(b).digest();
12787
15056
  var md5Hex = (b) => createHash("md5").update(b).digest("hex");
12788
15057
  function xorBuffers(a, b) {
@@ -12800,7 +15069,7 @@ function scramAttrs(s) {
12800
15069
  }
12801
15070
  return out;
12802
15071
  }
12803
- function message(type, body) {
15072
+ function message2(type, body) {
12804
15073
  const out = Buffer.alloc(5 + body.length);
12805
15074
  out[0] = type;
12806
15075
  out.writeInt32BE(4 + body.length, 1);
@@ -13849,6 +16118,7 @@ function readAuthSettings(root) {
13849
16118
  const sms = tableAt(root, "auth.sms");
13850
16119
  const mfa = tableAt(root, "auth.mfa");
13851
16120
  const mfaTotp = tableAt(root, "auth.mfa.totp");
16121
+ const recoveryCodes = tableAt(root, "auth.mfa.recovery_codes");
13852
16122
  const out = {};
13853
16123
  const disableSignup = getBool(auth, "disable_signup");
13854
16124
  if (disableSignup !== undefined)
@@ -13865,6 +16135,9 @@ function readAuthSettings(root) {
13865
16135
  const minLen = getInt(auth, "minimum_password_length");
13866
16136
  if (minLen !== undefined)
13867
16137
  out.minPasswordLength = minLen;
16138
+ const requireReauthentication = getBool(auth, "security_update_password_require_reauthentication") ?? getBool(auth, "password_require_reauthentication");
16139
+ if (requireReauthentication !== undefined)
16140
+ out.requirePasswordReauthentication = requireReauthentication;
13868
16141
  const otpLength = getInt(email, "otp_length");
13869
16142
  if (otpLength !== undefined)
13870
16143
  out.otpLength = otpLength;
@@ -13892,6 +16165,24 @@ function readAuthSettings(root) {
13892
16165
  const totpVerify = getBool(mfaTotp, "verify_enabled");
13893
16166
  if (totpVerify !== undefined)
13894
16167
  out.totpVerifyEnabled = totpVerify;
16168
+ const recoveryEnroll = getBool(recoveryCodes, "enroll_enabled");
16169
+ if (recoveryEnroll !== undefined)
16170
+ out.recoveryCodesEnrollEnabled = recoveryEnroll;
16171
+ const recoveryVerify = getBool(recoveryCodes, "verify_enabled");
16172
+ if (recoveryVerify !== undefined)
16173
+ out.recoveryCodesVerifyEnabled = recoveryVerify;
16174
+ const recoveryCount = getInt(recoveryCodes, "count");
16175
+ if (recoveryCount !== undefined)
16176
+ out.recoveryCodesCount = recoveryCount;
16177
+ const recoveryLength = getInt(recoveryCodes, "code_length");
16178
+ if (recoveryLength !== undefined)
16179
+ out.recoveryCodeLength = recoveryLength;
16180
+ const recoveryAttempts = getInt(recoveryCodes, "max_verify_attempts");
16181
+ if (recoveryAttempts !== undefined)
16182
+ out.recoveryCodesMaxVerifyAttempts = recoveryAttempts;
16183
+ const recoveryLockout = getDurationSeconds(recoveryCodes, "lockout_duration");
16184
+ if (recoveryLockout !== undefined)
16185
+ out.recoveryCodesLockoutSeconds = recoveryLockout;
13895
16186
  return out;
13896
16187
  }
13897
16188
  function readRateLimits(root) {
@@ -13936,7 +16227,11 @@ function readOAuthProviders(root, env) {
13936
16227
  authorizeUrl: getString(t, "authorize_url") ?? (url ? `${url}/authorize` : undefined),
13937
16228
  tokenUrl: getString(t, "token_url") ?? (url ? `${url}/token` : undefined),
13938
16229
  userInfoUrl: getString(t, "userinfo_url") ?? (url ? `${url}/userinfo` : undefined),
13939
- scopes: getString(t, "scopes")
16230
+ scopes: getString(t, "scopes"),
16231
+ idTokenIssuer: getString(t, "id_token_issuer") ?? getString(t, "issuer"),
16232
+ idTokenJwksUrl: getString(t, "id_token_jwks_url") ?? getString(t, "jwks_url"),
16233
+ idTokenAudience: getString(t, "id_token_audience") ?? clientId,
16234
+ ssoDomains: getStringArray(t, "sso_domains")
13940
16235
  };
13941
16236
  }
13942
16237
  }
@@ -13968,7 +16263,10 @@ function collectEnvProviders(env, out, prefix, secretKey) {
13968
16263
  authorizeUrl: g("AUTHORIZE_URL"),
13969
16264
  tokenUrl: g("TOKEN_URL"),
13970
16265
  userInfoUrl: g("USERINFO_URL"),
13971
- scopes: g("SCOPES")
16266
+ scopes: g("SCOPES"),
16267
+ idTokenIssuer: g("ID_TOKEN_ISSUER") ?? g("ISSUER"),
16268
+ idTokenJwksUrl: g("ID_TOKEN_JWKS_URL") ?? g("JWKS_URL"),
16269
+ idTokenAudience: g("ID_TOKEN_AUDIENCE") ?? clientId
13972
16270
  };
13973
16271
  }
13974
16272
  }