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