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