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