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