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