@supacloud/lite 0.31.3 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +37 -0
- package/dist/cli.js +2692 -262
- package/dist/index.js +2692 -262
- package/dist/runtime/auth/handler.d.ts +33 -1
- package/dist/runtime/auth/handler.d.ts.map +1 -1
- package/dist/runtime/auth/oauth.d.ts +28 -2
- package/dist/runtime/auth/oauth.d.ts.map +1 -1
- package/dist/runtime/auth/settings.d.ts +14 -0
- package/dist/runtime/auth/settings.d.ts.map +1 -1
- package/dist/runtime/auth/web3.d.ts +14 -0
- package/dist/runtime/auth/web3.d.ts.map +1 -0
- package/dist/runtime/db/bootstrap.d.ts +2 -2
- package/dist/runtime/db/bootstrap.d.ts.map +1 -1
- package/package.json +13 -13
package/dist/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,57 +1172,99 @@ 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
|
-
} catch
|
|
377
|
-
|
|
378
|
-
|
|
1214
|
+
} catch {
|
|
1215
|
+
return redirectWithFragment(flow.redirect_to, {
|
|
1216
|
+
error: "server_error",
|
|
1217
|
+
error_description: "OAuth provider request failed"
|
|
1218
|
+
});
|
|
379
1219
|
}
|
|
380
1220
|
let userId;
|
|
381
1221
|
try {
|
|
382
|
-
|
|
1222
|
+
if (flow.link_user_id) {
|
|
1223
|
+
await this.linkIdentityToUser(provider.name, profile, flow.link_user_id);
|
|
1224
|
+
userId = flow.link_user_id;
|
|
1225
|
+
} else {
|
|
1226
|
+
userId = await this.upsertUser(provider.name, profile);
|
|
1227
|
+
}
|
|
383
1228
|
} catch (e) {
|
|
384
1229
|
if (e instanceof OAuthAccountIneligibleError) {
|
|
385
|
-
return
|
|
1230
|
+
return redirectWithFragment(flow.redirect_to, { error: "access_denied", error_code: e.code });
|
|
386
1231
|
}
|
|
387
|
-
|
|
1232
|
+
if (e instanceof OAuthIdentityLinkError) {
|
|
1233
|
+
return redirectWithFragment(flow.redirect_to, { error: "access_denied", error_code: e.code });
|
|
1234
|
+
}
|
|
1235
|
+
return redirectWithFragment(flow.redirect_to, {
|
|
1236
|
+
error: "server_error",
|
|
1237
|
+
error_description: "OAuth callback failed"
|
|
1238
|
+
});
|
|
388
1239
|
}
|
|
389
1240
|
if (flow.code_challenge) {
|
|
390
1241
|
const authCode = randomToken(24);
|
|
391
1242
|
await this.db.query(`insert into auth.flow_state (provider, provider_state, redirect_to, code_challenge, code_challenge_method, auth_code, user_id, expires_at)
|
|
392
1243
|
values ($1, $2, $3, $4, $5, $6, $7, now() + interval '5 minutes')`, [flow.provider, randomToken(16), flow.redirect_to, flow.code_challenge, flow.code_challenge_method, authCode, userId]);
|
|
393
|
-
return
|
|
1244
|
+
return redirectWithQuery(flow.redirect_to, { code: authCode });
|
|
394
1245
|
}
|
|
395
1246
|
let session;
|
|
396
1247
|
try {
|
|
397
1248
|
session = await createSession(userId);
|
|
398
1249
|
} catch (e) {
|
|
399
1250
|
if (e instanceof OAuthAccountIneligibleError) {
|
|
400
|
-
return
|
|
1251
|
+
return redirectWithFragment(flow.redirect_to, { error: "access_denied", error_code: e.code });
|
|
401
1252
|
}
|
|
402
|
-
|
|
1253
|
+
return redirectWithFragment(flow.redirect_to, {
|
|
1254
|
+
error: "server_error",
|
|
1255
|
+
error_description: "OAuth callback failed"
|
|
1256
|
+
});
|
|
403
1257
|
}
|
|
404
|
-
|
|
405
|
-
|
|
1258
|
+
return redirectWithFragment(flow.redirect_to, {
|
|
1259
|
+
access_token: session.access_token,
|
|
1260
|
+
refresh_token: session.refresh_token,
|
|
1261
|
+
expires_in: String(session.expires_in),
|
|
1262
|
+
token_type: "bearer",
|
|
1263
|
+
provider_token: ""
|
|
1264
|
+
});
|
|
406
1265
|
}
|
|
407
1266
|
async exchangePkce(authCode, verifier) {
|
|
408
|
-
const res = await this.db.query(`
|
|
1267
|
+
const res = await this.db.query(`select * from auth.flow_state where auth_code = $1 and expires_at > now()`, [authCode]);
|
|
409
1268
|
const flow = res.rows[0];
|
|
410
1269
|
if (!flow || !flow.user_id)
|
|
411
1270
|
return null;
|
|
@@ -417,7 +1276,11 @@ class OAuthService {
|
|
|
417
1276
|
} else if (flow.code_challenge && flow.code_challenge !== verifier) {
|
|
418
1277
|
return null;
|
|
419
1278
|
}
|
|
420
|
-
|
|
1279
|
+
const claimed = await this.db.transaction(async (query) => {
|
|
1280
|
+
const consumed = await query(`delete from auth.flow_state where auth_code = $1 and expires_at > now() returning *`, [authCode]);
|
|
1281
|
+
return consumed.rows[0] ?? null;
|
|
1282
|
+
});
|
|
1283
|
+
return claimed?.user_id ?? null;
|
|
421
1284
|
}
|
|
422
1285
|
async exchangeCode(provider, code) {
|
|
423
1286
|
const body = new URLSearchParams({
|
|
@@ -453,6 +1316,20 @@ class OAuthService {
|
|
|
453
1316
|
async upsertUser(provider, profile) {
|
|
454
1317
|
return this.db.transaction((query) => this.upsertUserInTransaction(query, provider, profile));
|
|
455
1318
|
}
|
|
1319
|
+
async linkIdentityToUser(provider, profile, userId) {
|
|
1320
|
+
await this.db.transaction(async (query) => {
|
|
1321
|
+
await this.assertUserEligible(query, userId);
|
|
1322
|
+
const identityData = JSON.stringify({ sub: profile.id, email: profile.email, ...profile.metadata });
|
|
1323
|
+
const inserted = await query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
|
|
1324
|
+
values ($1, $2, $3, $4::jsonb)
|
|
1325
|
+
on conflict (provider, provider_id) do nothing
|
|
1326
|
+
returning user_id`, [userId, provider, profile.id, identityData]);
|
|
1327
|
+
if (inserted.rows.length === 0) {
|
|
1328
|
+
throw new OAuthIdentityLinkError("identity_already_exists");
|
|
1329
|
+
}
|
|
1330
|
+
await this.syncUserProviders(query, userId);
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
456
1333
|
async upsertUserInTransaction(query, provider, profile) {
|
|
457
1334
|
const existing = await query(`select user_id from auth.identities where provider = $1 and provider_id = $2`, [provider, profile.id]);
|
|
458
1335
|
if (existing.rows[0]) {
|
|
@@ -463,42 +1340,90 @@ class OAuthService {
|
|
|
463
1340
|
JSON.stringify({ sub: profile.id, email: profile.email, ...profile.metadata })
|
|
464
1341
|
]);
|
|
465
1342
|
await query(`update auth.users set last_sign_in_at = now() where id = $1`, [existing.rows[0].user_id]);
|
|
1343
|
+
await this.syncUserProviders(query, existing.rows[0].user_id);
|
|
466
1344
|
return existing.rows[0].user_id;
|
|
467
1345
|
}
|
|
468
1346
|
let userId;
|
|
469
|
-
const email = profile.email
|
|
470
|
-
const verified =
|
|
1347
|
+
const email = typeof profile.email === "string" ? profile.email.toLowerCase().trim() || null : null;
|
|
1348
|
+
const verified = email !== null && profile.emailVerified === true;
|
|
471
1349
|
if (email && verified) {
|
|
472
|
-
const byEmail = await query(`select id from auth.users where email = $1`, [email]);
|
|
1350
|
+
const byEmail = await query(`select id from auth.users where lower(email) = $1`, [email]);
|
|
473
1351
|
userId = byEmail.rows[0]?.id;
|
|
474
1352
|
if (userId)
|
|
475
1353
|
await this.assertUserEligible(query, userId);
|
|
476
1354
|
}
|
|
1355
|
+
let createdUserId;
|
|
477
1356
|
if (!userId) {
|
|
478
1357
|
let userEmail = verified ? email : null;
|
|
479
1358
|
if (userEmail) {
|
|
480
|
-
const taken = await query(`select 1 from auth.users where email = $1`, [userEmail]);
|
|
1359
|
+
const taken = await query(`select 1 from auth.users where lower(email) = $1`, [userEmail]);
|
|
481
1360
|
if (taken.rows.length > 0)
|
|
482
1361
|
userEmail = null;
|
|
483
1362
|
}
|
|
484
1363
|
const created = await query(`insert into auth.users (aud, role, email, email_confirmed_at, last_sign_in_at, raw_app_meta_data, raw_user_meta_data)
|
|
485
1364
|
values ('authenticated','authenticated',$1, $2, now(),
|
|
486
|
-
$3::jsonb, $4::jsonb)
|
|
1365
|
+
$3::jsonb, $4::jsonb)
|
|
1366
|
+
on conflict (email) do nothing
|
|
1367
|
+
returning id`, [
|
|
487
1368
|
userEmail,
|
|
488
1369
|
userEmail ? new Date().toISOString() : null,
|
|
489
1370
|
JSON.stringify({ provider, providers: [provider] }),
|
|
490
1371
|
JSON.stringify({ sub: profile.id, email: profile.email, full_name: profile.name, ...profile.metadata })
|
|
491
1372
|
]);
|
|
492
1373
|
const user = created.rows[0];
|
|
493
|
-
if (
|
|
1374
|
+
if (user) {
|
|
1375
|
+
userId = user.id;
|
|
1376
|
+
createdUserId = user.id;
|
|
1377
|
+
} else if (userEmail) {
|
|
1378
|
+
const existingEmail = await query(`select id from auth.users where lower(email) = $1 for update`, [userEmail]);
|
|
1379
|
+
userId = existingEmail.rows[0]?.id;
|
|
1380
|
+
if (!userId)
|
|
1381
|
+
throw new Error("OAuth email conflict has no winner");
|
|
1382
|
+
await this.assertUserEligible(query, userId);
|
|
1383
|
+
} else {
|
|
494
1384
|
throw new Error("OAuth user creation returned no row");
|
|
495
|
-
|
|
1385
|
+
}
|
|
496
1386
|
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
1387
|
+
const identityData = JSON.stringify({ sub: profile.id, email: profile.email, ...profile.metadata });
|
|
1388
|
+
const linked = await query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
|
|
1389
|
+
values ($1, $2, $3, $4::jsonb)
|
|
1390
|
+
on conflict (provider, provider_id) do nothing
|
|
1391
|
+
returning user_id`, [userId, provider, profile.id, identityData]);
|
|
1392
|
+
if (linked.rows[0]) {
|
|
1393
|
+
await this.syncUserProviders(query, linked.rows[0].user_id);
|
|
1394
|
+
return linked.rows[0].user_id;
|
|
1395
|
+
}
|
|
1396
|
+
const raced = await query(`select user_id from auth.identities where provider = $1 and provider_id = $2`, [provider, profile.id]);
|
|
1397
|
+
const racedUserId = raced.rows[0]?.user_id;
|
|
1398
|
+
if (!racedUserId)
|
|
1399
|
+
throw new Error("OAuth identity conflict has no winner");
|
|
1400
|
+
if (createdUserId && createdUserId !== racedUserId) {
|
|
1401
|
+
await query(`delete from auth.users where id = $1`, [createdUserId]);
|
|
1402
|
+
}
|
|
1403
|
+
await this.assertUserEligible(query, racedUserId);
|
|
1404
|
+
await query(`update auth.identities set last_sign_in_at = now(), identity_data = $3
|
|
1405
|
+
where provider = $1 and provider_id = $2`, [provider, profile.id, identityData]);
|
|
1406
|
+
await query(`update auth.users set last_sign_in_at = now() where id = $1`, [racedUserId]);
|
|
1407
|
+
await this.syncUserProviders(query, racedUserId);
|
|
1408
|
+
return racedUserId;
|
|
1409
|
+
}
|
|
1410
|
+
async syncUserProviders(query, userId) {
|
|
1411
|
+
await query(`update auth.users
|
|
1412
|
+
set raw_app_meta_data = jsonb_set(
|
|
1413
|
+
coalesce(raw_app_meta_data, '{}'::jsonb),
|
|
1414
|
+
'{providers}',
|
|
1415
|
+
(
|
|
1416
|
+
select jsonb_agg(provider order by provider)
|
|
1417
|
+
from (
|
|
1418
|
+
select distinct provider
|
|
1419
|
+
from auth.identities
|
|
1420
|
+
where user_id = $1
|
|
1421
|
+
) as providers
|
|
1422
|
+
),
|
|
1423
|
+
true
|
|
1424
|
+
),
|
|
1425
|
+
updated_at = now()
|
|
1426
|
+
where id = $1`, [userId]);
|
|
502
1427
|
}
|
|
503
1428
|
async assertUserEligible(query, userId) {
|
|
504
1429
|
const users = await query(`select deleted_at is not null as is_deleted,
|
|
@@ -511,9 +1436,27 @@ class OAuthService {
|
|
|
511
1436
|
throw new OAuthAccountIneligibleError("user_banned");
|
|
512
1437
|
}
|
|
513
1438
|
}
|
|
1439
|
+
async function oidcAtHash(accessToken, algorithm) {
|
|
1440
|
+
const hash = algorithm === "RS256" || algorithm === "ES256" ? "SHA-256" : null;
|
|
1441
|
+
if (!hash)
|
|
1442
|
+
return null;
|
|
1443
|
+
const digest = new Uint8Array(await crypto.subtle.digest(hash, new TextEncoder().encode(accessToken)));
|
|
1444
|
+
return base64url(digest.subarray(0, digest.length / 2));
|
|
1445
|
+
}
|
|
514
1446
|
function redirect(location) {
|
|
515
1447
|
return new Response(null, { status: 303, headers: { location } });
|
|
516
1448
|
}
|
|
1449
|
+
function redirectWithQuery(location, params) {
|
|
1450
|
+
const target = new URL(location);
|
|
1451
|
+
for (const [key, value] of Object.entries(params))
|
|
1452
|
+
target.searchParams.set(key, value);
|
|
1453
|
+
return redirect(target.toString());
|
|
1454
|
+
}
|
|
1455
|
+
function redirectWithFragment(location, params) {
|
|
1456
|
+
const target = new URL(location);
|
|
1457
|
+
target.hash = new URLSearchParams(params).toString();
|
|
1458
|
+
return redirect(target.toString());
|
|
1459
|
+
}
|
|
517
1460
|
function base64url(bytes) {
|
|
518
1461
|
let bin = "";
|
|
519
1462
|
for (const b of bytes)
|
|
@@ -1017,6 +1960,7 @@ var DEFAULT_AUTH_SETTINGS = {
|
|
|
1017
1960
|
anonymousUsers: true,
|
|
1018
1961
|
autoconfirm: true,
|
|
1019
1962
|
minPasswordLength: 6,
|
|
1963
|
+
requirePasswordReauthentication: false,
|
|
1020
1964
|
disabledProviders: [],
|
|
1021
1965
|
otpLength: 6,
|
|
1022
1966
|
otpExpirySeconds: 3600,
|
|
@@ -1026,7 +1970,13 @@ var DEFAULT_AUTH_SETTINGS = {
|
|
|
1026
1970
|
smsTemplate: "Your one-time code is {{ .Code }}",
|
|
1027
1971
|
maxEnrolledFactors: 10,
|
|
1028
1972
|
totpEnrollEnabled: true,
|
|
1029
|
-
totpVerifyEnabled: true
|
|
1973
|
+
totpVerifyEnabled: true,
|
|
1974
|
+
recoveryCodesEnrollEnabled: true,
|
|
1975
|
+
recoveryCodesVerifyEnabled: true,
|
|
1976
|
+
recoveryCodesCount: 10,
|
|
1977
|
+
recoveryCodeLength: 16,
|
|
1978
|
+
recoveryCodesMaxVerifyAttempts: 5,
|
|
1979
|
+
recoveryCodesLockoutSeconds: 300
|
|
1030
1980
|
};
|
|
1031
1981
|
function sanitize(raw) {
|
|
1032
1982
|
const s = { ...DEFAULT_AUTH_SETTINGS };
|
|
@@ -1039,6 +1989,9 @@ function sanitize(raw) {
|
|
|
1039
1989
|
if (typeof raw["minPasswordLength"] === "number" && Number.isFinite(raw["minPasswordLength"])) {
|
|
1040
1990
|
s.minPasswordLength = Math.max(4, Math.min(72, Math.floor(raw["minPasswordLength"])));
|
|
1041
1991
|
}
|
|
1992
|
+
if (typeof raw["requirePasswordReauthentication"] === "boolean") {
|
|
1993
|
+
s.requirePasswordReauthentication = raw["requirePasswordReauthentication"];
|
|
1994
|
+
}
|
|
1042
1995
|
if (Array.isArray(raw["disabledProviders"])) {
|
|
1043
1996
|
s.disabledProviders = raw["disabledProviders"].filter((p) => typeof p === "string").slice(0, 50);
|
|
1044
1997
|
}
|
|
@@ -1065,6 +2018,22 @@ function sanitize(raw) {
|
|
|
1065
2018
|
s.totpEnrollEnabled = raw["totpEnrollEnabled"];
|
|
1066
2019
|
if (typeof raw["totpVerifyEnabled"] === "boolean")
|
|
1067
2020
|
s.totpVerifyEnabled = raw["totpVerifyEnabled"];
|
|
2021
|
+
if (typeof raw["recoveryCodesEnrollEnabled"] === "boolean")
|
|
2022
|
+
s.recoveryCodesEnrollEnabled = raw["recoveryCodesEnrollEnabled"];
|
|
2023
|
+
if (typeof raw["recoveryCodesVerifyEnabled"] === "boolean")
|
|
2024
|
+
s.recoveryCodesVerifyEnabled = raw["recoveryCodesVerifyEnabled"];
|
|
2025
|
+
if (typeof raw["recoveryCodesCount"] === "number" && Number.isFinite(raw["recoveryCodesCount"])) {
|
|
2026
|
+
s.recoveryCodesCount = Math.max(1, Math.min(50, Math.floor(raw["recoveryCodesCount"])));
|
|
2027
|
+
}
|
|
2028
|
+
if (typeof raw["recoveryCodeLength"] === "number" && Number.isFinite(raw["recoveryCodeLength"])) {
|
|
2029
|
+
s.recoveryCodeLength = Math.max(8, Math.min(64, Math.floor(raw["recoveryCodeLength"])));
|
|
2030
|
+
}
|
|
2031
|
+
if (typeof raw["recoveryCodesMaxVerifyAttempts"] === "number" && Number.isFinite(raw["recoveryCodesMaxVerifyAttempts"])) {
|
|
2032
|
+
s.recoveryCodesMaxVerifyAttempts = Math.max(1, Math.min(20, Math.floor(raw["recoveryCodesMaxVerifyAttempts"])));
|
|
2033
|
+
}
|
|
2034
|
+
if (typeof raw["recoveryCodesLockoutSeconds"] === "number" && Number.isFinite(raw["recoveryCodesLockoutSeconds"])) {
|
|
2035
|
+
s.recoveryCodesLockoutSeconds = Math.max(1, Math.min(86400, Math.floor(raw["recoveryCodesLockoutSeconds"])));
|
|
2036
|
+
}
|
|
1068
2037
|
return s;
|
|
1069
2038
|
}
|
|
1070
2039
|
async function loadAuthSettings(db, defaults = {}) {
|
|
@@ -1203,10 +2172,156 @@ async function verifyTotp(secretBase32, code, window = 1, atMs = Date.now()) {
|
|
|
1203
2172
|
return false;
|
|
1204
2173
|
}
|
|
1205
2174
|
|
|
2175
|
+
// src/runtime/auth/web3.ts
|
|
2176
|
+
var BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
2177
|
+
var CLOCK_SKEW_SECONDS = 60;
|
|
2178
|
+
function decodeBase58(value) {
|
|
2179
|
+
if (!value)
|
|
2180
|
+
return null;
|
|
2181
|
+
const bytes = [];
|
|
2182
|
+
for (const character of value) {
|
|
2183
|
+
const digit = BASE58_ALPHABET.indexOf(character);
|
|
2184
|
+
if (digit < 0)
|
|
2185
|
+
return null;
|
|
2186
|
+
let carry = digit;
|
|
2187
|
+
for (let index = 0;index < bytes.length; index += 1) {
|
|
2188
|
+
carry += bytes[index] * 58;
|
|
2189
|
+
bytes[index] = carry & 255;
|
|
2190
|
+
carry >>= 8;
|
|
2191
|
+
}
|
|
2192
|
+
while (carry > 0) {
|
|
2193
|
+
bytes.push(carry & 255);
|
|
2194
|
+
carry >>= 8;
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
let leadingZeros = 0;
|
|
2198
|
+
while (leadingZeros < value.length && value[leadingZeros] === "1")
|
|
2199
|
+
leadingZeros += 1;
|
|
2200
|
+
return new Uint8Array([...new Array(leadingZeros).fill(0), ...bytes.reverse()]);
|
|
2201
|
+
}
|
|
2202
|
+
function encodeBase58(value) {
|
|
2203
|
+
if (value.length === 0)
|
|
2204
|
+
return "";
|
|
2205
|
+
const digits = [];
|
|
2206
|
+
for (const byte of value) {
|
|
2207
|
+
let carry = byte;
|
|
2208
|
+
for (let index = 0;index < digits.length; index += 1) {
|
|
2209
|
+
carry += digits[index] * 256;
|
|
2210
|
+
digits[index] = carry % 58;
|
|
2211
|
+
carry = Math.floor(carry / 58);
|
|
2212
|
+
}
|
|
2213
|
+
while (carry > 0) {
|
|
2214
|
+
digits.push(carry % 58);
|
|
2215
|
+
carry = Math.floor(carry / 58);
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
let leadingZeros = 0;
|
|
2219
|
+
while (leadingZeros < value.length && value[leadingZeros] === 0)
|
|
2220
|
+
leadingZeros += 1;
|
|
2221
|
+
return `${"1".repeat(leadingZeros)}${digits.reverse().map((digit) => BASE58_ALPHABET[digit]).join("")}`;
|
|
2222
|
+
}
|
|
2223
|
+
function field(lines, name) {
|
|
2224
|
+
const prefix = `${name}: `;
|
|
2225
|
+
const line = lines.find((entry) => entry.startsWith(prefix));
|
|
2226
|
+
return line?.slice(prefix.length);
|
|
2227
|
+
}
|
|
2228
|
+
function unixSeconds(value) {
|
|
2229
|
+
if (!value)
|
|
2230
|
+
return;
|
|
2231
|
+
const time = Date.parse(value);
|
|
2232
|
+
return Number.isFinite(time) ? Math.floor(time / 1000) : undefined;
|
|
2233
|
+
}
|
|
2234
|
+
function parseSolanaSignInMessage(message) {
|
|
2235
|
+
if (!message || message.length > 16384)
|
|
2236
|
+
return null;
|
|
2237
|
+
const lines = message.split(`
|
|
2238
|
+
`);
|
|
2239
|
+
const header = lines[0]?.match(/^(.+) wants you to sign in with your Solana account:$/);
|
|
2240
|
+
const address = lines[1]?.trim();
|
|
2241
|
+
const uri = field(lines, "URI");
|
|
2242
|
+
const version = field(lines, "Version");
|
|
2243
|
+
const issuedAt = unixSeconds(field(lines, "Issued At"));
|
|
2244
|
+
if (!header || !address || !uri || version !== "1" || issuedAt === undefined)
|
|
2245
|
+
return null;
|
|
2246
|
+
const publicKey = decodeBase58(address);
|
|
2247
|
+
if (!publicKey || publicKey.length !== 32 || encodeBase58(publicKey) !== address)
|
|
2248
|
+
return null;
|
|
2249
|
+
let parsedUri;
|
|
2250
|
+
try {
|
|
2251
|
+
parsedUri = new URL(uri);
|
|
2252
|
+
} catch {
|
|
2253
|
+
return null;
|
|
2254
|
+
}
|
|
2255
|
+
if (parsedUri.host !== header[1])
|
|
2256
|
+
return null;
|
|
2257
|
+
const expirationTime = unixSeconds(field(lines, "Expiration Time"));
|
|
2258
|
+
const notBefore = unixSeconds(field(lines, "Not Before"));
|
|
2259
|
+
if (field(lines, "Expiration Time") && expirationTime === undefined)
|
|
2260
|
+
return null;
|
|
2261
|
+
if (field(lines, "Not Before") && notBefore === undefined)
|
|
2262
|
+
return null;
|
|
2263
|
+
const now = Math.floor(Date.now() / 1000);
|
|
2264
|
+
if (issuedAt > now + CLOCK_SKEW_SECONDS)
|
|
2265
|
+
return null;
|
|
2266
|
+
if (expirationTime !== undefined && expirationTime <= now)
|
|
2267
|
+
return null;
|
|
2268
|
+
if (notBefore !== undefined && notBefore > now + CLOCK_SKEW_SECONDS)
|
|
2269
|
+
return null;
|
|
2270
|
+
return {
|
|
2271
|
+
address,
|
|
2272
|
+
domain: header[1],
|
|
2273
|
+
uri,
|
|
2274
|
+
issuedAt,
|
|
2275
|
+
...expirationTime === undefined ? {} : { expirationTime },
|
|
2276
|
+
...notBefore === undefined ? {} : { notBefore },
|
|
2277
|
+
...field(lines, "Nonce") === undefined ? {} : { nonce: field(lines, "Nonce") }
|
|
2278
|
+
};
|
|
2279
|
+
}
|
|
2280
|
+
async function verifySolanaSignIn(message, signature) {
|
|
2281
|
+
const parsed = parseSolanaSignInMessage(message);
|
|
2282
|
+
if (!parsed)
|
|
2283
|
+
return null;
|
|
2284
|
+
const publicKey = decodeBase58(parsed.address);
|
|
2285
|
+
if (!publicKey)
|
|
2286
|
+
return null;
|
|
2287
|
+
let signatureBytes;
|
|
2288
|
+
try {
|
|
2289
|
+
const normalized = signature.replace(/-/g, "+").replace(/_/g, "/");
|
|
2290
|
+
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
|
|
2291
|
+
signatureBytes = Uint8Array.from(Buffer.from(padded, "base64"));
|
|
2292
|
+
} catch {
|
|
2293
|
+
return null;
|
|
2294
|
+
}
|
|
2295
|
+
if (signatureBytes.length !== 64)
|
|
2296
|
+
return null;
|
|
2297
|
+
try {
|
|
2298
|
+
const publicKeyBuffer = Uint8Array.from(publicKey).buffer;
|
|
2299
|
+
const signatureBuffer = Uint8Array.from(signatureBytes).buffer;
|
|
2300
|
+
const messageBuffer = new TextEncoder().encode(message).buffer;
|
|
2301
|
+
const key = await crypto.subtle.importKey("raw", publicKeyBuffer, "Ed25519", false, ["verify"]);
|
|
2302
|
+
const valid = await crypto.subtle.verify("Ed25519", key, signatureBuffer, messageBuffer);
|
|
2303
|
+
return valid ? parsed : null;
|
|
2304
|
+
} catch {
|
|
2305
|
+
return null;
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
|
|
1206
2309
|
// src/runtime/auth/handler.ts
|
|
1207
2310
|
function authError(status, errorCode, msg) {
|
|
1208
2311
|
return json(status, { code: status, error_code: errorCode, msg });
|
|
1209
2312
|
}
|
|
2313
|
+
function normalizeEmailInput(value) {
|
|
2314
|
+
if (typeof value !== "string")
|
|
2315
|
+
return null;
|
|
2316
|
+
const email = value.trim().toLowerCase();
|
|
2317
|
+
return email || null;
|
|
2318
|
+
}
|
|
2319
|
+
function base64url2(bytes) {
|
|
2320
|
+
let binary = "";
|
|
2321
|
+
for (const byte of bytes)
|
|
2322
|
+
binary += String.fromCharCode(byte);
|
|
2323
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
2324
|
+
}
|
|
1210
2325
|
function randomOtp(length) {
|
|
1211
2326
|
const n = Math.max(6, Math.min(10, Math.floor(length)));
|
|
1212
2327
|
let code = "";
|
|
@@ -1232,9 +2347,41 @@ function normalizePhone(value) {
|
|
|
1232
2347
|
const phone = value.trim();
|
|
1233
2348
|
return /^\+[1-9]\d{7,14}$/.test(phone) ? phone : null;
|
|
1234
2349
|
}
|
|
2350
|
+
function banDurationSeconds(value) {
|
|
2351
|
+
if (value === "none")
|
|
2352
|
+
return null;
|
|
2353
|
+
if (typeof value !== "string")
|
|
2354
|
+
return;
|
|
2355
|
+
const match = value.trim().match(/^(\d+(?:\.\d+)?)(ns|us|\u00B5s|ms|s|m|h)$/);
|
|
2356
|
+
if (!match)
|
|
2357
|
+
return;
|
|
2358
|
+
const amount = Number(match[1]);
|
|
2359
|
+
const multipliers = { ns: 0.000000001, us: 0.000001, "\xB5s": 0.000001, ms: 0.001, s: 1, m: 60, h: 3600 };
|
|
2360
|
+
const multiplier = multipliers[match[2]];
|
|
2361
|
+
const seconds = amount * multiplier;
|
|
2362
|
+
return Number.isFinite(seconds) ? seconds : undefined;
|
|
2363
|
+
}
|
|
1235
2364
|
function recordValue(value) {
|
|
1236
2365
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
|
|
1237
2366
|
}
|
|
2367
|
+
var RECOVERY_CODE_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
|
|
2368
|
+
function normalizeRecoveryCode(value) {
|
|
2369
|
+
return typeof value === "string" ? value.replace(/[\s-]/g, "").toLowerCase() : "";
|
|
2370
|
+
}
|
|
2371
|
+
function randomRecoveryCode(length) {
|
|
2372
|
+
let code = "";
|
|
2373
|
+
while (code.length < length) {
|
|
2374
|
+
const bytes = crypto.getRandomValues(new Uint8Array(Math.max(16, length - code.length)));
|
|
2375
|
+
for (const byte of bytes) {
|
|
2376
|
+
if (byte >= 224)
|
|
2377
|
+
continue;
|
|
2378
|
+
code += RECOVERY_CODE_ALPHABET[byte % RECOVERY_CODE_ALPHABET.length];
|
|
2379
|
+
if (code.length === length)
|
|
2380
|
+
break;
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
return code;
|
|
2384
|
+
}
|
|
1238
2385
|
function databaseDiagnosticCode(error) {
|
|
1239
2386
|
const candidate = typeof error === "object" && error !== null && "code" in error ? error.code : null;
|
|
1240
2387
|
return typeof candidate === "string" && /^[0-9A-Z]{5}$/.test(candidate) ? candidate : "database_error";
|
|
@@ -1258,12 +2405,21 @@ async function sendSmsUntilAbort(sender, message, signal) {
|
|
|
1258
2405
|
signal.removeEventListener("abort", rejectForAbort);
|
|
1259
2406
|
}
|
|
1260
2407
|
}
|
|
1261
|
-
function json(status, body) {
|
|
2408
|
+
function json(status, body, extraHeaders = {}) {
|
|
1262
2409
|
return new Response(status === 204 ? null : JSON.stringify(body), {
|
|
1263
2410
|
status,
|
|
1264
|
-
headers: { "content-type": "application/json; charset=utf-8" }
|
|
2411
|
+
headers: { "content-type": "application/json; charset=utf-8", ...extraHeaders }
|
|
1265
2412
|
});
|
|
1266
2413
|
}
|
|
2414
|
+
function redirect2(location) {
|
|
2415
|
+
return new Response(null, { status: 303, headers: { location } });
|
|
2416
|
+
}
|
|
2417
|
+
function redirectWithQuery2(location, params) {
|
|
2418
|
+
const target = new URL(location);
|
|
2419
|
+
for (const [key, value] of Object.entries(params))
|
|
2420
|
+
target.searchParams.set(key, value);
|
|
2421
|
+
return redirect2(target.toString());
|
|
2422
|
+
}
|
|
1267
2423
|
function iso(v) {
|
|
1268
2424
|
if (v === null || v === undefined)
|
|
1269
2425
|
return null;
|
|
@@ -1360,26 +2516,61 @@ class AuthHandler {
|
|
|
1360
2516
|
return await this.getUser(req);
|
|
1361
2517
|
if (path === "user" && method === "PUT")
|
|
1362
2518
|
return await this.updateUser(req);
|
|
2519
|
+
if (/^user\/identities\/[^/]+$/.test(path) && method === "DELETE") {
|
|
2520
|
+
return await this.unlinkIdentity(req, decodeURIComponent(path.split("/")[2]));
|
|
2521
|
+
}
|
|
2522
|
+
if (path === "reauthenticate" && (method === "GET" || method === "POST"))
|
|
2523
|
+
return await this.reauthenticate(req);
|
|
1363
2524
|
if (path === "logout" && method === "POST")
|
|
1364
2525
|
return await this.logout(req, url);
|
|
1365
2526
|
if (path === "otp" && method === "POST")
|
|
1366
2527
|
return await this.sendOtp(req);
|
|
1367
2528
|
if (path === "recover" && method === "POST")
|
|
1368
2529
|
return this.limit("recover", req) ?? await this.sendRecovery(req);
|
|
1369
|
-
if (
|
|
2530
|
+
if (path === "magiclink" && method === "POST")
|
|
1370
2531
|
return await this.sendOtp(req);
|
|
2532
|
+
if (path === "resend" && method === "POST")
|
|
2533
|
+
return await this.sendOtp(req, true);
|
|
1371
2534
|
if (path === "verify" && method === "POST")
|
|
1372
2535
|
return this.limit("verify", req) ?? await this.verifyToken(req);
|
|
1373
2536
|
if (path === "verify" && method === "GET")
|
|
1374
2537
|
return await this.verifyLink(url);
|
|
2538
|
+
if (path === "sso" && method === "POST")
|
|
2539
|
+
return await this.sso(req);
|
|
2540
|
+
if (path === "invite" && method === "POST") {
|
|
2541
|
+
if (ctx.role !== "service_role") {
|
|
2542
|
+
return authError(403, "insufficient_permissions", "Admin endpoints require the service_role key");
|
|
2543
|
+
}
|
|
2544
|
+
return await this.generateAdminMagicLink(req, true, "invite");
|
|
2545
|
+
}
|
|
1375
2546
|
if (path === "factors" && method === "POST")
|
|
1376
2547
|
return await this.enrollFactor(req);
|
|
2548
|
+
if (path === "factors/recovery-codes" && method === "GET")
|
|
2549
|
+
return await this.recoveryCodesStatus(req);
|
|
2550
|
+
if (path === "factors/recovery-codes" && method === "POST")
|
|
2551
|
+
return await this.generateRecoveryCodes(req);
|
|
2552
|
+
if (path === "factors/recovery-codes" && method === "DELETE")
|
|
2553
|
+
return await this.deleteRecoveryCodes(req);
|
|
2554
|
+
if (path === "factors/recovery-codes/verify" && method === "POST")
|
|
2555
|
+
return await this.verifyRecoveryCode(req);
|
|
2556
|
+
if (path === "factors/recovery-codes/regenerate" && method === "POST")
|
|
2557
|
+
return await this.regenerateRecoveryCodes(req);
|
|
1377
2558
|
if (/^factors\/[^/]+\/challenge$/.test(path) && method === "POST")
|
|
1378
2559
|
return await this.challengeFactor(req, path.split("/")[1]);
|
|
1379
2560
|
if (/^factors\/[^/]+\/verify$/.test(path) && method === "POST")
|
|
1380
2561
|
return await this.verifyFactor(req, path.split("/")[1]);
|
|
1381
2562
|
if (/^factors\/[^/]+$/.test(path) && method === "DELETE")
|
|
1382
2563
|
return await this.unenrollFactor(req, path.split("/")[1]);
|
|
2564
|
+
if (path === "user/identities/authorize" && method === "GET") {
|
|
2565
|
+
const user = await this.userFromBearer(req);
|
|
2566
|
+
if (!user)
|
|
2567
|
+
return authError(401, "no_authorization", "Invalid or expired token");
|
|
2568
|
+
const provider = url.searchParams.get("provider") ?? "";
|
|
2569
|
+
if (provider && this.settings.disabledProviders.includes(provider)) {
|
|
2570
|
+
return authError(422, "provider_disabled", `Sign-ins with ${provider} are disabled`);
|
|
2571
|
+
}
|
|
2572
|
+
return await this.oauth.authorize(url, user.id);
|
|
2573
|
+
}
|
|
1383
2574
|
if (path === "authorize" && method === "GET") {
|
|
1384
2575
|
const provider = url.searchParams.get("provider") ?? "";
|
|
1385
2576
|
if (provider && this.settings.disabledProviders.includes(provider)) {
|
|
@@ -1397,8 +2588,10 @@ class AuthHandler {
|
|
|
1397
2588
|
} catch (e) {
|
|
1398
2589
|
if (e instanceof OAuthAccountIneligibleError)
|
|
1399
2590
|
return authError(400, e.code, e.message);
|
|
1400
|
-
|
|
1401
|
-
|
|
2591
|
+
try {
|
|
2592
|
+
this.config.log?.(`[auth] request_failed code=${databaseDiagnosticCode(e)}`);
|
|
2593
|
+
} catch {}
|
|
2594
|
+
return authError(500, "unexpected_failure", "Unable to complete authentication request");
|
|
1402
2595
|
}
|
|
1403
2596
|
}
|
|
1404
2597
|
async oauthCallbackUrl(req, url) {
|
|
@@ -1412,69 +2605,118 @@ class AuthHandler {
|
|
|
1412
2605
|
return callbackUrl;
|
|
1413
2606
|
}
|
|
1414
2607
|
async signup(req) {
|
|
1415
|
-
|
|
1416
|
-
|
|
2608
|
+
let body;
|
|
2609
|
+
try {
|
|
2610
|
+
const parsed = await req.json();
|
|
2611
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
2612
|
+
return authError(400, "validation_failed", "Signup body must be a JSON object");
|
|
2613
|
+
}
|
|
2614
|
+
body = parsed;
|
|
2615
|
+
} catch {
|
|
2616
|
+
return authError(400, "validation_failed", "Signup body must be valid JSON");
|
|
2617
|
+
}
|
|
2618
|
+
if (!("email" in body) && !("password" in body)) {
|
|
1417
2619
|
if (!this.settings.anonymousUsers) {
|
|
1418
2620
|
return authError(422, "anonymous_provider_disabled", "Anonymous sign-ins are disabled");
|
|
1419
2621
|
}
|
|
1420
2622
|
const res = await this.db.query(`insert into auth.users (aud, role, raw_app_meta_data, raw_user_meta_data, is_anonymous, last_sign_in_at)
|
|
1421
|
-
values ('authenticated', 'authenticated', '{}', $1, true, now())
|
|
2623
|
+
values ('authenticated', 'authenticated', '{"provider":"anonymous","providers":["anonymous"]}', $1, true, now())
|
|
1422
2624
|
returning *`, [JSON.stringify(body.data ?? {})]);
|
|
1423
|
-
return json(200, await this.sessionFor(res.rows[0]
|
|
2625
|
+
return json(200, await this.sessionFor(res.rows[0], undefined, {
|
|
2626
|
+
amr: [{ method: "anonymous", timestamp: Math.floor(Date.now() / 1000) }]
|
|
2627
|
+
}));
|
|
1424
2628
|
}
|
|
1425
2629
|
if (this.settings.disableSignup) {
|
|
1426
2630
|
return authError(422, "signup_disabled", "Signups not allowed for this instance");
|
|
1427
2631
|
}
|
|
1428
|
-
|
|
2632
|
+
const email = normalizeEmailInput(body.email);
|
|
2633
|
+
if (!email || !body.password) {
|
|
1429
2634
|
return authError(400, "validation_failed", "Signup requires a valid email and password");
|
|
1430
2635
|
}
|
|
1431
2636
|
if (body.password.length < this.settings.minPasswordLength) {
|
|
1432
2637
|
return authError(422, "weak_password", `Password should be at least ${this.settings.minPasswordLength} characters.`);
|
|
1433
2638
|
}
|
|
1434
|
-
const email = body.email.toLowerCase().trim();
|
|
1435
|
-
const existing = await this.db.query(`select id from auth.users where email = $1`, [email]);
|
|
1436
|
-
if (existing.rows.length > 0) {
|
|
1437
|
-
return authError(422, "user_already_exists", "User already registered");
|
|
1438
|
-
}
|
|
1439
2639
|
const hashed = await hashPassword(body.password);
|
|
1440
2640
|
const autoconfirm = this.settings.autoconfirm;
|
|
1441
|
-
const
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
2641
|
+
const newUser = await this.db.transaction(async (query) => {
|
|
2642
|
+
const res = await query(`insert into auth.users
|
|
2643
|
+
(aud, role, email, encrypted_password, email_confirmed_at, last_sign_in_at,
|
|
2644
|
+
raw_app_meta_data, raw_user_meta_data)
|
|
2645
|
+
values ('authenticated', 'authenticated', $1, $2, case when $4 then now() else null end,
|
|
2646
|
+
case when $4 then now() else null end,
|
|
2647
|
+
'{"provider":"email","providers":["email"]}', $3)
|
|
2648
|
+
on conflict (email) do nothing
|
|
2649
|
+
returning *`, [email, hashed, JSON.stringify(body.data ?? {}), autoconfirm]);
|
|
2650
|
+
const user = res.rows[0];
|
|
2651
|
+
if (!user)
|
|
2652
|
+
return null;
|
|
2653
|
+
await query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
|
|
2654
|
+
values ($1, 'email', $2, $3)
|
|
2655
|
+
on conflict (provider, provider_id) do nothing`, [user.id, user.id, JSON.stringify({ sub: user.id, email: user.email })]);
|
|
2656
|
+
return user;
|
|
2657
|
+
});
|
|
2658
|
+
if (!newUser)
|
|
2659
|
+
return authError(422, "user_already_exists", "User already registered");
|
|
1451
2660
|
await this.audit("user_signedup", { actorId: newUser.id, actorEmail: email });
|
|
1452
2661
|
if (!autoconfirm) {
|
|
1453
|
-
await this.issueToken(email, "otp", false, "confirm");
|
|
2662
|
+
await this.issueToken(email, "otp", false, "confirm", {}, new URL(req.url).searchParams.get("redirect_to"), body.code_challenge, body.code_challenge_method);
|
|
1454
2663
|
return json(200, this.userJson(newUser));
|
|
1455
2664
|
}
|
|
1456
2665
|
return json(200, await this.sessionFor(newUser));
|
|
1457
2666
|
}
|
|
2667
|
+
async sso(req) {
|
|
2668
|
+
const body = await req.json().catch(() => ({}));
|
|
2669
|
+
const providerId = typeof body.provider_id === "string" ? body.provider_id.trim().toLowerCase() : "";
|
|
2670
|
+
const domain = typeof body.domain === "string" ? body.domain.trim().toLowerCase() : "";
|
|
2671
|
+
const providers = this.config.oauthProviders ?? {};
|
|
2672
|
+
const provider = providerId && providers[providerId] ? providerId : Object.entries(providers).find(([, config]) => domain && (config.ssoDomains ?? []).some((item) => item.toLowerCase() === domain))?.[0] ?? "";
|
|
2673
|
+
if (!provider)
|
|
2674
|
+
return authError(404, "sso_provider_not_found", "SSO provider not found");
|
|
2675
|
+
const authorizeUrl = new URL(req.url);
|
|
2676
|
+
authorizeUrl.pathname = authorizeUrl.pathname.replace(/\/sso$/, "/authorize");
|
|
2677
|
+
authorizeUrl.search = "";
|
|
2678
|
+
authorizeUrl.searchParams.set("provider", provider);
|
|
2679
|
+
authorizeUrl.searchParams.set("skip_http_redirect", String(body.skip_http_redirect === true));
|
|
2680
|
+
for (const [key, value] of [
|
|
2681
|
+
["redirect_to", body.redirect_to],
|
|
2682
|
+
["code_challenge", body.code_challenge],
|
|
2683
|
+
["code_challenge_method", body.code_challenge_method]
|
|
2684
|
+
]) {
|
|
2685
|
+
if (value)
|
|
2686
|
+
authorizeUrl.searchParams.set(key, value);
|
|
2687
|
+
}
|
|
2688
|
+
return this.oauth.authorize(authorizeUrl);
|
|
2689
|
+
}
|
|
1458
2690
|
async token(req, url) {
|
|
1459
2691
|
const grantType = url.searchParams.get("grant_type");
|
|
1460
2692
|
const body = await req.json().catch(() => ({}));
|
|
1461
2693
|
if (grantType === "password") {
|
|
1462
|
-
const email =
|
|
2694
|
+
const email = body.email === undefined ? null : normalizeEmailInput(body.email);
|
|
2695
|
+
if (body.email !== undefined && !email)
|
|
2696
|
+
return authError(400, "validation_failed", "email must not be empty");
|
|
2697
|
+
const phone = body.phone === undefined ? null : normalizePhone(body.phone);
|
|
2698
|
+
if (body.phone !== undefined && !phone)
|
|
2699
|
+
return authError(400, "validation_failed", "phone must be a valid E.164 number");
|
|
2700
|
+
if (!email && !phone)
|
|
2701
|
+
return authError(400, "validation_failed", "email or phone is required");
|
|
1463
2702
|
const res = await this.db.query(`select *, coalesce(banned_until > now(), false) as is_banned
|
|
1464
|
-
from auth.users
|
|
2703
|
+
from auth.users
|
|
2704
|
+
where (($1::text is not null and lower(email) = $1)
|
|
2705
|
+
or ($2::text is not null and phone = $2))
|
|
2706
|
+
and deleted_at is null`, [email, phone]);
|
|
1465
2707
|
const user = res.rows[0];
|
|
1466
2708
|
if (user?.encrypted_password && user.is_banned) {
|
|
1467
2709
|
return authError(400, "user_banned", "User is banned");
|
|
1468
2710
|
}
|
|
1469
2711
|
if (!user || !user.encrypted_password || !await verifyPassword(body.password ?? "", user.encrypted_password)) {
|
|
1470
|
-
await this.audit("login_failed", { actorEmail: email, traits: { grant_type: "password" } });
|
|
2712
|
+
await this.audit("login_failed", { actorEmail: email ?? phone, traits: { grant_type: "password" } });
|
|
1471
2713
|
return authError(400, "invalid_credentials", "Invalid login credentials");
|
|
1472
2714
|
}
|
|
1473
2715
|
const response = await this.db.transaction(async (query) => {
|
|
1474
2716
|
const currentResult = await query(`select *, coalesce(banned_until > now(), false) as is_banned
|
|
1475
2717
|
from auth.users where id = $1 for update`, [user.id]);
|
|
1476
2718
|
const current = currentResult.rows[0];
|
|
1477
|
-
if (!current || current.deleted_at !== null || !current.encrypted_password || current.email !== email) {
|
|
2719
|
+
if (!current || current.deleted_at !== null || !current.encrypted_password || (email ? current.email?.toLowerCase().trim() !== email : current.phone !== phone)) {
|
|
1478
2720
|
return authError(400, "invalid_credentials", "Invalid login credentials");
|
|
1479
2721
|
}
|
|
1480
2722
|
if (current.is_banned) {
|
|
@@ -1483,7 +2725,10 @@ class AuthHandler {
|
|
|
1483
2725
|
if (current.encrypted_password !== user.encrypted_password) {
|
|
1484
2726
|
return authError(400, "invalid_credentials", "Invalid login credentials");
|
|
1485
2727
|
}
|
|
1486
|
-
if (
|
|
2728
|
+
if (phone && !current.phone_confirmed_at) {
|
|
2729
|
+
return authError(400, "phone_not_confirmed", "Phone not confirmed");
|
|
2730
|
+
}
|
|
2731
|
+
if (email && !this.settings.autoconfirm && !current.email_confirmed_at) {
|
|
1487
2732
|
return authError(400, "email_not_confirmed", "Email not confirmed");
|
|
1488
2733
|
}
|
|
1489
2734
|
const signedIn = await query(`update auth.users set last_sign_in_at = now() where id = $1 returning *`, [current.id]);
|
|
@@ -1494,7 +2739,7 @@ class AuthHandler {
|
|
|
1494
2739
|
return json(200, await this.sessionFor(updated, undefined, undefined, query));
|
|
1495
2740
|
});
|
|
1496
2741
|
if (response.ok) {
|
|
1497
|
-
await this.audit("login", { actorId: user.id, actorEmail: email, traits: { grant_type: "password" } });
|
|
2742
|
+
await this.audit("login", { actorId: user.id, actorEmail: user.email ?? user.phone, traits: { grant_type: "password" } });
|
|
1498
2743
|
}
|
|
1499
2744
|
return response;
|
|
1500
2745
|
}
|
|
@@ -1509,11 +2754,72 @@ class AuthHandler {
|
|
|
1509
2754
|
const verifier = body.code_verifier;
|
|
1510
2755
|
if (!authCode || !verifier)
|
|
1511
2756
|
return authError(400, "validation_failed", "auth_code and code_verifier required");
|
|
2757
|
+
const emailClaim = await this.exchangeEmailPkce(authCode, verifier);
|
|
2758
|
+
if (emailClaim) {
|
|
2759
|
+
return json(200, await this.sessionForEmailPkce(emailClaim.userId, emailClaim.provider));
|
|
2760
|
+
}
|
|
1512
2761
|
const userId = await this.oauth.exchangePkce(authCode, verifier);
|
|
1513
2762
|
if (!userId)
|
|
1514
2763
|
return authError(403, "flow_state_not_found", "invalid or expired auth code");
|
|
1515
2764
|
return json(200, await this.sessionForOAuth(userId));
|
|
1516
2765
|
}
|
|
2766
|
+
if (grantType === "id_token") {
|
|
2767
|
+
const provider = typeof body.provider === "string" ? body.provider.toLowerCase() : "";
|
|
2768
|
+
const idToken = body.id_token;
|
|
2769
|
+
if (!provider || !idToken)
|
|
2770
|
+
return authError(400, "validation_failed", "provider and id_token required");
|
|
2771
|
+
if (String(body.link_identity) === "true" || body.link_identity === "1") {
|
|
2772
|
+
const current = await this.userFromBearer(req);
|
|
2773
|
+
if (!current)
|
|
2774
|
+
return authError(401, "no_authorization", "This endpoint requires a Bearer token");
|
|
2775
|
+
try {
|
|
2776
|
+
const linked = await this.oauth.linkIdentityWithIdToken(provider, idToken, current.id, body.access_token, body.nonce);
|
|
2777
|
+
if (!linked)
|
|
2778
|
+
return authError(400, "invalid_credentials", "Invalid ID token");
|
|
2779
|
+
} catch (error) {
|
|
2780
|
+
if (error instanceof OAuthIdentityLinkError) {
|
|
2781
|
+
return authError(422, error.code, "Identity is already linked");
|
|
2782
|
+
}
|
|
2783
|
+
throw error;
|
|
2784
|
+
}
|
|
2785
|
+
return this.sessionForOAuth(current.id).then((session) => json(200, session));
|
|
2786
|
+
}
|
|
2787
|
+
const userId = await this.oauth.signInWithIdToken(provider, idToken, body.access_token, body.nonce);
|
|
2788
|
+
if (!userId)
|
|
2789
|
+
return authError(400, "invalid_credentials", "Invalid ID token");
|
|
2790
|
+
return this.db.transaction(async (query) => {
|
|
2791
|
+
const result = await query(`select * from auth.users where id = $1 and deleted_at is null
|
|
2792
|
+
and (banned_until is null or banned_until <= now()) for update`, [userId]);
|
|
2793
|
+
const user = result.rows[0];
|
|
2794
|
+
if (!user)
|
|
2795
|
+
return authError(400, "invalid_credentials", "Invalid ID token");
|
|
2796
|
+
return json(200, await this.sessionFor(user, undefined, {
|
|
2797
|
+
amr: [{ method: "oauth", timestamp: Math.floor(Date.now() / 1000) }]
|
|
2798
|
+
}, query));
|
|
2799
|
+
});
|
|
2800
|
+
}
|
|
2801
|
+
if (grantType === "web3") {
|
|
2802
|
+
if (body.chain !== "solana" || !body.message || !body.signature) {
|
|
2803
|
+
return authError(400, "validation_failed", "chain, message and signature are required");
|
|
2804
|
+
}
|
|
2805
|
+
const wallet = await verifySolanaSignIn(body.message, body.signature);
|
|
2806
|
+
if (!wallet)
|
|
2807
|
+
return authError(400, "invalid_credentials", "Invalid Web3 credentials");
|
|
2808
|
+
const userId = await this.oauth.signInWithProfile("solana", {
|
|
2809
|
+
id: wallet.address,
|
|
2810
|
+
metadata: { address: wallet.address, chain: "solana" }
|
|
2811
|
+
});
|
|
2812
|
+
return this.db.transaction(async (query) => {
|
|
2813
|
+
const result = await query(`select * from auth.users where id = $1 and deleted_at is null
|
|
2814
|
+
and (banned_until is null or banned_until <= now()) for update`, [userId]);
|
|
2815
|
+
const user = result.rows[0];
|
|
2816
|
+
if (!user)
|
|
2817
|
+
return authError(400, "invalid_credentials", "Invalid Web3 credentials");
|
|
2818
|
+
return json(200, await this.sessionFor(user, undefined, {
|
|
2819
|
+
amr: [{ method: "web3", timestamp: Math.floor(Date.now() / 1000) }]
|
|
2820
|
+
}, query));
|
|
2821
|
+
});
|
|
2822
|
+
}
|
|
1517
2823
|
return authError(400, "invalid_grant", `unsupported grant_type: ${grantType}`);
|
|
1518
2824
|
}
|
|
1519
2825
|
async getUser(req) {
|
|
@@ -1522,15 +2828,65 @@ class AuthHandler {
|
|
|
1522
2828
|
return authError(401, "no_authorization", "Invalid or expired token");
|
|
1523
2829
|
return json(200, this.userJson(user, await this.getUserFactors(user.id), await this.getUserIdentities(user.id)));
|
|
1524
2830
|
}
|
|
2831
|
+
async unlinkIdentity(req, identityId) {
|
|
2832
|
+
const user = await this.userFromBearer(req);
|
|
2833
|
+
if (!user)
|
|
2834
|
+
return authError(401, "no_authorization", "Invalid or expired token");
|
|
2835
|
+
return this.db.transaction(async (query) => {
|
|
2836
|
+
const current = (await query(`select * from auth.users where id = $1 and deleted_at is null
|
|
2837
|
+
and (banned_until is null or banned_until <= now()) for update`, [user.id])).rows[0];
|
|
2838
|
+
if (!current)
|
|
2839
|
+
return authError(401, "no_authorization", "Invalid or expired token");
|
|
2840
|
+
const identity = (await query(`select id from auth.identities where id = $1 and user_id = $2`, [identityId, user.id])).rows[0];
|
|
2841
|
+
if (!identity)
|
|
2842
|
+
return authError(404, "identity_not_found", "Identity not found");
|
|
2843
|
+
const count = await query(`select count(*)::int as count from auth.identities where user_id = $1`, [user.id]);
|
|
2844
|
+
if ((count.rows[0]?.count ?? 0) <= 1) {
|
|
2845
|
+
return authError(422, "cannot_unlink_identity", "Cannot unlink the only identity");
|
|
2846
|
+
}
|
|
2847
|
+
await query(`delete from auth.identities where id = $1 and user_id = $2`, [identity.id, user.id]);
|
|
2848
|
+
await query(`update auth.users
|
|
2849
|
+
set raw_app_meta_data = jsonb_set(
|
|
2850
|
+
coalesce(raw_app_meta_data, '{}'::jsonb),
|
|
2851
|
+
'{providers}',
|
|
2852
|
+
(
|
|
2853
|
+
select jsonb_agg(provider order by provider)
|
|
2854
|
+
from (
|
|
2855
|
+
select distinct provider
|
|
2856
|
+
from auth.identities
|
|
2857
|
+
where user_id = $1
|
|
2858
|
+
) as providers
|
|
2859
|
+
),
|
|
2860
|
+
true
|
|
2861
|
+
),
|
|
2862
|
+
updated_at = now()
|
|
2863
|
+
where id = $1`, [user.id]);
|
|
2864
|
+
return json(200, {});
|
|
2865
|
+
});
|
|
2866
|
+
}
|
|
1525
2867
|
async updateUser(req) {
|
|
2868
|
+
const claims = await this.claimsFromBearer(req);
|
|
1526
2869
|
const user = await this.userFromBearer(req);
|
|
1527
2870
|
if (!user)
|
|
1528
2871
|
return authError(401, "no_authorization", "Invalid or expired token");
|
|
1529
2872
|
const body = await req.json().catch(() => ({}));
|
|
1530
2873
|
const sets = [];
|
|
1531
2874
|
const params = [];
|
|
1532
|
-
|
|
1533
|
-
|
|
2875
|
+
const requestedEmail = body.email === undefined ? undefined : normalizeEmailInput(body.email);
|
|
2876
|
+
if (body.email !== undefined && !requestedEmail) {
|
|
2877
|
+
return authError(400, "validation_failed", "email must not be empty");
|
|
2878
|
+
}
|
|
2879
|
+
if (body.phone !== undefined) {
|
|
2880
|
+
if (requestedEmail !== undefined)
|
|
2881
|
+
return authError(400, "validation_failed", "email and phone cannot be updated together");
|
|
2882
|
+
const phone = normalizePhone(body.phone);
|
|
2883
|
+
if (!phone)
|
|
2884
|
+
return authError(400, "validation_failed", "phone must be a valid E.164 number");
|
|
2885
|
+
return this.requestPhoneChange(req, user, phone);
|
|
2886
|
+
}
|
|
2887
|
+
const requiresReauthentication = Boolean(body.password) && this.settings.requirePasswordReauthentication && await this.sessionRequiresReauthentication(claims);
|
|
2888
|
+
if (requestedEmail && !this.settings.autoconfirm) {
|
|
2889
|
+
return this.requestEmailChange(req, user, requestedEmail, body);
|
|
1534
2890
|
}
|
|
1535
2891
|
if (body.password) {
|
|
1536
2892
|
if (body.password.length < this.settings.minPasswordLength) {
|
|
@@ -1543,41 +2899,212 @@ class AuthHandler {
|
|
|
1543
2899
|
params.push(JSON.stringify(body.data));
|
|
1544
2900
|
sets.push(`raw_user_meta_data = coalesce(raw_user_meta_data, '{}'::jsonb) || $${params.length}::jsonb`);
|
|
1545
2901
|
}
|
|
1546
|
-
|
|
2902
|
+
let response;
|
|
2903
|
+
try {
|
|
2904
|
+
response = await this.db.transaction(async (query) => {
|
|
2905
|
+
const currentResult = await query(`select * from auth.users where id = $1 and deleted_at is null
|
|
2906
|
+
and (banned_until is null or banned_until <= now()) for update`, [user.id]);
|
|
2907
|
+
const current = currentResult.rows[0];
|
|
2908
|
+
if (!current)
|
|
2909
|
+
return authError(401, "no_authorization", "Invalid or expired token");
|
|
2910
|
+
const transactionSets = [...sets];
|
|
2911
|
+
const transactionParams = [...params];
|
|
2912
|
+
if (requiresReauthentication) {
|
|
2913
|
+
if (!body.nonce) {
|
|
2914
|
+
return authError(400, "reauthentication_needed", "Password update requires reauthentication");
|
|
2915
|
+
}
|
|
2916
|
+
if (!await this.consumeReauthentication(query, current, body.nonce)) {
|
|
2917
|
+
return authError(422, "reauthentication_not_valid", "Nonce has expired or is invalid");
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2920
|
+
const upgradingAnon = (current.is_anonymous ?? false) && requestedEmail !== undefined;
|
|
2921
|
+
if (requestedEmail) {
|
|
2922
|
+
const email = requestedEmail;
|
|
2923
|
+
const clash = await query(`select id from auth.users where lower(email) = $1 and id <> $2`, [email, current.id]);
|
|
2924
|
+
if (clash.rows.length > 0) {
|
|
2925
|
+
return authError(422, "email_exists", "A user with this email address has already been registered");
|
|
2926
|
+
}
|
|
2927
|
+
transactionParams.push(email);
|
|
2928
|
+
transactionSets.push(`email = $${transactionParams.length}, email_confirmed_at = now()`);
|
|
2929
|
+
}
|
|
2930
|
+
if (upgradingAnon) {
|
|
2931
|
+
transactionSets.push(`is_anonymous = false`);
|
|
2932
|
+
transactionSets.push(`raw_app_meta_data = coalesce(raw_app_meta_data, '{}'::jsonb) || '{"provider":"email","providers":["email"]}'::jsonb`);
|
|
2933
|
+
}
|
|
2934
|
+
if (transactionSets.length === 0) {
|
|
2935
|
+
return json(200, await this.userJsonWithRelations(current, query));
|
|
2936
|
+
}
|
|
2937
|
+
transactionParams.push(current.id);
|
|
2938
|
+
const updatedResult = await query(`update auth.users set ${transactionSets.join(", ")}, updated_at = now() where id = $${transactionParams.length} returning *`, transactionParams);
|
|
2939
|
+
const updated = updatedResult.rows[0];
|
|
2940
|
+
if (!updated)
|
|
2941
|
+
return authError(401, "no_authorization", "Invalid or expired token");
|
|
2942
|
+
if (requestedEmail)
|
|
2943
|
+
await this.ensureEmailIdentity(updated, query, upgradingAnon);
|
|
2944
|
+
return json(200, await this.userJsonWithRelations(updated, query));
|
|
2945
|
+
});
|
|
2946
|
+
} catch (error) {
|
|
2947
|
+
if (databaseDiagnosticCode(error) === "23505" && requestedEmail) {
|
|
2948
|
+
return authError(422, "email_exists", "A user with this email address has already been registered");
|
|
2949
|
+
}
|
|
2950
|
+
throw error;
|
|
2951
|
+
}
|
|
2952
|
+
return response;
|
|
2953
|
+
}
|
|
2954
|
+
async reauthenticate(req) {
|
|
2955
|
+
const user = await this.userFromBearer(req);
|
|
2956
|
+
if (!user)
|
|
2957
|
+
return authError(401, "no_authorization", "Invalid or expired token");
|
|
2958
|
+
const prepared = await this.db.transaction(async (query) => {
|
|
1547
2959
|
const currentResult = await query(`select * from auth.users where id = $1 and deleted_at is null
|
|
1548
2960
|
and (banned_until is null or banned_until <= now()) for update`, [user.id]);
|
|
1549
2961
|
const current = currentResult.rows[0];
|
|
1550
2962
|
if (!current)
|
|
1551
2963
|
return authError(401, "no_authorization", "Invalid or expired token");
|
|
1552
|
-
const
|
|
1553
|
-
const
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
if (
|
|
1559
|
-
return authError(422, "
|
|
2964
|
+
const email = current.email_confirmed_at && current.email ? current.email : null;
|
|
2965
|
+
const phone = !email && current.phone_confirmed_at && current.phone ? current.phone : null;
|
|
2966
|
+
if (!email && !phone) {
|
|
2967
|
+
if (current.email && !current.email_confirmed_at) {
|
|
2968
|
+
return authError(422, "email_not_confirmed", "Please verify your email first.");
|
|
2969
|
+
}
|
|
2970
|
+
if (current.phone && !current.phone_confirmed_at) {
|
|
2971
|
+
return authError(422, "phone_not_confirmed", "Please verify your phone first.");
|
|
1560
2972
|
}
|
|
1561
|
-
|
|
1562
|
-
|
|
2973
|
+
return authError(400, "validation_failed", "Reauthentication requires the user to have an email or a phone number");
|
|
2974
|
+
}
|
|
2975
|
+
const contact = email ?? phone;
|
|
2976
|
+
const sender = this.config.smsSender;
|
|
2977
|
+
if (!email && !sender) {
|
|
2978
|
+
return authError(422, "phone_provider_disabled", "Phone sign-ins are disabled");
|
|
1563
2979
|
}
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
2980
|
+
const limited = email ? this.limit("otp", req) : this.limitKey("sms", `phone:${await keyedDigest(this.config.jwtSecret, "supacloud-lite:phone-fingerprint:v1", contact)}`) ?? this.limit("sms", req);
|
|
2981
|
+
if (limited)
|
|
2982
|
+
return limited;
|
|
2983
|
+
const code = randomOtp(this.settings.otpLength);
|
|
2984
|
+
const tokenDigest = `hmac-sha256:v1:${await keyedDigest(this.config.jwtSecret, "supacloud-lite:reauthentication-otp:v1", `${contact}\x00${code}`)}`;
|
|
2985
|
+
const issuanceId = crypto.randomUUID();
|
|
2986
|
+
await query(`delete from auth.one_time_tokens where user_id = $1 and token_type = 'reauthentication'`, [current.id]);
|
|
2987
|
+
await query(`insert into auth.one_time_tokens (id, user_id, email, phone, token_type, token, expires_at)
|
|
2988
|
+
values ($1, $2, $3, $4, 'reauthentication', $5, now() + $6::interval)`, [issuanceId, current.id, email, phone, tokenDigest, `${this.settings.otpExpirySeconds} seconds`]);
|
|
2989
|
+
const updated = (await query(`update auth.users
|
|
2990
|
+
set reauthentication_token = $1, reauthentication_sent_at = now(), updated_at = now()
|
|
2991
|
+
where id = $2 returning *`, [tokenDigest, current.id])).rows[0];
|
|
2992
|
+
return updated ? { user: updated, email, phone, code, issuanceId, tokenDigest } : null;
|
|
2993
|
+
});
|
|
2994
|
+
if (prepared instanceof Response)
|
|
2995
|
+
return prepared;
|
|
2996
|
+
if (!prepared)
|
|
2997
|
+
return authError(401, "no_authorization", "Invalid or expired token");
|
|
2998
|
+
try {
|
|
2999
|
+
if (prepared.email) {
|
|
3000
|
+
await this.config.mailer.send({
|
|
3001
|
+
to: prepared.email,
|
|
3002
|
+
subject: "Your reauthentication code",
|
|
3003
|
+
text: `Your reauthentication code is ${prepared.code}`
|
|
3004
|
+
});
|
|
3005
|
+
} else {
|
|
3006
|
+
const smsSender = this.config.smsSender;
|
|
3007
|
+
if (!smsSender || !prepared.phone)
|
|
3008
|
+
return authError(422, "phone_provider_disabled", "Phone sign-ins are disabled");
|
|
3009
|
+
await this.sendPhoneOtp(smsSender, prepared.phone, this.settings.smsTemplate.replace(/\{\{\s*\.Code\s*\}\}/g, prepared.code));
|
|
1567
3010
|
}
|
|
1568
|
-
|
|
3011
|
+
} catch {
|
|
3012
|
+
await this.db.query(`delete from auth.one_time_tokens where id = $1 and token = $2`, [prepared.issuanceId, prepared.tokenDigest]);
|
|
3013
|
+
await this.db.query(`update auth.users
|
|
3014
|
+
set reauthentication_token = '', reauthentication_sent_at = null
|
|
3015
|
+
where id = $1 and reauthentication_token = $2`, [prepared.user.id, prepared.tokenDigest]);
|
|
3016
|
+
return authError(502, prepared.email ? "email_provider_failed" : "sms_provider_failed", "Unable to send the verification code");
|
|
3017
|
+
}
|
|
3018
|
+
return json(200, {});
|
|
3019
|
+
}
|
|
3020
|
+
async sessionRequiresReauthentication(claims) {
|
|
3021
|
+
if (!claims?.sub || typeof claims.session_id !== "string" || !claims.session_id)
|
|
3022
|
+
return true;
|
|
3023
|
+
const result = await this.db.query(`select min(created_at) as started_at
|
|
3024
|
+
from auth.refresh_tokens where session_id = $1 and user_id = $2`, [claims.session_id, claims.sub]);
|
|
3025
|
+
const startedAt = timestampMs(result.rows[0]?.started_at ?? null);
|
|
3026
|
+
return startedAt === null || Date.now() - startedAt >= 24 * 60 * 60 * 1000;
|
|
3027
|
+
}
|
|
3028
|
+
async consumeReauthentication(query, user, nonce) {
|
|
3029
|
+
const email = user.email_confirmed_at && user.email ? user.email : null;
|
|
3030
|
+
const phone = !email && user.phone_confirmed_at && user.phone ? user.phone : null;
|
|
3031
|
+
const contact = email ?? phone;
|
|
3032
|
+
if (!contact || !nonce)
|
|
3033
|
+
return false;
|
|
3034
|
+
const tokenDigest = `hmac-sha256:v1:${await keyedDigest(this.config.jwtSecret, "supacloud-lite:reauthentication-otp:v1", `${contact}\x00${nonce}`)}`;
|
|
3035
|
+
const candidate = await query(`select id from auth.one_time_tokens
|
|
3036
|
+
where user_id = $1 and token_type = 'reauthentication'
|
|
3037
|
+
and token = $2 and expires_at > now() and attempts < $3
|
|
3038
|
+
limit 1`, [user.id, tokenDigest, AuthHandler.MAX_OTP_ATTEMPTS]);
|
|
3039
|
+
if (candidate.rows.length === 0) {
|
|
3040
|
+
await query(`update auth.one_time_tokens set attempts = attempts + 1
|
|
3041
|
+
where user_id = $1 and token_type = 'reauthentication' and expires_at > now()`, [user.id]);
|
|
3042
|
+
await query(`delete from auth.one_time_tokens
|
|
3043
|
+
where user_id = $1 and token_type = 'reauthentication' and attempts >= $2`, [user.id, AuthHandler.MAX_OTP_ATTEMPTS]);
|
|
3044
|
+
return false;
|
|
3045
|
+
}
|
|
3046
|
+
const claimed = await query(`delete from auth.one_time_tokens
|
|
3047
|
+
where id = $1 and user_id = $2 and token = $3 and expires_at > now()
|
|
3048
|
+
returning id`, [candidate.rows[0].id, user.id, tokenDigest]);
|
|
3049
|
+
if (claimed.rows.length === 0)
|
|
3050
|
+
return false;
|
|
3051
|
+
const cleared = await query(`update auth.users
|
|
3052
|
+
set reauthentication_token = '', reauthentication_sent_at = null
|
|
3053
|
+
where id = $1 and reauthentication_token = $2
|
|
3054
|
+
returning id`, [user.id, tokenDigest]);
|
|
3055
|
+
return cleared.rows.length > 0;
|
|
3056
|
+
}
|
|
3057
|
+
async requestEmailChange(req, user, newEmail, body) {
|
|
3058
|
+
const url = new URL(req.url);
|
|
3059
|
+
const redirectTo = resolveRedirect(url.searchParams.get("redirect_to"), this.config.siteUrl, this.config.uriAllowList, this.config.enforceRedirectAllowList);
|
|
3060
|
+
const challenge = typeof body.code_challenge === "string" && body.code_challenge ? body.code_challenge : null;
|
|
3061
|
+
const challengeMethod = challenge ? body.code_challenge_method?.toLowerCase() || "s256" : null;
|
|
3062
|
+
const prepared = await this.db.transaction(async (query) => {
|
|
3063
|
+
const currentResult = await query(`select * from auth.users where id = $1 and deleted_at is null
|
|
3064
|
+
and (banned_until is null or banned_until <= now()) for update`, [user.id]);
|
|
3065
|
+
const current = currentResult.rows[0];
|
|
3066
|
+
if (!current)
|
|
3067
|
+
return authError(401, "no_authorization", "Invalid or expired token");
|
|
3068
|
+
if (current.email?.toLowerCase().trim() === newEmail)
|
|
1569
3069
|
return json(200, await this.userJsonWithRelations(current, query));
|
|
3070
|
+
const clash = await query(`select id from auth.users where lower(email) = $1 and id <> $2`, [newEmail, current.id]);
|
|
3071
|
+
if (clash.rows.length > 0)
|
|
3072
|
+
return authError(422, "email_exists", "A user with this email address has already been registered");
|
|
3073
|
+
const code = randomOtp(this.settings.otpLength);
|
|
3074
|
+
const linkToken = randomToken(24);
|
|
3075
|
+
await query(`delete from auth.one_time_tokens where user_id = $1 and token_type = 'email_change'`, [current.id]);
|
|
3076
|
+
await query(`insert into auth.one_time_tokens (user_id, email, token_type, token, expires_at)
|
|
3077
|
+
values ($1, $2, 'email_change', $3, now() + $4::interval),
|
|
3078
|
+
($1, $2, 'email_change', $5, now() + $4::interval)`, [current.id, newEmail, code, `${this.settings.otpExpirySeconds} seconds`, linkToken]);
|
|
3079
|
+
await query(`update auth.users
|
|
3080
|
+
set email_change = $1, email_change_sent_at = now(), email_change_confirm_status = 0, updated_at = now()
|
|
3081
|
+
where id = $2`, [newEmail, current.id]);
|
|
3082
|
+
await query(`delete from auth.flow_state
|
|
3083
|
+
where provider = 'email-change' and user_id = $1 and auth_code is null`, [current.id]);
|
|
3084
|
+
if (challenge) {
|
|
3085
|
+
await query(`insert into auth.flow_state
|
|
3086
|
+
(provider, provider_state, redirect_to, code_challenge, code_challenge_method, user_id, expires_at)
|
|
3087
|
+
values ('email-change', $1, $2, $3, $4, $5, now() + interval '10 minutes')`, [linkToken, redirectTo, challenge, challengeMethod, current.id]);
|
|
1570
3088
|
}
|
|
1571
|
-
|
|
1572
|
-
const updatedResult = await query(`update auth.users set ${transactionSets.join(", ")}, updated_at = now() where id = $${transactionParams.length} returning *`, transactionParams);
|
|
1573
|
-
const updated = updatedResult.rows[0];
|
|
3089
|
+
const updated = (await query(`select * from auth.users where id = $1`, [current.id])).rows[0];
|
|
1574
3090
|
if (!updated)
|
|
1575
3091
|
return authError(401, "no_authorization", "Invalid or expired token");
|
|
1576
|
-
|
|
1577
|
-
await this.ensureEmailIdentity(updated, query);
|
|
1578
|
-
return json(200, await this.userJsonWithRelations(updated, query));
|
|
3092
|
+
return { user: updated, linkToken, code, redirectTo };
|
|
1579
3093
|
});
|
|
1580
|
-
|
|
3094
|
+
if (prepared instanceof Response)
|
|
3095
|
+
return prepared;
|
|
3096
|
+
const actionUrl = new URL(`${this.config.apiUrl}/auth/v1/verify`);
|
|
3097
|
+
actionUrl.searchParams.set("token", prepared.linkToken);
|
|
3098
|
+
actionUrl.searchParams.set("type", "email_change");
|
|
3099
|
+
actionUrl.searchParams.set("redirect_to", prepared.redirectTo);
|
|
3100
|
+
await this.config.mailer.send({
|
|
3101
|
+
to: newEmail,
|
|
3102
|
+
subject: "Confirm your email change",
|
|
3103
|
+
text: `Confirm your new email address with this link: ${actionUrl}
|
|
3104
|
+
|
|
3105
|
+
Or use code ${prepared.code}`
|
|
3106
|
+
});
|
|
3107
|
+
return json(200, await this.userJsonWithRelations(prepared.user));
|
|
1581
3108
|
}
|
|
1582
3109
|
async logout(req, url) {
|
|
1583
3110
|
const claims = await this.claimsFromBearer(req);
|
|
@@ -1601,28 +3128,66 @@ class AuthHandler {
|
|
|
1601
3128
|
}
|
|
1602
3129
|
return new Response(null, { status: 204 });
|
|
1603
3130
|
}
|
|
1604
|
-
async issueToken(email, tokenType, createUser, flavor = "login") {
|
|
3131
|
+
async issueToken(email, tokenType, createUser, flavor = "login", metadata = {}, requestedRedirect, codeChallenge, codeChallengeMethod) {
|
|
1605
3132
|
const normalized = email.toLowerCase().trim();
|
|
1606
|
-
let res = await this.db.query(`select * from auth.users where email = $1`, [normalized]);
|
|
1607
|
-
let user = res.rows[0];
|
|
1608
|
-
if (!user) {
|
|
1609
|
-
if (!createUser)
|
|
1610
|
-
return json(200, {});
|
|
1611
|
-
if (this.settings.disableSignup)
|
|
1612
|
-
return authError(422, "signup_disabled", "Signups not allowed for this instance");
|
|
1613
|
-
res = await this.db.query(`insert into auth.users (aud, role, email, raw_app_meta_data, raw_user_meta_data)
|
|
1614
|
-
values ('authenticated', 'authenticated', $1, '{"provider":"email","providers":["email"]}', '{}')
|
|
1615
|
-
returning *`, [normalized]);
|
|
1616
|
-
user = res.rows[0];
|
|
1617
|
-
}
|
|
1618
|
-
const code = randomOtp(this.settings.otpLength);
|
|
1619
|
-
const linkToken = randomToken(24);
|
|
1620
3133
|
const expiry = `${this.settings.otpExpirySeconds} seconds`;
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
3134
|
+
const normalizedChallenge = typeof codeChallenge === "string" && codeChallenge ? codeChallenge : null;
|
|
3135
|
+
const normalizedChallengeMethod = normalizedChallenge ? codeChallengeMethod?.toLowerCase() || "s256" : null;
|
|
3136
|
+
const resolvedRedirect = resolveRedirect(requestedRedirect, this.config.siteUrl, this.config.uriAllowList, this.config.enforceRedirectAllowList);
|
|
3137
|
+
const prepared = await this.db.transaction(async (query) => {
|
|
3138
|
+
let user = (await query(`select * from auth.users where lower(email) = $1 for update`, [normalized])).rows[0];
|
|
3139
|
+
if (!user) {
|
|
3140
|
+
if (!createUser)
|
|
3141
|
+
return { kind: "unknown" };
|
|
3142
|
+
if (this.settings.disableSignup)
|
|
3143
|
+
return { kind: "signup_disabled" };
|
|
3144
|
+
const inserted = await query(`insert into auth.users (aud, role, email, raw_app_meta_data, raw_user_meta_data)
|
|
3145
|
+
values ('authenticated', 'authenticated', $1, '{"provider":"email","providers":["email"]}', $2::jsonb)
|
|
3146
|
+
on conflict (email) do nothing
|
|
3147
|
+
returning *`, [normalized, JSON.stringify(metadata)]);
|
|
3148
|
+
user = inserted.rows[0];
|
|
3149
|
+
if (!user) {
|
|
3150
|
+
user = (await query(`select * from auth.users where lower(email) = $1 for update`, [normalized])).rows[0];
|
|
3151
|
+
}
|
|
3152
|
+
}
|
|
3153
|
+
if (!user)
|
|
3154
|
+
return { kind: "unknown" };
|
|
3155
|
+
const code = randomOtp(this.settings.otpLength);
|
|
3156
|
+
const linkToken = randomToken(24);
|
|
3157
|
+
const tokenTypes = tokenType === "otp" ? ["otp", "magiclink"] : [tokenType];
|
|
3158
|
+
await query(`delete from auth.one_time_tokens where lower(email) = $1 and token_type = any($2::text[])`, [normalized, tokenTypes]);
|
|
3159
|
+
await query(`insert into auth.one_time_tokens (user_id, email, token_type, token, expires_at)
|
|
3160
|
+
values ($1, $2, $3, $4, now() + $7::interval), ($1, $2, $5, $6, now() + $7::interval)`, [user.id, normalized, tokenType, code, tokenType === "otp" ? "magiclink" : tokenType, linkToken, expiry]);
|
|
3161
|
+
if (normalizedChallenge) {
|
|
3162
|
+
await query(`delete from auth.flow_state
|
|
3163
|
+
where provider in ('email', 'email-recovery') and user_id = $1 and auth_code is null`, [user.id]);
|
|
3164
|
+
await query(`insert into auth.flow_state
|
|
3165
|
+
(provider, provider_state, redirect_to, code_challenge, code_challenge_method, user_id, expires_at)
|
|
3166
|
+
values ($1, $2, $3, $4, $5, $6, now() + interval '10 minutes')`, [
|
|
3167
|
+
tokenType === "recovery" ? "email-recovery" : "email",
|
|
3168
|
+
linkToken,
|
|
3169
|
+
resolvedRedirect,
|
|
3170
|
+
normalizedChallenge,
|
|
3171
|
+
normalizedChallengeMethod,
|
|
3172
|
+
user.id
|
|
3173
|
+
]);
|
|
3174
|
+
}
|
|
3175
|
+
return { kind: "issued", user, code, linkToken };
|
|
3176
|
+
});
|
|
3177
|
+
if (prepared.kind === "unknown")
|
|
3178
|
+
return json(200, {});
|
|
3179
|
+
if (prepared.kind === "signup_disabled") {
|
|
3180
|
+
return authError(422, "signup_disabled", "Signups not allowed for this instance");
|
|
3181
|
+
}
|
|
3182
|
+
const { user, code, linkToken } = prepared;
|
|
1624
3183
|
const kind = tokenType === "otp" ? "magiclink" : tokenType;
|
|
1625
|
-
const
|
|
3184
|
+
const linkUrl = new URL(`${this.config.apiUrl}/auth/v1/verify`);
|
|
3185
|
+
linkUrl.searchParams.set("token", linkToken);
|
|
3186
|
+
linkUrl.searchParams.set("type", kind);
|
|
3187
|
+
if (requestedRedirect) {
|
|
3188
|
+
linkUrl.searchParams.set("redirect_to", resolvedRedirect);
|
|
3189
|
+
}
|
|
3190
|
+
const link = linkUrl.toString();
|
|
1626
3191
|
await this.config.mailer.send({
|
|
1627
3192
|
to: normalized,
|
|
1628
3193
|
subject: tokenType === "recovery" ? "Reset your password" : flavor === "confirm" ? "Confirm your email" : "Your login code",
|
|
@@ -1636,7 +3201,7 @@ Or sign in with this link: ${link}`
|
|
|
1636
3201
|
});
|
|
1637
3202
|
return json(200, {});
|
|
1638
3203
|
}
|
|
1639
|
-
async sendOtp(req) {
|
|
3204
|
+
async sendOtp(req, resend = false) {
|
|
1640
3205
|
const body = await req.json().catch(() => ({}));
|
|
1641
3206
|
if (body.email && body.phone)
|
|
1642
3207
|
return authError(400, "validation_failed", "email and phone are mutually exclusive");
|
|
@@ -1647,11 +3212,77 @@ Or sign in with this link: ${link}`
|
|
|
1647
3212
|
const phone = normalizePhone(body.phone);
|
|
1648
3213
|
if (!phone)
|
|
1649
3214
|
return authError(400, "validation_failed", "phone must be a valid E.164 number");
|
|
3215
|
+
if (resend && body.type === "phone_change") {
|
|
3216
|
+
const user = await this.userFromBearer(req);
|
|
3217
|
+
if (!user || user.phone_change !== phone)
|
|
3218
|
+
return json(200, {});
|
|
3219
|
+
return this.requestPhoneChange(req, user, phone);
|
|
3220
|
+
}
|
|
1650
3221
|
return this.limit("sms", req) ?? this.issuePhoneToken(phone, body.create_user !== false, recordValue(body.data));
|
|
1651
3222
|
}
|
|
1652
|
-
|
|
3223
|
+
const email = normalizeEmailInput(body.email);
|
|
3224
|
+
if (!email)
|
|
1653
3225
|
return authError(400, "validation_failed", "email or phone is required");
|
|
1654
|
-
|
|
3226
|
+
if (resend && body.type === "email_change") {
|
|
3227
|
+
return this.resendEmailChange(req, email, body);
|
|
3228
|
+
}
|
|
3229
|
+
const signupResend = resend && body.type === "signup";
|
|
3230
|
+
return this.limit("otp", req) ?? this.issueToken(email, "otp", signupResend ? false : body.create_user !== false, signupResend ? "confirm" : "login", recordValue(body.data), new URL(req.url).searchParams.get("redirect_to"), body.code_challenge, body.code_challenge_method);
|
|
3231
|
+
}
|
|
3232
|
+
async resendEmailChange(req, newEmail, body) {
|
|
3233
|
+
const result = await this.db.query(`select * from auth.users
|
|
3234
|
+
where lower(email_change) = $1 and deleted_at is null
|
|
3235
|
+
and (banned_until is null or banned_until <= now())`, [newEmail]);
|
|
3236
|
+
const user = result.rows[0];
|
|
3237
|
+
if (!user)
|
|
3238
|
+
return json(200, {});
|
|
3239
|
+
return this.requestEmailChange(req, user, newEmail, body);
|
|
3240
|
+
}
|
|
3241
|
+
async requestPhoneChange(req, user, newPhone) {
|
|
3242
|
+
const sender = this.config.smsSender;
|
|
3243
|
+
if (!this.settings.smsEnabled || !sender) {
|
|
3244
|
+
return authError(422, "phone_provider_disabled", "Phone sign-ins are disabled");
|
|
3245
|
+
}
|
|
3246
|
+
const fingerprint = await keyedDigest(this.config.jwtSecret, "supacloud-lite:phone-fingerprint:v1", newPhone);
|
|
3247
|
+
const limited = this.limitKey("sms", `phone:${fingerprint}`) ?? this.limit("sms", req);
|
|
3248
|
+
if (limited)
|
|
3249
|
+
return limited;
|
|
3250
|
+
const token = randomOtp(this.settings.otpLength);
|
|
3251
|
+
const tokenDigest = `hmac-sha256:v1:${await keyedDigest(this.config.jwtSecret, "supacloud-lite:phone-otp:v1", `${newPhone}\x00${token}`)}`;
|
|
3252
|
+
const issuanceId = crypto.randomUUID();
|
|
3253
|
+
const prepared = await this.db.transaction(async (query) => {
|
|
3254
|
+
const currentResult = await query(`select * from auth.users where id = $1 and deleted_at is null
|
|
3255
|
+
and (banned_until is null or banned_until <= now()) for update`, [user.id]);
|
|
3256
|
+
const current = currentResult.rows[0];
|
|
3257
|
+
if (!current)
|
|
3258
|
+
return null;
|
|
3259
|
+
if (current.phone === newPhone)
|
|
3260
|
+
return { user: current, unchanged: true };
|
|
3261
|
+
const clash = await query(`select id from auth.users where phone = $1 and id <> $2`, [newPhone, current.id]);
|
|
3262
|
+
if (clash.rows.length > 0)
|
|
3263
|
+
return { conflict: true };
|
|
3264
|
+
await query(`delete from auth.one_time_tokens where user_id = $1 and token_type = 'phone_change'`, [current.id]);
|
|
3265
|
+
await query(`insert into auth.one_time_tokens (id, user_id, phone, token_type, token, expires_at)
|
|
3266
|
+
values ($1, $2, $3, 'phone_change', $4, now() + $5::interval)`, [issuanceId, current.id, newPhone, tokenDigest, `${this.settings.otpExpirySeconds} seconds`]);
|
|
3267
|
+
const updated = (await query(`update auth.users
|
|
3268
|
+
set phone_change = $1, phone_change_token = $2, phone_change_sent_at = now(), updated_at = now()
|
|
3269
|
+
where id = $3 returning *`, [newPhone, tokenDigest, current.id])).rows[0];
|
|
3270
|
+
return updated ? { user: updated, unchanged: false } : null;
|
|
3271
|
+
});
|
|
3272
|
+
if (!prepared)
|
|
3273
|
+
return authError(401, "no_authorization", "Invalid or expired token");
|
|
3274
|
+
if ("conflict" in prepared)
|
|
3275
|
+
return authError(422, "phone_exists", "A user with this phone number has already been registered");
|
|
3276
|
+
if (prepared.unchanged)
|
|
3277
|
+
return json(200, await this.userJsonWithRelations(prepared.user));
|
|
3278
|
+
try {
|
|
3279
|
+
await this.sendPhoneOtp(sender, newPhone, this.settings.smsTemplate.replace(/\{\{\s*\.Code\s*\}\}/g, token));
|
|
3280
|
+
} catch {
|
|
3281
|
+
await this.db.query(`delete from auth.one_time_tokens where id = $1 and token = $2`, [issuanceId, tokenDigest]);
|
|
3282
|
+
await this.db.query(`update auth.users set phone_change = null, phone_change_token = '', phone_change_sent_at = null where id = $1`, [user.id]);
|
|
3283
|
+
return authError(502, "sms_provider_failed", "Unable to send the verification code");
|
|
3284
|
+
}
|
|
3285
|
+
return json(200, await this.userJsonWithRelations(prepared.user));
|
|
1655
3286
|
}
|
|
1656
3287
|
async issuePhoneToken(phone, createUser, metadata) {
|
|
1657
3288
|
const sender = this.config.smsSender;
|
|
@@ -1705,7 +3336,7 @@ Or sign in with this link: ${link}`
|
|
|
1705
3336
|
const resolved = await this.phoneUser(query, request);
|
|
1706
3337
|
if (resolved === "unknown" || resolved === "signup_disabled")
|
|
1707
3338
|
return { state: resolved };
|
|
1708
|
-
await this.
|
|
3339
|
+
await this.ensurePhoneIdentity(resolved, query);
|
|
1709
3340
|
await query(`delete from auth.one_time_tokens where phone = $1 and token_type = 'sms'`, [request.phone]);
|
|
1710
3341
|
await query(`insert into auth.one_time_tokens (id, user_id, phone, token_type, token, expires_at)
|
|
1711
3342
|
values ($1, $2, $3, 'sms', $4, now() + $5::interval)`, [request.issuanceId, resolved.id, request.phone, request.tokenDigest, request.expiry]);
|
|
@@ -1749,11 +3380,6 @@ Or sign in with this link: ${link}`
|
|
|
1749
3380
|
from auth.users where phone = $1 for update`, [phone]);
|
|
1750
3381
|
return users.rows[0];
|
|
1751
3382
|
}
|
|
1752
|
-
createPhoneIdentity(query, user) {
|
|
1753
|
-
return query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
|
|
1754
|
-
values ($1, 'phone', $2, $3::jsonb)
|
|
1755
|
-
on conflict (provider, provider_id) do nothing`, [user.id, user.phone, JSON.stringify({ sub: user.id, phone: user.phone, phone_verified: user.phone_confirmed_at != null })]);
|
|
1756
|
-
}
|
|
1757
3383
|
async deliverPhoneOtp(issuance, sender, phone, body) {
|
|
1758
3384
|
try {
|
|
1759
3385
|
await this.sendPhoneOtp(sender, phone, body);
|
|
@@ -1805,12 +3431,24 @@ Or sign in with this link: ${link}`
|
|
|
1805
3431
|
}
|
|
1806
3432
|
async sendRecovery(req) {
|
|
1807
3433
|
const body = await req.json().catch(() => ({}));
|
|
1808
|
-
|
|
3434
|
+
const email = normalizeEmailInput(body.email);
|
|
3435
|
+
if (!email)
|
|
1809
3436
|
return authError(400, "validation_failed", "email is required");
|
|
1810
|
-
return this.issueToken(
|
|
3437
|
+
return this.issueToken(email, "recovery", false, "login", {}, new URL(req.url).searchParams.get("redirect_to"), body.code_challenge, body.code_challenge_method);
|
|
1811
3438
|
}
|
|
1812
3439
|
static MAX_OTP_ATTEMPTS = 5;
|
|
1813
|
-
async redeem(token, types, email) {
|
|
3440
|
+
async redeem(token, types, email, sessionOptions) {
|
|
3441
|
+
const redemption = {
|
|
3442
|
+
token,
|
|
3443
|
+
tokenTypes: `{${types.join(",")}}`,
|
|
3444
|
+
email: email?.toLowerCase().trim() ?? null
|
|
3445
|
+
};
|
|
3446
|
+
return this.db.transaction(async (query) => {
|
|
3447
|
+
const user = await this.claimEmailToken(query, redemption);
|
|
3448
|
+
return user ? this.sessionFor(user, undefined, sessionOptions, query) : null;
|
|
3449
|
+
});
|
|
3450
|
+
}
|
|
3451
|
+
async redeemUser(token, types, email) {
|
|
1814
3452
|
const redemption = {
|
|
1815
3453
|
token,
|
|
1816
3454
|
tokenTypes: `{${types.join(",")}}`,
|
|
@@ -1818,7 +3456,7 @@ Or sign in with this link: ${link}`
|
|
|
1818
3456
|
};
|
|
1819
3457
|
return this.db.transaction(async (query) => {
|
|
1820
3458
|
const user = await this.claimEmailToken(query, redemption);
|
|
1821
|
-
return user
|
|
3459
|
+
return user;
|
|
1822
3460
|
});
|
|
1823
3461
|
}
|
|
1824
3462
|
async claimEmailToken(query, redemption) {
|
|
@@ -1842,15 +3480,19 @@ Or sign in with this link: ${link}`
|
|
|
1842
3480
|
return claimedTokens.rows[0] ?? null;
|
|
1843
3481
|
}
|
|
1844
3482
|
async confirmEmailTokenUser(query, claimedToken) {
|
|
1845
|
-
await query(`delete from auth.one_time_tokens where email = $1`, [claimedToken.email]);
|
|
3483
|
+
await query(`delete from auth.one_time_tokens where lower(email) = lower($1)`, [claimedToken.email]);
|
|
1846
3484
|
const users = await query(`update auth.users set email_confirmed_at = coalesce(email_confirmed_at, now()), last_sign_in_at = now()
|
|
1847
3485
|
where id = $1 returning *`, [claimedToken.user_id]);
|
|
1848
|
-
|
|
3486
|
+
const user = users.rows[0];
|
|
3487
|
+
if (!user)
|
|
3488
|
+
return null;
|
|
3489
|
+
await this.ensureEmailIdentity(user, query);
|
|
3490
|
+
return user;
|
|
1849
3491
|
}
|
|
1850
3492
|
async findEmailTokenCandidate(query, redemption) {
|
|
1851
3493
|
const candidates = await query(`select id, user_id, email from auth.one_time_tokens
|
|
1852
3494
|
where token = $1 and token_type = any($2::text[])
|
|
1853
|
-
and ($3::text is null or email = $3) and expires_at > now()
|
|
3495
|
+
and ($3::text is null or lower(email) = lower($3)) and expires_at > now()
|
|
1854
3496
|
and attempts < $4`, [redemption.token, redemption.tokenTypes, redemption.email, AuthHandler.MAX_OTP_ATTEMPTS]);
|
|
1855
3497
|
return candidates.rows[0] ?? null;
|
|
1856
3498
|
}
|
|
@@ -1858,14 +3500,28 @@ Or sign in with this link: ${link}`
|
|
|
1858
3500
|
if (!redemption.email)
|
|
1859
3501
|
return;
|
|
1860
3502
|
await query(`update auth.one_time_tokens set attempts = attempts + 1
|
|
1861
|
-
where email = $1 and token_type = any($2::text[]) and expires_at > now()`, [redemption.email, redemption.tokenTypes]);
|
|
1862
|
-
await query(`delete from auth.one_time_tokens where email = $1 and attempts >= $2`, [redemption.email, AuthHandler.MAX_OTP_ATTEMPTS]);
|
|
3503
|
+
where lower(email) = lower($1) and token_type = any($2::text[]) and expires_at > now()`, [redemption.email, redemption.tokenTypes]);
|
|
3504
|
+
await query(`delete from auth.one_time_tokens where lower(email) = lower($1) and attempts >= $2`, [redemption.email, AuthHandler.MAX_OTP_ATTEMPTS]);
|
|
1863
3505
|
}
|
|
1864
3506
|
async verifyToken(req) {
|
|
1865
3507
|
const body = await req.json().catch(() => ({}));
|
|
1866
3508
|
const token = body.token ?? body.token_hash;
|
|
1867
3509
|
if (!token)
|
|
1868
3510
|
return authError(400, "validation_failed", "token or token_hash is required");
|
|
3511
|
+
if (body.type === "phone_change") {
|
|
3512
|
+
const phone = normalizePhone(body.phone);
|
|
3513
|
+
if (!phone || !body.token || !/^\d{6,10}$/.test(body.token)) {
|
|
3514
|
+
return authError(400, "validation_failed", "phone, token, and type=phone_change are required");
|
|
3515
|
+
}
|
|
3516
|
+
const fingerprint = await keyedDigest(this.config.jwtSecret, "supacloud-lite:phone-fingerprint:v1", phone);
|
|
3517
|
+
const limited = this.limitKey("verify", `phone:${fingerprint}`);
|
|
3518
|
+
if (limited)
|
|
3519
|
+
return limited;
|
|
3520
|
+
const session = await this.redeemPhoneChange(phone, body.token);
|
|
3521
|
+
if (!session)
|
|
3522
|
+
return authError(403, "otp_expired", "Token has expired or is invalid");
|
|
3523
|
+
return json(200, session);
|
|
3524
|
+
}
|
|
1869
3525
|
if (body.type === "sms" || body.phone !== undefined) {
|
|
1870
3526
|
const phone = normalizePhone(body.phone);
|
|
1871
3527
|
if (body.type !== "sms" || !phone || !body.token || !/^\d{6,10}$/.test(body.token)) {
|
|
@@ -1886,8 +3542,22 @@ Or sign in with this link: ${link}`
|
|
|
1886
3542
|
return authError(403, "otp_expired", "Token has expired or is invalid");
|
|
1887
3543
|
return json(200, session);
|
|
1888
3544
|
}
|
|
1889
|
-
|
|
1890
|
-
|
|
3545
|
+
if (body.type === "email_change") {
|
|
3546
|
+
const result = await this.redeemEmailChange(token, body.email);
|
|
3547
|
+
if (!result)
|
|
3548
|
+
return authError(403, "otp_expired", "Token has expired or is invalid");
|
|
3549
|
+
if ("pending" in result) {
|
|
3550
|
+
return json(200, {
|
|
3551
|
+
msg: "Confirmation link accepted. Please proceed to confirm link sent to the other email",
|
|
3552
|
+
code: "200"
|
|
3553
|
+
});
|
|
3554
|
+
}
|
|
3555
|
+
return json(200, result);
|
|
3556
|
+
}
|
|
3557
|
+
const types = body.type === "recovery" ? ["recovery"] : body.type === "magiclink" ? ["magiclink"] : body.type === "signup" || body.type === "invite" ? [body.type] : ["otp", "magiclink"];
|
|
3558
|
+
const session = await this.redeem(token, types, body.email, {
|
|
3559
|
+
amr: [{ method: body.type === "recovery" ? "recovery" : "otp", timestamp: Math.floor(Date.now() / 1000) }]
|
|
3560
|
+
});
|
|
1891
3561
|
if (!session)
|
|
1892
3562
|
return authError(403, "otp_expired", "Token has expired or is invalid");
|
|
1893
3563
|
return json(200, session);
|
|
@@ -1935,24 +3605,199 @@ Or sign in with this link: ${link}`
|
|
|
1935
3605
|
const user = users.rows[0];
|
|
1936
3606
|
if (!user)
|
|
1937
3607
|
return null;
|
|
1938
|
-
await
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
3608
|
+
await this.ensurePhoneIdentity(user, query);
|
|
3609
|
+
await query(`update auth.identities
|
|
3610
|
+
set last_sign_in_at = now(), updated_at = now()
|
|
3611
|
+
where user_id = $1 and provider = 'phone' and provider_id = $2`, [user.id, phone]);
|
|
1942
3612
|
return this.sessionFor(user, undefined, { amr: [{ method: "otp", timestamp: Math.floor(Date.now() / 1000) }] }, query);
|
|
1943
3613
|
});
|
|
1944
3614
|
}
|
|
3615
|
+
async redeemPhoneChange(phone, code) {
|
|
3616
|
+
const tokenDigest = `hmac-sha256:v1:${await keyedDigest(this.config.jwtSecret, "supacloud-lite:phone-otp:v1", `${phone}\x00${code}`)}`;
|
|
3617
|
+
return this.db.transaction(async (query) => {
|
|
3618
|
+
const candidate = (await query(`select id, user_id from auth.one_time_tokens
|
|
3619
|
+
where phone = $1 and token_type = 'phone_change' and token = $2 and expires_at > now()
|
|
3620
|
+
limit 1`, [phone, tokenDigest])).rows[0];
|
|
3621
|
+
if (!candidate)
|
|
3622
|
+
return null;
|
|
3623
|
+
const currentResult = await query(`select * from auth.users where id = $1 and phone_change = $2 and deleted_at is null
|
|
3624
|
+
and (banned_until is null or banned_until <= now()) for update`, [candidate.user_id, phone]);
|
|
3625
|
+
const current = currentResult.rows[0];
|
|
3626
|
+
if (!current)
|
|
3627
|
+
return null;
|
|
3628
|
+
const clash = await query(`select id from auth.users where phone = $1 and id <> $2`, [phone, current.id]);
|
|
3629
|
+
if (clash.rows.length > 0)
|
|
3630
|
+
return null;
|
|
3631
|
+
const claimed = await query(`delete from auth.one_time_tokens where id = $1 and token = $2 returning id`, [candidate.id, tokenDigest]);
|
|
3632
|
+
if (claimed.rows.length === 0)
|
|
3633
|
+
return null;
|
|
3634
|
+
await query(`delete from auth.identities where user_id = $1 and provider = 'phone'`, [current.id]);
|
|
3635
|
+
const updated = (await query(`update auth.users
|
|
3636
|
+
set phone = $1, phone_confirmed_at = now(),
|
|
3637
|
+
phone_change = null, phone_change_token = '', phone_change_sent_at = null,
|
|
3638
|
+
updated_at = now()
|
|
3639
|
+
where id = $2 and deleted_at is null
|
|
3640
|
+
and (banned_until is null or banned_until <= now())
|
|
3641
|
+
returning *`, [phone, current.id])).rows[0];
|
|
3642
|
+
if (!updated)
|
|
3643
|
+
return null;
|
|
3644
|
+
await this.ensurePhoneIdentity(updated, query);
|
|
3645
|
+
return this.sessionFor(updated, undefined, { amr: [{ method: "otp", timestamp: Math.floor(Date.now() / 1000) }] }, query);
|
|
3646
|
+
});
|
|
3647
|
+
}
|
|
1945
3648
|
async verifyLink(url) {
|
|
1946
3649
|
const token = url.searchParams.get("token") ?? "";
|
|
1947
3650
|
const type = url.searchParams.get("type") ?? "magiclink";
|
|
1948
3651
|
const redirectTo = resolveRedirect(url.searchParams.get("redirect_to"), this.config.siteUrl, this.config.uriAllowList, this.config.enforceRedirectAllowList);
|
|
1949
|
-
const
|
|
3652
|
+
const flow = (await this.db.query(`select id, provider, redirect_to, code_challenge, code_challenge_method
|
|
3653
|
+
from auth.flow_state
|
|
3654
|
+
where provider in ('email', 'email-recovery', 'email-change')
|
|
3655
|
+
and provider_state = $1 and auth_code is null and expires_at > now()`, [token])).rows[0];
|
|
3656
|
+
const emailChange = type === "email_change" ? await this.redeemEmailChangeUser(token) : null;
|
|
3657
|
+
if (emailChange === "pending") {
|
|
3658
|
+
return redirectWithQuery2(redirectTo, {
|
|
3659
|
+
message: "Confirmation link accepted. Please proceed to confirm link sent to the other email"
|
|
3660
|
+
});
|
|
3661
|
+
}
|
|
3662
|
+
let user = null;
|
|
3663
|
+
let session = null;
|
|
3664
|
+
if (type === "email_change") {
|
|
3665
|
+
user = emailChange;
|
|
3666
|
+
} else if (flow) {
|
|
3667
|
+
user = await this.redeemUser(token, [type]);
|
|
3668
|
+
} else {
|
|
3669
|
+
session = await this.redeem(token, [type], undefined, {
|
|
3670
|
+
amr: [{ method: type === "recovery" ? "recovery" : "otp", timestamp: Math.floor(Date.now() / 1000) }]
|
|
3671
|
+
});
|
|
3672
|
+
}
|
|
3673
|
+
if (type === "email_change" || flow) {
|
|
3674
|
+
if (!user) {
|
|
3675
|
+
return new Response(null, { status: 303, headers: { location: redirectTo + "#error=access_denied&error_code=otp_expired" } });
|
|
3676
|
+
}
|
|
3677
|
+
if (flow) {
|
|
3678
|
+
const authCode = randomToken(24);
|
|
3679
|
+
const claimed = await this.db.query(`update auth.flow_state
|
|
3680
|
+
set auth_code = $1, user_id = $2, expires_at = now() + interval '5 minutes'
|
|
3681
|
+
where id = $3 and auth_code is null and expires_at > now()
|
|
3682
|
+
returning redirect_to`, [authCode, user.id, flow.id]);
|
|
3683
|
+
const target = claimed.rows[0]?.redirect_to;
|
|
3684
|
+
if (target)
|
|
3685
|
+
return redirectWithQuery2(target, { code: authCode });
|
|
3686
|
+
}
|
|
3687
|
+
session = await this.sessionFor(user, undefined, {
|
|
3688
|
+
amr: [{ method: type === "recovery" ? "recovery" : "otp", timestamp: Math.floor(Date.now() / 1000) }]
|
|
3689
|
+
});
|
|
3690
|
+
}
|
|
1950
3691
|
if (!session) {
|
|
1951
|
-
return new Response(null, { status: 303, headers: { location:
|
|
3692
|
+
return new Response(null, { status: 303, headers: { location: redirectTo + "#error=access_denied&error_code=otp_expired" } });
|
|
1952
3693
|
}
|
|
1953
3694
|
const hash = `#access_token=${session.access_token}&refresh_token=${session.refresh_token}&expires_in=${session.expires_in}&token_type=bearer&type=${type}`;
|
|
1954
3695
|
return new Response(null, { status: 303, headers: { location: `${redirectTo}${hash}` } });
|
|
1955
3696
|
}
|
|
3697
|
+
async exchangeEmailPkce(authCode, verifier) {
|
|
3698
|
+
return this.db.transaction(async (query) => {
|
|
3699
|
+
const flow = (await query(`select id, provider, redirect_to, code_challenge, code_challenge_method
|
|
3700
|
+
from auth.flow_state
|
|
3701
|
+
where provider in ('email', 'email-recovery', 'email-change')
|
|
3702
|
+
and auth_code = $1 and expires_at > now()`, [authCode])).rows[0];
|
|
3703
|
+
if (!flow)
|
|
3704
|
+
return null;
|
|
3705
|
+
if (flow.code_challenge_method === "s256" || flow.code_challenge_method === "S256") {
|
|
3706
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
3707
|
+
const challenge = base64url2(new Uint8Array(digest));
|
|
3708
|
+
if (challenge !== flow.code_challenge)
|
|
3709
|
+
return null;
|
|
3710
|
+
} else if (flow.code_challenge !== verifier) {
|
|
3711
|
+
return null;
|
|
3712
|
+
}
|
|
3713
|
+
const claimed = await query(`delete from auth.flow_state where id = $1 and auth_code = $2 and expires_at > now()
|
|
3714
|
+
returning user_id`, [flow.id, authCode]);
|
|
3715
|
+
const userId = claimed.rows[0]?.user_id;
|
|
3716
|
+
return userId ? { userId, provider: flow.provider } : null;
|
|
3717
|
+
});
|
|
3718
|
+
}
|
|
3719
|
+
async redeemEmailChangeUser(token, email) {
|
|
3720
|
+
return this.db.transaction((query) => this.claimEmailChange(query, token, email));
|
|
3721
|
+
}
|
|
3722
|
+
async redeemEmailChange(token, email) {
|
|
3723
|
+
return this.db.transaction(async (query) => {
|
|
3724
|
+
const user = await this.claimEmailChange(query, token, email);
|
|
3725
|
+
if (user === "pending")
|
|
3726
|
+
return { pending: true };
|
|
3727
|
+
return user ? this.sessionFor(user, undefined, {
|
|
3728
|
+
amr: [{ method: "otp", timestamp: Math.floor(Date.now() / 1000) }]
|
|
3729
|
+
}, query) : null;
|
|
3730
|
+
});
|
|
3731
|
+
}
|
|
3732
|
+
async claimEmailChange(query, token, email) {
|
|
3733
|
+
const candidate = (await query(`select id, user_id, email, token_type from auth.one_time_tokens
|
|
3734
|
+
where token = $1 and token_type in ('email_change', 'email_change_current', 'email_change_new')
|
|
3735
|
+
and expires_at > now()
|
|
3736
|
+
and ($2::text is null or lower(email) = lower($2))
|
|
3737
|
+
limit 1`, [token, email?.toLowerCase().trim() ?? null])).rows[0];
|
|
3738
|
+
if (!candidate)
|
|
3739
|
+
return null;
|
|
3740
|
+
const currentResult = await query(`select * from auth.users where id = $1 and email_change is not null and deleted_at is null
|
|
3741
|
+
and (banned_until is null or banned_until <= now()) for update`, [candidate.user_id]);
|
|
3742
|
+
const current = currentResult.rows[0];
|
|
3743
|
+
if (!current)
|
|
3744
|
+
return null;
|
|
3745
|
+
if (candidate.token_type === "email_change" ? current.email_change?.toLowerCase() !== candidate.email.toLowerCase() : candidate.token_type === "email_change_current" ? current.email?.toLowerCase() !== candidate.email.toLowerCase() : current.email_change?.toLowerCase() !== candidate.email.toLowerCase())
|
|
3746
|
+
return null;
|
|
3747
|
+
const targetEmail = candidate.token_type === "email_change_current" ? current.email_change : candidate.email;
|
|
3748
|
+
if (!targetEmail)
|
|
3749
|
+
return null;
|
|
3750
|
+
const clash = await query(`select id from auth.users where lower(email) = lower($1) and id <> $2`, [targetEmail, candidate.user_id]);
|
|
3751
|
+
if (clash.rows.length > 0)
|
|
3752
|
+
return null;
|
|
3753
|
+
const needsSecondConfirmation = candidate.token_type !== "email_change";
|
|
3754
|
+
if (needsSecondConfirmation && (current.email_change_confirm_status ?? 0) === 0) {
|
|
3755
|
+
const deleted = await query(`delete from auth.one_time_tokens
|
|
3756
|
+
where user_id = $1 and token_type = $2 returning id`, [candidate.user_id, candidate.token_type]);
|
|
3757
|
+
if (deleted.rows.length === 0)
|
|
3758
|
+
return null;
|
|
3759
|
+
const updated = (await query(`update auth.users
|
|
3760
|
+
set email_change_confirm_status = 1,
|
|
3761
|
+
email_change_token_current = case when $1 = 'email_change_current' then '' else email_change_token_current end,
|
|
3762
|
+
email_change_token_new = case when $1 = 'email_change_new' then '' else email_change_token_new end,
|
|
3763
|
+
updated_at = now()
|
|
3764
|
+
where id = $2
|
|
3765
|
+
returning *`, [candidate.token_type, candidate.user_id])).rows[0];
|
|
3766
|
+
return updated ? "pending" : null;
|
|
3767
|
+
}
|
|
3768
|
+
const deleted = await query(`delete from auth.one_time_tokens where user_id = $1
|
|
3769
|
+
and token_type = $2 returning id`, [candidate.user_id, candidate.token_type]);
|
|
3770
|
+
if (deleted.rows.length === 0)
|
|
3771
|
+
return null;
|
|
3772
|
+
const updated = (await query(`update auth.users
|
|
3773
|
+
set email = $1, email_confirmed_at = now(),
|
|
3774
|
+
email_change = null, email_change_sent_at = null,
|
|
3775
|
+
email_change_token_current = '', email_change_token_new = '',
|
|
3776
|
+
email_change_confirm_status = 0,
|
|
3777
|
+
is_anonymous = false,
|
|
3778
|
+
raw_app_meta_data = case when is_anonymous
|
|
3779
|
+
then coalesce(raw_app_meta_data, '{}'::jsonb) || '{"provider":"email","providers":["email"]}'::jsonb
|
|
3780
|
+
else raw_app_meta_data end,
|
|
3781
|
+
updated_at = now()
|
|
3782
|
+
where id = $2
|
|
3783
|
+
returning *`, [targetEmail, candidate.user_id])).rows[0];
|
|
3784
|
+
if (!updated)
|
|
3785
|
+
return null;
|
|
3786
|
+
await this.ensureEmailIdentity(updated, query);
|
|
3787
|
+
return updated;
|
|
3788
|
+
}
|
|
3789
|
+
async sessionForEmailPkce(userId, provider) {
|
|
3790
|
+
return this.db.transaction(async (query) => {
|
|
3791
|
+
const result = await query(`select * from auth.users where id = $1 and deleted_at is null
|
|
3792
|
+
and (banned_until is null or banned_until <= now()) for update`, [userId]);
|
|
3793
|
+
const user = result.rows[0];
|
|
3794
|
+
if (!user)
|
|
3795
|
+
throw new OAuthAccountIneligibleError("user_not_found");
|
|
3796
|
+
return this.sessionFor(user, undefined, {
|
|
3797
|
+
amr: [{ method: provider === "email-recovery" ? "recovery" : "otp", timestamp: Math.floor(Date.now() / 1000) }]
|
|
3798
|
+
}, query);
|
|
3799
|
+
});
|
|
3800
|
+
}
|
|
1956
3801
|
async admin(req, ctx, path, method) {
|
|
1957
3802
|
if (ctx.role !== "service_role") {
|
|
1958
3803
|
return authError(403, "insufficient_permissions", "Admin endpoints require the service_role key");
|
|
@@ -1985,30 +3830,96 @@ Or sign in with this link: ${link}`
|
|
|
1985
3830
|
return json(200, { id: factorMatch[2] });
|
|
1986
3831
|
}
|
|
1987
3832
|
if (path === "admin/users" && method === "GET") {
|
|
1988
|
-
const
|
|
1989
|
-
|
|
3833
|
+
const requestUrl = new URL(req.url);
|
|
3834
|
+
const pageValue = requestUrl.searchParams.get("page");
|
|
3835
|
+
const perPageValue = requestUrl.searchParams.get("per_page");
|
|
3836
|
+
const page = pageValue === null ? 1 : Number(pageValue);
|
|
3837
|
+
const perPage = perPageValue === null ? 50 : Number(perPageValue);
|
|
3838
|
+
if (!Number.isInteger(page) || page < 1 || !Number.isInteger(perPage) || perPage < 1 || perPage > 1000) {
|
|
3839
|
+
return authError(400, "validation_failed", "page must be a positive integer and per_page must be between 1 and 1000");
|
|
3840
|
+
}
|
|
3841
|
+
const total = Number((await this.db.query(`select count(*)::int as count from auth.users`)).rows[0]?.count ?? 0);
|
|
3842
|
+
const res = await this.db.query(`select * from auth.users order by created_at desc, id desc limit $1 offset $2`, [perPage, (page - 1) * perPage]);
|
|
3843
|
+
const lastPage = Math.max(1, Math.ceil(total / perPage));
|
|
3844
|
+
const linkUrl = (targetPage) => {
|
|
3845
|
+
const target = new URL(requestUrl);
|
|
3846
|
+
target.searchParams.set("page", String(targetPage));
|
|
3847
|
+
target.searchParams.set("per_page", String(perPage));
|
|
3848
|
+
return target.toString();
|
|
3849
|
+
};
|
|
3850
|
+
const links = [
|
|
3851
|
+
`<${linkUrl(1)}>; rel="first"`,
|
|
3852
|
+
`<${linkUrl(lastPage)}>; rel="last"`,
|
|
3853
|
+
...page > 1 ? [`<${linkUrl(page - 1)}>; rel="prev"`] : [],
|
|
3854
|
+
...page < lastPage ? [`<${linkUrl(page + 1)}>; rel="next"`] : []
|
|
3855
|
+
];
|
|
3856
|
+
return json(200, { users: res.rows.map((user) => this.userJson(user)), aud: "authenticated" }, { "x-total-count": String(total), link: links.join(", ") });
|
|
1990
3857
|
}
|
|
1991
3858
|
if (path === "admin/users" && method === "POST") {
|
|
1992
3859
|
const body = await req.json().catch(() => ({}));
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
3860
|
+
const email = normalizeEmailInput(body.email);
|
|
3861
|
+
if (body.email !== undefined && !email)
|
|
3862
|
+
return authError(400, "validation_failed", "email must not be empty");
|
|
3863
|
+
const phone = body.phone === undefined ? null : normalizePhone(body.phone);
|
|
3864
|
+
if (body.phone !== undefined && !phone) {
|
|
3865
|
+
return authError(400, "validation_failed", "phone must be a valid E.164 number");
|
|
3866
|
+
}
|
|
3867
|
+
if (!email && !phone)
|
|
3868
|
+
return authError(400, "validation_failed", "email or phone is required");
|
|
3869
|
+
if (body.phone_confirm !== undefined && typeof body.phone_confirm !== "boolean") {
|
|
3870
|
+
return authError(400, "validation_failed", "phone_confirm must be a boolean");
|
|
3871
|
+
}
|
|
3872
|
+
const banSeconds = body.ban_duration === undefined ? null : banDurationSeconds(body.ban_duration);
|
|
3873
|
+
if (banSeconds === undefined)
|
|
3874
|
+
return authError(400, "validation_failed", "ban_duration is invalid");
|
|
1996
3875
|
const hashed = body.password ? await hashPassword(body.password) : null;
|
|
1997
3876
|
const created = await this.db.transaction(async (query) => {
|
|
3877
|
+
if (email) {
|
|
3878
|
+
const existing = await query(`select id from auth.users where lower(email) = $1 for update`, [email]);
|
|
3879
|
+
if (existing.rows.length > 0)
|
|
3880
|
+
return { error: "email_exists" };
|
|
3881
|
+
}
|
|
3882
|
+
if (phone) {
|
|
3883
|
+
const phoneExisting = await query(`select id from auth.users where phone = $1 for update`, [phone]);
|
|
3884
|
+
if (phoneExisting.rows.length > 0)
|
|
3885
|
+
return { error: "phone_exists" };
|
|
3886
|
+
}
|
|
1998
3887
|
const res = await query(`insert into auth.users
|
|
1999
|
-
(aud, role, email, encrypted_password, email_confirmed_at,
|
|
2000
|
-
|
|
3888
|
+
(aud, role, email, encrypted_password, email_confirmed_at, phone, phone_confirmed_at,
|
|
3889
|
+
banned_until, raw_app_meta_data, raw_user_meta_data)
|
|
3890
|
+
values ('authenticated', 'authenticated', $1, $2, case when $3 then now() else null end,
|
|
3891
|
+
$4, case when $5 then now() else null end,
|
|
3892
|
+
case when $6::double precision is null then null
|
|
3893
|
+
else now() + ($6::double precision * interval '1 second') end, $7, $8)
|
|
3894
|
+
on conflict do nothing
|
|
2001
3895
|
returning *`, [
|
|
2002
|
-
email
|
|
3896
|
+
email,
|
|
2003
3897
|
hashed,
|
|
2004
|
-
body.email_confirm ??
|
|
2005
|
-
|
|
3898
|
+
body.email_confirm ?? false,
|
|
3899
|
+
phone,
|
|
3900
|
+
body.phone_confirm === true,
|
|
3901
|
+
banSeconds,
|
|
3902
|
+
JSON.stringify({
|
|
3903
|
+
provider: email ? "email" : "phone",
|
|
3904
|
+
providers: email && phone ? ["email", "phone"] : [email ? "email" : "phone"],
|
|
3905
|
+
...body.app_metadata ?? {}
|
|
3906
|
+
}),
|
|
2006
3907
|
JSON.stringify(body.user_metadata ?? {})
|
|
2007
3908
|
]);
|
|
2008
3909
|
const user = res.rows[0];
|
|
2009
|
-
|
|
3910
|
+
if (!user)
|
|
3911
|
+
return null;
|
|
3912
|
+
if (email)
|
|
3913
|
+
await this.ensureEmailIdentity(user, query);
|
|
3914
|
+
if (phone)
|
|
3915
|
+
await this.ensurePhoneIdentity(user, query);
|
|
2010
3916
|
return this.userJsonWithRelations(user, query);
|
|
2011
3917
|
});
|
|
3918
|
+
if (!created)
|
|
3919
|
+
return authError(422, "email_exists", "A user with this email address has already been registered");
|
|
3920
|
+
if ("error" in created) {
|
|
3921
|
+
return authError(422, created.error, created.error === "email_exists" ? "A user with this email address has already been registered" : "A user with this phone number has already been registered");
|
|
3922
|
+
}
|
|
2012
3923
|
return json(200, created);
|
|
2013
3924
|
}
|
|
2014
3925
|
if (idMatch && method === "GET") {
|
|
@@ -2021,14 +3932,43 @@ Or sign in with this link: ${link}`
|
|
|
2021
3932
|
const body = await req.json().catch(() => ({}));
|
|
2022
3933
|
const sets = [];
|
|
2023
3934
|
const params = [];
|
|
2024
|
-
|
|
2025
|
-
|
|
3935
|
+
const requestedEmail = body.email === undefined ? undefined : normalizeEmailInput(body.email);
|
|
3936
|
+
if (body.email !== undefined && !requestedEmail)
|
|
3937
|
+
return authError(400, "validation_failed", "email must not be empty");
|
|
3938
|
+
if (requestedEmail) {
|
|
3939
|
+
params.push(requestedEmail);
|
|
2026
3940
|
sets.push(`email = $${params.length}`);
|
|
2027
3941
|
}
|
|
2028
3942
|
if (typeof body.password === "string") {
|
|
2029
3943
|
params.push(await hashPassword(body.password));
|
|
2030
3944
|
sets.push(`encrypted_password = $${params.length}`);
|
|
2031
3945
|
}
|
|
3946
|
+
const requestedPhone = body.phone === undefined ? undefined : normalizePhone(body.phone);
|
|
3947
|
+
if (body.phone !== undefined && !requestedPhone) {
|
|
3948
|
+
return authError(400, "validation_failed", "phone must be a valid E.164 number");
|
|
3949
|
+
}
|
|
3950
|
+
if (body.phone_confirm !== undefined && typeof body.phone_confirm !== "boolean") {
|
|
3951
|
+
return authError(400, "validation_failed", "phone_confirm must be a boolean");
|
|
3952
|
+
}
|
|
3953
|
+
if (body.ban_duration !== undefined) {
|
|
3954
|
+
const seconds = banDurationSeconds(body.ban_duration);
|
|
3955
|
+
if (seconds === undefined)
|
|
3956
|
+
return authError(400, "validation_failed", "ban_duration is invalid");
|
|
3957
|
+
if (seconds === null) {
|
|
3958
|
+
sets.push("banned_until = null");
|
|
3959
|
+
} else {
|
|
3960
|
+
params.push(seconds);
|
|
3961
|
+
sets.push(`banned_until = now() + ($${params.length} * interval '1 second')`);
|
|
3962
|
+
}
|
|
3963
|
+
}
|
|
3964
|
+
if (requestedPhone) {
|
|
3965
|
+
params.push(requestedPhone);
|
|
3966
|
+
sets.push(`phone = $${params.length}`);
|
|
3967
|
+
}
|
|
3968
|
+
if (body.phone_confirm === true)
|
|
3969
|
+
sets.push("phone_confirmed_at = now()");
|
|
3970
|
+
if (body.phone_confirm === false)
|
|
3971
|
+
sets.push("phone_confirmed_at = null");
|
|
2032
3972
|
if (body.user_metadata) {
|
|
2033
3973
|
params.push(JSON.stringify(body.user_metadata));
|
|
2034
3974
|
sets.push(`raw_user_meta_data = $${params.length}::jsonb`);
|
|
@@ -2038,70 +3978,175 @@ Or sign in with this link: ${link}`
|
|
|
2038
3978
|
sets.push(`raw_app_meta_data = coalesce(raw_app_meta_data, '{}'::jsonb) || $${params.length}::jsonb`);
|
|
2039
3979
|
}
|
|
2040
3980
|
if (body.email_confirm === true)
|
|
2041
|
-
sets.push(
|
|
3981
|
+
sets.push("email_confirmed_at = now()");
|
|
3982
|
+
if (body.email_confirm === false)
|
|
3983
|
+
sets.push("email_confirmed_at = null");
|
|
2042
3984
|
if (sets.length === 0)
|
|
2043
3985
|
return authError(400, "validation_failed", "nothing to update");
|
|
2044
3986
|
params.push(idMatch[1]);
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
3987
|
+
let updated;
|
|
3988
|
+
let emailConflict = false;
|
|
3989
|
+
let phoneConflict = false;
|
|
3990
|
+
try {
|
|
3991
|
+
updated = await this.db.transaction(async (query) => {
|
|
3992
|
+
if (requestedEmail) {
|
|
3993
|
+
const clash = await query(`select id from auth.users where lower(email) = $1 and id <> $2`, [requestedEmail, idMatch[1]]);
|
|
3994
|
+
if (clash.rows.length > 0) {
|
|
3995
|
+
emailConflict = true;
|
|
3996
|
+
return null;
|
|
3997
|
+
}
|
|
3998
|
+
}
|
|
3999
|
+
if (requestedPhone) {
|
|
4000
|
+
const clash = await query(`select id from auth.users where phone = $1 and id <> $2`, [requestedPhone, idMatch[1]]);
|
|
4001
|
+
if (clash.rows.length > 0) {
|
|
4002
|
+
phoneConflict = true;
|
|
4003
|
+
return null;
|
|
4004
|
+
}
|
|
4005
|
+
}
|
|
4006
|
+
const res = await query(`update auth.users set ${sets.join(", ")}, updated_at = now() where id = $${params.length} returning *`, params);
|
|
4007
|
+
const user = res.rows[0];
|
|
4008
|
+
if (!user)
|
|
4009
|
+
return null;
|
|
4010
|
+
if (requestedEmail)
|
|
4011
|
+
await this.ensureEmailIdentity(user, query);
|
|
4012
|
+
if (requestedPhone || body.phone_confirm !== undefined) {
|
|
4013
|
+
if (requestedPhone) {
|
|
4014
|
+
await query(`delete from auth.identities
|
|
4015
|
+
where user_id = $1 and provider = 'phone' and provider_id <> $2`, [user.id, requestedPhone]);
|
|
4016
|
+
}
|
|
4017
|
+
await this.ensurePhoneIdentity(user, query);
|
|
4018
|
+
}
|
|
4019
|
+
return this.userJsonWithRelations(user, query);
|
|
4020
|
+
});
|
|
4021
|
+
} catch (error) {
|
|
4022
|
+
if (databaseDiagnosticCode(error) === "23505" && requestedEmail) {
|
|
4023
|
+
return authError(422, "email_exists", "A user with this email address has already been registered");
|
|
4024
|
+
}
|
|
4025
|
+
if (databaseDiagnosticCode(error) === "23505" && requestedPhone) {
|
|
4026
|
+
return authError(422, "phone_exists", "A user with this phone number has already been registered");
|
|
4027
|
+
}
|
|
4028
|
+
throw error;
|
|
4029
|
+
}
|
|
4030
|
+
if (emailConflict)
|
|
4031
|
+
return authError(422, "email_exists", "A user with this email address has already been registered");
|
|
4032
|
+
if (phoneConflict)
|
|
4033
|
+
return authError(422, "phone_exists", "A user with this phone number has already been registered");
|
|
2054
4034
|
if (!updated)
|
|
2055
4035
|
return authError(404, "user_not_found", "User not found");
|
|
2056
4036
|
return json(200, updated);
|
|
2057
4037
|
}
|
|
2058
4038
|
if (idMatch && method === "DELETE") {
|
|
2059
|
-
return await this.eraseUser(idMatch[1]);
|
|
4039
|
+
return await this.eraseUser(idMatch[1], req);
|
|
2060
4040
|
}
|
|
2061
4041
|
return authError(404, "not_found", `unknown admin endpoint`);
|
|
2062
4042
|
}
|
|
2063
|
-
async generateAdminMagicLink(req) {
|
|
4043
|
+
async generateAdminMagicLink(req, sendEmail = false, forcedType) {
|
|
2064
4044
|
const body = await req.json().catch(() => ({}));
|
|
2065
|
-
if (
|
|
2066
|
-
|
|
4045
|
+
if (forcedType)
|
|
4046
|
+
body.type = forcedType;
|
|
4047
|
+
if (!["signup", "invite", "magiclink", "recovery", "email_change_current", "email_change_new"].includes(body.type ?? "")) {
|
|
4048
|
+
return authError(422, "unsupported_link_type", "Unsupported email action link type");
|
|
2067
4049
|
}
|
|
2068
|
-
|
|
4050
|
+
const email = normalizeEmailInput(body.email);
|
|
4051
|
+
if (!email)
|
|
2069
4052
|
return authError(400, "validation_failed", "email is required");
|
|
2070
|
-
const
|
|
4053
|
+
const newEmail = body.new_email === undefined ? null : normalizeEmailInput(body.new_email);
|
|
4054
|
+
if (body.type === "email_change_current" || body.type === "email_change_new") {
|
|
4055
|
+
if (!newEmail)
|
|
4056
|
+
return authError(400, "validation_failed", "new_email is required");
|
|
4057
|
+
if (newEmail === email)
|
|
4058
|
+
return authError(422, "validation_failed", "new_email must differ from email");
|
|
4059
|
+
}
|
|
4060
|
+
if (body.type === "signup") {
|
|
4061
|
+
if (!body.password) {
|
|
4062
|
+
return authError(400, "validation_failed", "password is required for signup links");
|
|
4063
|
+
}
|
|
4064
|
+
if (body.password.length < this.settings.minPasswordLength) {
|
|
4065
|
+
return authError(422, "weak_password", `Password should be at least ${this.settings.minPasswordLength} characters.`);
|
|
4066
|
+
}
|
|
4067
|
+
}
|
|
2071
4068
|
const emailOtp = randomOtp(this.settings.otpLength);
|
|
2072
4069
|
const hashedToken = await sha256Hex(randomToken(32));
|
|
2073
4070
|
const expiry = `${this.settings.otpExpirySeconds} seconds`;
|
|
4071
|
+
const passwordHash = body.type === "signup" ? await hashPassword(body.password) : null;
|
|
2074
4072
|
const requestUrl = new URL(req.url);
|
|
2075
4073
|
const redirectTo = resolveRedirect(requestUrl.searchParams.get("redirect_to") ?? body.redirect_to, this.config.siteUrl, this.config.uriAllowList, this.config.enforceRedirectAllowList);
|
|
2076
4074
|
const user = await this.db.transaction(async (query) => {
|
|
2077
|
-
const
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
4075
|
+
const existing = await query(`select * from auth.users where lower(email) = $1 for update`, [email]);
|
|
4076
|
+
let linkedUser = existing.rows[0];
|
|
4077
|
+
if (!linkedUser && (body.type === "recovery" || body.type === "email_change_current" || body.type === "email_change_new")) {
|
|
4078
|
+
return null;
|
|
4079
|
+
}
|
|
4080
|
+
if (linkedUser && (body.type === "signup" || body.type === "invite")) {
|
|
4081
|
+
return { error: "email_exists" };
|
|
4082
|
+
}
|
|
4083
|
+
if (linkedUser && (body.type === "email_change_current" || body.type === "email_change_new")) {
|
|
4084
|
+
const duplicate = await query(`select id from auth.users where lower(email) = $1 and id <> $2`, [newEmail, linkedUser.id]);
|
|
4085
|
+
if (duplicate.rows.length > 0)
|
|
4086
|
+
return { error: "email_exists" };
|
|
4087
|
+
const tokenType = body.type;
|
|
4088
|
+
await query(`delete from auth.one_time_tokens where user_id = $1 and token_type = $2`, [linkedUser.id, tokenType]);
|
|
4089
|
+
await query(`insert into auth.one_time_tokens (user_id, email, token_type, token, expires_at)
|
|
4090
|
+
values ($1, $2, $3, $4, now() + $5::interval),
|
|
4091
|
+
($1, $2, $3, $6, now() + $5::interval)`, [linkedUser.id, tokenType === "email_change_current" ? email : newEmail, tokenType, emailOtp, expiry, hashedToken]);
|
|
4092
|
+
const tokenColumn = tokenType === "email_change_current" ? "email_change_token_current" : "email_change_token_new";
|
|
4093
|
+
await query(`update auth.users
|
|
4094
|
+
set email_change = $1, email_change_sent_at = now(), email_change_confirm_status = 0,
|
|
4095
|
+
${tokenColumn} = $2, updated_at = now()
|
|
4096
|
+
where id = $3`, [newEmail, hashedToken, linkedUser.id]);
|
|
4097
|
+
return (await query(`select * from auth.users where id = $1`, [linkedUser.id])).rows[0];
|
|
4098
|
+
}
|
|
4099
|
+
if (!linkedUser) {
|
|
4100
|
+
const result = await query(`insert into auth.users
|
|
4101
|
+
(aud, role, email, encrypted_password, invited_at, raw_app_meta_data, raw_user_meta_data)
|
|
4102
|
+
values ('authenticated', 'authenticated', $1, $2, case when $3 then now() else null end,
|
|
4103
|
+
'{"provider":"email","providers":["email"]}', $4::jsonb)
|
|
4104
|
+
on conflict (email) do update set email = excluded.email
|
|
4105
|
+
returning *`, [email, passwordHash, body.type === "invite", JSON.stringify(body.data ?? {})]);
|
|
4106
|
+
linkedUser = result.rows[0];
|
|
4107
|
+
}
|
|
4108
|
+
await this.ensureEmailIdentity(linkedUser, query);
|
|
2086
4109
|
await query(`delete from auth.one_time_tokens
|
|
2087
|
-
where email = $1 and token_type = any($2::text[])`, [email, "{otp,magiclink}"]);
|
|
4110
|
+
where lower(email) = $1 and token_type = any($2::text[])`, [email, body.type === "magiclink" ? "{otp,magiclink}" : `{${body.type}}`]);
|
|
2088
4111
|
await query(`insert into auth.one_time_tokens (user_id, email, token_type, token, expires_at)
|
|
2089
|
-
values ($1, $2,
|
|
2090
|
-
($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
|
+
]);
|
|
2091
4122
|
return linkedUser;
|
|
2092
4123
|
});
|
|
4124
|
+
if (!user)
|
|
4125
|
+
return authError(404, "user_not_found", "User not found");
|
|
4126
|
+
if ("error" in user)
|
|
4127
|
+
return authError(422, user.error, "A user with this email address has already been registered");
|
|
2093
4128
|
const actionUrl = new URL(`${this.config.apiUrl}/auth/v1/verify`);
|
|
2094
4129
|
actionUrl.searchParams.set("token", hashedToken);
|
|
2095
|
-
actionUrl.searchParams.set("type", "
|
|
4130
|
+
actionUrl.searchParams.set("type", body.type === "email_change_current" || body.type === "email_change_new" ? "email_change" : body.type);
|
|
2096
4131
|
actionUrl.searchParams.set("redirect_to", redirectTo);
|
|
2097
|
-
|
|
4132
|
+
if (sendEmail) {
|
|
4133
|
+
await this.audit("user_invited", { actorId: user.id, actorEmail: email, type: "admin" });
|
|
4134
|
+
await this.config.mailer.send({
|
|
4135
|
+
to: email,
|
|
4136
|
+
subject: "You have been invited",
|
|
4137
|
+
text: `Accept your invitation with this link: ${actionUrl}`
|
|
4138
|
+
});
|
|
4139
|
+
return json(200, { user: this.userJson(user, [], await this.getUserIdentities(user.id)) });
|
|
4140
|
+
}
|
|
4141
|
+
const auditAction = body.type === "invite" ? "user_invited" : body.type === "signup" ? "user_signedup" : body.type === "recovery" ? "user_recovery_requested" : body.type === "email_change_current" || body.type === "email_change_new" ? "user_email_change_requested" : "user_magiclink_requested";
|
|
4142
|
+
await this.audit(auditAction, { actorId: user.id, actorEmail: email, type: "admin" });
|
|
2098
4143
|
return json(200, {
|
|
2099
4144
|
...this.userJson(user, [], await this.getUserIdentities(user.id)),
|
|
2100
4145
|
action_link: actionUrl.toString(),
|
|
2101
4146
|
email_otp: emailOtp,
|
|
2102
4147
|
hashed_token: hashedToken,
|
|
2103
4148
|
redirect_to: redirectTo,
|
|
2104
|
-
verification_type:
|
|
4149
|
+
verification_type: body.type
|
|
2105
4150
|
});
|
|
2106
4151
|
}
|
|
2107
4152
|
async audit(action, opts = {}) {
|
|
@@ -2148,17 +4193,46 @@ Or sign in with this link: ${link}`
|
|
|
2148
4193
|
mfa_factors: factors.rows
|
|
2149
4194
|
});
|
|
2150
4195
|
}
|
|
2151
|
-
async eraseUser(userId) {
|
|
4196
|
+
async eraseUser(userId, req) {
|
|
4197
|
+
const body = await req.json().catch(() => ({}));
|
|
4198
|
+
const shouldSoftDelete = body.should_soft_delete === true;
|
|
4199
|
+
const userResult = await this.db.query(`select * from auth.users where id = $1`, [userId]);
|
|
4200
|
+
const user = userResult.rows[0];
|
|
4201
|
+
if (!user)
|
|
4202
|
+
return authError(404, "user_not_found", "User not found");
|
|
2152
4203
|
const before = await this.db.query(`select
|
|
2153
4204
|
(select count(*) from auth.identities where user_id = $1)::int as identities,
|
|
2154
4205
|
(select count(*) from auth.refresh_tokens where user_id = $1)::int as sessions,
|
|
2155
4206
|
(select count(*) from auth.mfa_factors where user_id = $1)::int as factors`, [userId]);
|
|
4207
|
+
if (shouldSoftDelete) {
|
|
4208
|
+
const deleted = await this.db.transaction(async (query) => {
|
|
4209
|
+
const updated = await query(`update auth.users set deleted_at = coalesce(deleted_at, now()), updated_at = now()
|
|
4210
|
+
where id = $1 returning *`, [userId]);
|
|
4211
|
+
if (updated.rows.length === 0)
|
|
4212
|
+
return null;
|
|
4213
|
+
await query(`update auth.refresh_tokens set revoked = true, updated_at = now() where user_id = $1`, [userId]);
|
|
4214
|
+
await query(`delete from auth.one_time_tokens where user_id = $1`, [userId]);
|
|
4215
|
+
await query(`delete from auth.flow_state where user_id = $1`, [userId]);
|
|
4216
|
+
return updated.rows[0];
|
|
4217
|
+
});
|
|
4218
|
+
if (!deleted)
|
|
4219
|
+
return authError(404, "user_not_found", "User not found");
|
|
4220
|
+
await this.audit("user_deleted", { actorId: userId, type: "admin", traits: { erased: false, soft_deleted: true } });
|
|
4221
|
+
const c = before.rows[0];
|
|
4222
|
+
return json(200, {
|
|
4223
|
+
user: this.userJson(deleted),
|
|
4224
|
+
erased: false,
|
|
4225
|
+
user_id: userId,
|
|
4226
|
+
cascaded: { identities: c.identities, sessions: c.sessions, mfa_factors: c.factors }
|
|
4227
|
+
});
|
|
4228
|
+
}
|
|
2156
4229
|
const del = await this.db.query(`delete from auth.users where id = $1 returning id`, [userId]);
|
|
2157
4230
|
if (del.rows.length === 0)
|
|
2158
4231
|
return authError(404, "user_not_found", "User not found");
|
|
2159
4232
|
const c = before.rows[0];
|
|
2160
4233
|
await this.audit("user_deleted", { actorId: userId, type: "admin", traits: { erased: true } });
|
|
2161
4234
|
return json(200, {
|
|
4235
|
+
user: this.userJson(user),
|
|
2162
4236
|
erased: true,
|
|
2163
4237
|
user_id: userId,
|
|
2164
4238
|
cascaded: { identities: c.identities, sessions: c.sessions, mfa_factors: c.factors }
|
|
@@ -2176,17 +4250,7 @@ Or sign in with this link: ${link}`
|
|
|
2176
4250
|
if (!this.settings.totpEnrollEnabled) {
|
|
2177
4251
|
return authError(422, "mfa_totp_enroll_disabled", "TOTP enrollment is disabled");
|
|
2178
4252
|
}
|
|
2179
|
-
const enrolled = await this.db.query(`select count(*)::int as n from auth.mfa_factors where user_id = $1`, [user.id]);
|
|
2180
|
-
if (enrolled.rows[0].n >= this.settings.maxEnrolledFactors) {
|
|
2181
|
-
return authError(422, "too_many_enrolled_mfa_factors", "Maximum number of enrolled MFA factors reached");
|
|
2182
|
-
}
|
|
2183
4253
|
const friendlyName = body.friendly_name ?? null;
|
|
2184
|
-
if (friendlyName) {
|
|
2185
|
-
const dup = await this.db.query(`select 1 from auth.mfa_factors where user_id = $1 and friendly_name = $2`, [user.id, friendlyName]);
|
|
2186
|
-
if (dup.rows.length > 0) {
|
|
2187
|
-
return authError(422, "mfa_factor_name_conflict", "A factor with this friendly name already exists");
|
|
2188
|
-
}
|
|
2189
|
-
}
|
|
2190
4254
|
const secret = generateTotpSecret();
|
|
2191
4255
|
let issuer = body.issuer;
|
|
2192
4256
|
if (!issuer) {
|
|
@@ -2196,16 +4260,33 @@ Or sign in with this link: ${link}`
|
|
|
2196
4260
|
issuer = "supacloud-lite";
|
|
2197
4261
|
}
|
|
2198
4262
|
}
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
4263
|
+
return this.db.transaction(async (query) => {
|
|
4264
|
+
const currentResult = await query(`select * from auth.users where id = $1 and deleted_at is null
|
|
4265
|
+
and (banned_until is null or banned_until <= now()) for update`, [user.id]);
|
|
4266
|
+
const current = currentResult.rows[0];
|
|
4267
|
+
if (!current)
|
|
4268
|
+
return authError(401, "no_authorization", "Invalid or expired token");
|
|
4269
|
+
const uri = otpauthUri({ secret, account: current.email || current.id, issuer });
|
|
4270
|
+
const enrolled = await query(`select count(*)::int as n from auth.mfa_factors where user_id = $1`, [current.id]);
|
|
4271
|
+
if (enrolled.rows[0].n >= this.settings.maxEnrolledFactors) {
|
|
4272
|
+
return authError(422, "too_many_enrolled_mfa_factors", "Maximum number of enrolled MFA factors reached");
|
|
4273
|
+
}
|
|
4274
|
+
if (friendlyName) {
|
|
4275
|
+
const dup = await query(`select 1 from auth.mfa_factors where user_id = $1 and friendly_name = $2`, [current.id, friendlyName]);
|
|
4276
|
+
if (dup.rows.length > 0) {
|
|
4277
|
+
return authError(422, "mfa_factor_name_conflict", "A factor with this friendly name already exists");
|
|
4278
|
+
}
|
|
4279
|
+
}
|
|
4280
|
+
const ins = await query(`insert into auth.mfa_factors (user_id, friendly_name, factor_type, status, secret)
|
|
4281
|
+
values ($1, $2, 'totp', 'unverified', $3) returning id`, [current.id, friendlyName, secret]);
|
|
4282
|
+
const id = ins.rows[0].id;
|
|
4283
|
+
return json(200, {
|
|
4284
|
+
id,
|
|
4285
|
+
type: "totp",
|
|
4286
|
+
friendly_name: friendlyName,
|
|
4287
|
+
status: "unverified",
|
|
4288
|
+
totp: { qr_code: qrSvgDataUri(uri), secret, uri }
|
|
4289
|
+
});
|
|
2209
4290
|
});
|
|
2210
4291
|
}
|
|
2211
4292
|
async challengeFactor(req, factorId) {
|
|
@@ -2215,16 +4296,25 @@ Or sign in with this link: ${link}`
|
|
|
2215
4296
|
if (!this.settings.totpVerifyEnabled) {
|
|
2216
4297
|
return authError(422, "mfa_totp_verify_disabled", "TOTP verification is disabled");
|
|
2217
4298
|
}
|
|
2218
|
-
const fr = await this.db.query(`select id from auth.mfa_factors where id = $1 and user_id = $2`, [factorId, user.id]);
|
|
2219
|
-
if (fr.rows.length === 0)
|
|
2220
|
-
return authError(404, "mfa_factor_not_found", "MFA factor not found");
|
|
2221
4299
|
const expiresAt = new Date(Date.now() + 300000).toISOString();
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
4300
|
+
return this.db.transaction(async (query) => {
|
|
4301
|
+
const currentResult = await query(`select * from auth.users where id = $1 and deleted_at is null
|
|
4302
|
+
and (banned_until is null or banned_until <= now()) for update`, [user.id]);
|
|
4303
|
+
if (!currentResult.rows[0])
|
|
4304
|
+
return authError(401, "no_authorization", "Invalid or expired token");
|
|
4305
|
+
const fr = await query(`select id, factor_type from auth.mfa_factors where id = $1 and user_id = $2`, [factorId, user.id]);
|
|
4306
|
+
if (fr.rows.length === 0)
|
|
4307
|
+
return authError(404, "mfa_factor_not_found", "MFA factor not found");
|
|
4308
|
+
if (fr.rows[0].factor_type === "recovery_code") {
|
|
4309
|
+
return authError(422, "validation_failed", "Recovery code factors use the recovery-codes API");
|
|
4310
|
+
}
|
|
4311
|
+
const ins = await query(`insert into auth.mfa_challenges (factor_id, expires_at) values ($1, $2) returning id, expires_at`, [factorId, expiresAt]);
|
|
4312
|
+
const c = ins.rows[0];
|
|
4313
|
+
return json(200, {
|
|
4314
|
+
id: c.id,
|
|
4315
|
+
type: "totp",
|
|
4316
|
+
expires_at: Math.floor(new Date(c.expires_at).getTime() / 1000)
|
|
4317
|
+
});
|
|
2228
4318
|
});
|
|
2229
4319
|
}
|
|
2230
4320
|
async verifyFactor(req, factorId) {
|
|
@@ -2235,10 +4325,13 @@ Or sign in with this link: ${link}`
|
|
|
2235
4325
|
return authError(422, "mfa_totp_verify_disabled", "TOTP verification is disabled");
|
|
2236
4326
|
}
|
|
2237
4327
|
const body = await req.json().catch(() => ({}));
|
|
2238
|
-
const fr = await this.db.query(`select id, secret, status from auth.mfa_factors where id = $1 and user_id = $2`, [factorId, user.id]);
|
|
4328
|
+
const fr = await this.db.query(`select id, secret, status, factor_type from auth.mfa_factors where id = $1 and user_id = $2`, [factorId, user.id]);
|
|
2239
4329
|
const factor = fr.rows[0];
|
|
2240
4330
|
if (!factor)
|
|
2241
4331
|
return authError(404, "mfa_factor_not_found", "MFA factor not found");
|
|
4332
|
+
if (factor.factor_type === "recovery_code") {
|
|
4333
|
+
return authError(422, "validation_failed", "Recovery code factors use the recovery-codes API");
|
|
4334
|
+
}
|
|
2242
4335
|
const cr = await this.db.query(`select id, expires_at, verified_at from auth.mfa_challenges where id = $1 and factor_id = $2`, [body.challenge_id ?? "", factorId]);
|
|
2243
4336
|
const challenge = cr.rows[0];
|
|
2244
4337
|
if (!challenge)
|
|
@@ -2258,12 +4351,17 @@ Or sign in with this link: ${link}`
|
|
|
2258
4351
|
const currentUser = currentUsers.rows[0];
|
|
2259
4352
|
if (!currentUser)
|
|
2260
4353
|
return null;
|
|
4354
|
+
const currentFactors = await query(`select id, secret, status, factor_type from auth.mfa_factors
|
|
4355
|
+
where id = $1 and user_id = $2 for update`, [factorId, user.id]);
|
|
4356
|
+
const currentFactor = currentFactors.rows[0];
|
|
4357
|
+
if (!currentFactor || currentFactor.factor_type === "recovery_code" || !await verifyTotp(currentFactor.secret, body.code ?? ""))
|
|
4358
|
+
return null;
|
|
2261
4359
|
const claimed = await query(`update auth.mfa_challenges set verified_at = now()
|
|
2262
4360
|
where id = $1 and factor_id = $2 and verified_at is null and expires_at >= now()
|
|
2263
4361
|
returning id`, [challenge.id, factorId]);
|
|
2264
4362
|
if (claimed.rows.length === 0)
|
|
2265
4363
|
return null;
|
|
2266
|
-
if (
|
|
4364
|
+
if (currentFactor.status !== "verified") {
|
|
2267
4365
|
await query(`update auth.mfa_factors set status = 'verified', updated_at = now() where id = $1`, [factorId]);
|
|
2268
4366
|
}
|
|
2269
4367
|
return this.sessionFor(currentUser, undefined, {
|
|
@@ -2279,14 +4377,214 @@ Or sign in with this link: ${link}`
|
|
|
2279
4377
|
}
|
|
2280
4378
|
return json(200, session);
|
|
2281
4379
|
}
|
|
4380
|
+
async recoveryCodesStatus(req) {
|
|
4381
|
+
const user = await this.userFromBearer(req);
|
|
4382
|
+
if (!user)
|
|
4383
|
+
return authError(401, "no_authorization", "This endpoint requires a Bearer token");
|
|
4384
|
+
const result = await this.db.query(`select f.id, count(c.id)::int as total,
|
|
4385
|
+
count(c.id) filter (where c.consumed_at is null)::int as remaining
|
|
4386
|
+
from auth.mfa_recovery_code_sets s
|
|
4387
|
+
join auth.mfa_factors f on f.id = s.mfa_factor_id
|
|
4388
|
+
left join auth.mfa_recovery_codes c on c.mfa_recovery_code_set_id = s.id
|
|
4389
|
+
where s.user_id = $1
|
|
4390
|
+
group by f.id`, [user.id]);
|
|
4391
|
+
const row = result.rows[0];
|
|
4392
|
+
if (!row)
|
|
4393
|
+
return authError(404, "mfa_factor_not_found", "The user has not enrolled recovery codes");
|
|
4394
|
+
return json(200, { id: row.id, type: "recovery_code", total: row.total, remaining: row.remaining });
|
|
4395
|
+
}
|
|
4396
|
+
async generateRecoveryCodes(req) {
|
|
4397
|
+
const user = await this.userFromBearer(req);
|
|
4398
|
+
if (!user)
|
|
4399
|
+
return authError(401, "no_authorization", "This endpoint requires a Bearer token");
|
|
4400
|
+
if (!this.settings.recoveryCodesEnrollEnabled) {
|
|
4401
|
+
return authError(422, "mfa_recovery_codes_enroll_not_enabled", "MFA enroll is disabled for recovery codes");
|
|
4402
|
+
}
|
|
4403
|
+
if (!await this.requireAal2(req))
|
|
4404
|
+
return authError(403, "insufficient_aal", "AAL2 required to generate recovery codes");
|
|
4405
|
+
const body = await req.json().catch(() => ({}));
|
|
4406
|
+
const friendlyName = typeof body.friendly_name === "string" && body.friendly_name.trim() ? body.friendly_name.trim().slice(0, 255) : "Recovery codes";
|
|
4407
|
+
const codes = Array.from({ length: this.settings.recoveryCodesCount }, () => randomRecoveryCode(this.settings.recoveryCodeLength));
|
|
4408
|
+
const hashes = await Promise.all(codes.map((code) => hashPassword(code)));
|
|
4409
|
+
return this.db.transaction(async (query) => {
|
|
4410
|
+
const current = (await query(`select * from auth.users where id = $1 and deleted_at is null
|
|
4411
|
+
and (banned_until is null or banned_until <= now()) for update`, [user.id])).rows[0];
|
|
4412
|
+
if (!current)
|
|
4413
|
+
return authError(401, "no_authorization", "Invalid or expired token");
|
|
4414
|
+
const existing = await query(`select 1 from auth.mfa_recovery_code_sets where user_id = $1`, [user.id]);
|
|
4415
|
+
if (existing.rows.length > 0) {
|
|
4416
|
+
return authError(422, "mfa_verified_factor_exists", "Recovery codes are already enrolled for this user, use regenerate to rotate them");
|
|
4417
|
+
}
|
|
4418
|
+
const verified = await query(`select count(*)::int as count from auth.mfa_factors
|
|
4419
|
+
where user_id = $1 and status = 'verified' and factor_type <> 'recovery_code'`, [user.id]);
|
|
4420
|
+
if ((verified.rows[0]?.count ?? 0) < 1) {
|
|
4421
|
+
return authError(422, "mfa_recovery_codes_sole_factor", "At least one other verified factor is required to generate recovery codes");
|
|
4422
|
+
}
|
|
4423
|
+
const duplicate = await query(`select 1 from auth.mfa_factors where user_id = $1 and friendly_name = $2`, [user.id, friendlyName]);
|
|
4424
|
+
if (duplicate.rows.length > 0) {
|
|
4425
|
+
return authError(422, "mfa_factor_name_conflict", "A factor with this friendly name already exists");
|
|
4426
|
+
}
|
|
4427
|
+
const factors = await query(`select count(*)::int as count from auth.mfa_factors where user_id = $1`, [user.id]);
|
|
4428
|
+
if ((factors.rows[0]?.count ?? 0) >= this.settings.maxEnrolledFactors) {
|
|
4429
|
+
return authError(422, "too_many_enrolled_mfa_factors", "Maximum number of enrolled MFA factors reached");
|
|
4430
|
+
}
|
|
4431
|
+
const factor = (await query(`insert into auth.mfa_factors (user_id, friendly_name, factor_type, status)
|
|
4432
|
+
values ($1, $2, 'recovery_code', 'verified') returning id`, [user.id, friendlyName])).rows[0];
|
|
4433
|
+
const set = (await query(`insert into auth.mfa_recovery_code_sets (user_id, mfa_factor_id) values ($1, $2) returning id`, [user.id, factor.id])).rows[0];
|
|
4434
|
+
for (const hash of hashes) {
|
|
4435
|
+
await query(`insert into auth.mfa_recovery_codes (mfa_recovery_code_set_id, code_hash) values ($1, $2)`, [set.id, hash]);
|
|
4436
|
+
}
|
|
4437
|
+
return json(200, {
|
|
4438
|
+
id: factor.id,
|
|
4439
|
+
type: "recovery_code",
|
|
4440
|
+
friendly_name: friendlyName,
|
|
4441
|
+
total: codes.length,
|
|
4442
|
+
codes
|
|
4443
|
+
});
|
|
4444
|
+
});
|
|
4445
|
+
}
|
|
4446
|
+
async regenerateRecoveryCodes(req) {
|
|
4447
|
+
const user = await this.userFromBearer(req);
|
|
4448
|
+
if (!user)
|
|
4449
|
+
return authError(401, "no_authorization", "This endpoint requires a Bearer token");
|
|
4450
|
+
if (!this.settings.recoveryCodesEnrollEnabled) {
|
|
4451
|
+
return authError(422, "mfa_recovery_codes_enroll_not_enabled", "MFA enroll is disabled for recovery codes");
|
|
4452
|
+
}
|
|
4453
|
+
if (!await this.requireAal2(req))
|
|
4454
|
+
return authError(403, "insufficient_aal", "AAL2 required to regenerate recovery codes");
|
|
4455
|
+
const codes = Array.from({ length: this.settings.recoveryCodesCount }, () => randomRecoveryCode(this.settings.recoveryCodeLength));
|
|
4456
|
+
const hashes = await Promise.all(codes.map((code) => hashPassword(code)));
|
|
4457
|
+
return this.db.transaction(async (query) => {
|
|
4458
|
+
const current = (await query(`select * from auth.users where id = $1 and deleted_at is null
|
|
4459
|
+
and (banned_until is null or banned_until <= now()) for update`, [user.id])).rows[0];
|
|
4460
|
+
if (!current)
|
|
4461
|
+
return authError(401, "no_authorization", "Invalid or expired token");
|
|
4462
|
+
const set = (await query(`select id, mfa_factor_id from auth.mfa_recovery_code_sets where user_id = $1 for update`, [user.id])).rows[0];
|
|
4463
|
+
if (!set)
|
|
4464
|
+
return authError(404, "mfa_factor_not_found", "The user has not enrolled recovery codes");
|
|
4465
|
+
const factor = (await query(`select id, friendly_name from auth.mfa_factors where id = $1 and user_id = $2`, [set.mfa_factor_id, user.id])).rows[0];
|
|
4466
|
+
if (!factor)
|
|
4467
|
+
return authError(404, "mfa_factor_not_found", "The user has not enrolled recovery codes");
|
|
4468
|
+
await query(`delete from auth.mfa_recovery_codes where mfa_recovery_code_set_id = $1`, [set.id]);
|
|
4469
|
+
for (const hash of hashes) {
|
|
4470
|
+
await query(`insert into auth.mfa_recovery_codes (mfa_recovery_code_set_id, code_hash) values ($1, $2)`, [set.id, hash]);
|
|
4471
|
+
}
|
|
4472
|
+
await query(`update auth.mfa_recovery_code_sets
|
|
4473
|
+
set failed_verification_count = 0, verification_locked_until = null, updated_at = now()
|
|
4474
|
+
where id = $1`, [set.id]);
|
|
4475
|
+
return json(200, {
|
|
4476
|
+
id: factor.id,
|
|
4477
|
+
type: "recovery_code",
|
|
4478
|
+
friendly_name: factor.friendly_name ?? "Recovery codes",
|
|
4479
|
+
total: codes.length,
|
|
4480
|
+
codes
|
|
4481
|
+
});
|
|
4482
|
+
});
|
|
4483
|
+
}
|
|
4484
|
+
async deleteRecoveryCodes(req) {
|
|
4485
|
+
const user = await this.userFromBearer(req);
|
|
4486
|
+
if (!user)
|
|
4487
|
+
return authError(401, "no_authorization", "This endpoint requires a Bearer token");
|
|
4488
|
+
if (!await this.requireAal2(req))
|
|
4489
|
+
return authError(403, "insufficient_aal", "AAL2 required to delete recovery codes");
|
|
4490
|
+
return this.db.transaction(async (query) => {
|
|
4491
|
+
const current = (await query(`select * from auth.users where id = $1 and deleted_at is null
|
|
4492
|
+
and (banned_until is null or banned_until <= now()) for update`, [user.id])).rows[0];
|
|
4493
|
+
if (!current)
|
|
4494
|
+
return authError(401, "no_authorization", "Invalid or expired token");
|
|
4495
|
+
const factor = (await query(`select mfa_factor_id as id from auth.mfa_recovery_code_sets where user_id = $1 for update`, [user.id])).rows[0];
|
|
4496
|
+
if (!factor)
|
|
4497
|
+
return authError(404, "mfa_factor_not_found", "The user has not enrolled recovery codes");
|
|
4498
|
+
await query(`delete from auth.mfa_recovery_codes
|
|
4499
|
+
where mfa_recovery_code_set_id in (select id from auth.mfa_recovery_code_sets where user_id = $1)`, [user.id]);
|
|
4500
|
+
await query(`delete from auth.mfa_recovery_code_sets where user_id = $1`, [user.id]);
|
|
4501
|
+
await query(`delete from auth.mfa_factors where id = $1 and user_id = $2`, [factor.id, user.id]);
|
|
4502
|
+
return json(200, { id: factor.id });
|
|
4503
|
+
});
|
|
4504
|
+
}
|
|
4505
|
+
async verifyRecoveryCode(req) {
|
|
4506
|
+
const user = await this.userFromBearer(req);
|
|
4507
|
+
if (!user)
|
|
4508
|
+
return authError(401, "no_authorization", "This endpoint requires a Bearer token");
|
|
4509
|
+
if (!this.settings.recoveryCodesVerifyEnabled) {
|
|
4510
|
+
return authError(422, "mfa_recovery_codes_verify_not_enabled", "MFA verification is disabled for recovery codes");
|
|
4511
|
+
}
|
|
4512
|
+
const body = await req.json().catch(() => ({}));
|
|
4513
|
+
const code = normalizeRecoveryCode(body.code);
|
|
4514
|
+
if (!code)
|
|
4515
|
+
return authError(400, "validation_failed", "Code needs to be non-empty");
|
|
4516
|
+
return this.db.transaction(async (query) => {
|
|
4517
|
+
const current = (await query(`select * from auth.users where id = $1 and deleted_at is null
|
|
4518
|
+
and (banned_until is null or banned_until <= now()) for update`, [user.id])).rows[0];
|
|
4519
|
+
if (!current)
|
|
4520
|
+
return authError(401, "no_authorization", "Invalid or expired token");
|
|
4521
|
+
const set = (await query(`select id, failed_verification_count, verification_locked_until
|
|
4522
|
+
from auth.mfa_recovery_code_sets where user_id = $1 for update`, [user.id])).rows[0];
|
|
4523
|
+
if (!set)
|
|
4524
|
+
return authError(422, "mfa_verification_failed", "Invalid recovery code entered");
|
|
4525
|
+
if (set.verification_locked_until && new Date(set.verification_locked_until).getTime() > Date.now()) {
|
|
4526
|
+
return authError(429, "mfa_recovery_codes_locked", "Too many failed verification attempts, try again later");
|
|
4527
|
+
}
|
|
4528
|
+
const entries = await query(`select id, code_hash from auth.mfa_recovery_codes
|
|
4529
|
+
where mfa_recovery_code_set_id = $1 and consumed_at is null`, [set.id]);
|
|
4530
|
+
let matched = null;
|
|
4531
|
+
for (const entry of entries.rows) {
|
|
4532
|
+
if (await verifyPassword(code, entry.code_hash) && matched === null)
|
|
4533
|
+
matched = entry.id;
|
|
4534
|
+
}
|
|
4535
|
+
if (!matched) {
|
|
4536
|
+
const failures = set.failed_verification_count + 1;
|
|
4537
|
+
await query(`update auth.mfa_recovery_code_sets
|
|
4538
|
+
set failed_verification_count = $1::int,
|
|
4539
|
+
verification_locked_until = case when $1::int >= $2::int
|
|
4540
|
+
then now() + $3::interval else verification_locked_until end,
|
|
4541
|
+
updated_at = now()
|
|
4542
|
+
where id = $4::uuid`, [failures, this.settings.recoveryCodesMaxVerifyAttempts, `${this.settings.recoveryCodesLockoutSeconds} seconds`, set.id]);
|
|
4543
|
+
return authError(422, "mfa_verification_failed", "Invalid recovery code entered");
|
|
4544
|
+
}
|
|
4545
|
+
const consumed = await query(`update auth.mfa_recovery_codes set consumed_at = now()
|
|
4546
|
+
where id = $1 and consumed_at is null returning id`, [matched]);
|
|
4547
|
+
if (consumed.rows.length === 0)
|
|
4548
|
+
return authError(422, "mfa_verification_failed", "Invalid recovery code entered");
|
|
4549
|
+
await query(`update auth.mfa_recovery_code_sets
|
|
4550
|
+
set failed_verification_count = 0, verification_locked_until = null, updated_at = now()
|
|
4551
|
+
where id = $1`, [set.id]);
|
|
4552
|
+
const session = await this.sessionFor(current, undefined, {
|
|
4553
|
+
aal: "aal2",
|
|
4554
|
+
amr: [
|
|
4555
|
+
{ method: "password", timestamp: Math.floor(Date.now() / 1000) },
|
|
4556
|
+
{ method: "mfa/recovery_code", timestamp: Math.floor(Date.now() / 1000) }
|
|
4557
|
+
]
|
|
4558
|
+
}, query);
|
|
4559
|
+
return json(200, session);
|
|
4560
|
+
});
|
|
4561
|
+
}
|
|
4562
|
+
async requireAal2(req) {
|
|
4563
|
+
const claims = await this.claimsFromBearer(req);
|
|
4564
|
+
return claims?.aal === "aal2";
|
|
4565
|
+
}
|
|
2282
4566
|
async unenrollFactor(req, factorId) {
|
|
2283
4567
|
const user = await this.userFromBearer(req);
|
|
2284
4568
|
if (!user)
|
|
2285
4569
|
return authError(401, "no_authorization", "This endpoint requires a Bearer token");
|
|
2286
|
-
const
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
4570
|
+
const hasAal2 = await this.requireAal2(req);
|
|
4571
|
+
return this.db.transaction(async (query) => {
|
|
4572
|
+
const currentResult = await query(`select * from auth.users where id = $1 and deleted_at is null
|
|
4573
|
+
and (banned_until is null or banned_until <= now()) for update`, [user.id]);
|
|
4574
|
+
if (!currentResult.rows[0])
|
|
4575
|
+
return authError(401, "no_authorization", "Invalid or expired token");
|
|
4576
|
+
const factor = await query(`select id, factor_type, status from auth.mfa_factors where id = $1 and user_id = $2 for update`, [factorId, user.id]);
|
|
4577
|
+
if (factor.rows.length === 0)
|
|
4578
|
+
return authError(404, "mfa_factor_not_found", "MFA factor not found");
|
|
4579
|
+
if (factor.rows[0].factor_type === "recovery_code") {
|
|
4580
|
+
return authError(422, "validation_failed", "Recovery code factors use the recovery-codes API");
|
|
4581
|
+
}
|
|
4582
|
+
if (factor.rows[0].status === "verified" && !hasAal2) {
|
|
4583
|
+
return authError(403, "insufficient_aal", "AAL2 required to unenroll a verified MFA factor");
|
|
4584
|
+
}
|
|
4585
|
+
const del = await query(`delete from auth.mfa_factors where id = $1 and user_id = $2 returning id`, [factorId, user.id]);
|
|
4586
|
+
return json(200, { id: factorId });
|
|
4587
|
+
});
|
|
2290
4588
|
}
|
|
2291
4589
|
async getUserFactors(userId, query = (sql, params) => this.db.query(sql, params)) {
|
|
2292
4590
|
const res = await query(`select id, friendly_name, factor_type, status, created_at, updated_at
|
|
@@ -2314,13 +4612,45 @@ Or sign in with this link: ${link}`
|
|
|
2314
4612
|
updated_at: iso(r.updated_at)
|
|
2315
4613
|
}));
|
|
2316
4614
|
}
|
|
2317
|
-
async ensureEmailIdentity(user, query = (sql, params) => this.db.query(sql, params)) {
|
|
4615
|
+
async ensureEmailIdentity(user, query = (sql, params) => this.db.query(sql, params), syncProviders = true) {
|
|
2318
4616
|
if (!user.email)
|
|
2319
4617
|
return;
|
|
2320
4618
|
await query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
|
|
2321
4619
|
values ($1::uuid, 'email', $1::text, $2::jsonb)
|
|
2322
4620
|
on conflict (provider, provider_id) do update
|
|
2323
4621
|
set identity_data = excluded.identity_data, updated_at = now()`, [user.id, JSON.stringify({ sub: user.id, email: user.email })]);
|
|
4622
|
+
if (syncProviders)
|
|
4623
|
+
await this.syncUserProviders(user, query);
|
|
4624
|
+
}
|
|
4625
|
+
async ensurePhoneIdentity(user, query = (sql, params) => this.db.query(sql, params)) {
|
|
4626
|
+
if (!user.phone)
|
|
4627
|
+
return;
|
|
4628
|
+
await query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
|
|
4629
|
+
values ($1::uuid, 'phone', $2, $3::jsonb)
|
|
4630
|
+
on conflict (provider, provider_id) do update
|
|
4631
|
+
set identity_data = excluded.identity_data, updated_at = now()`, [user.id, user.phone, JSON.stringify({ sub: user.id, phone: user.phone, phone_verified: Boolean(user.phone_confirmed_at) })]);
|
|
4632
|
+
await this.syncUserProviders(user, query);
|
|
4633
|
+
}
|
|
4634
|
+
async syncUserProviders(user, query = (sql, params) => this.db.query(sql, params)) {
|
|
4635
|
+
const updated = (await query(`update auth.users
|
|
4636
|
+
set raw_app_meta_data = jsonb_set(
|
|
4637
|
+
coalesce(raw_app_meta_data, '{}'::jsonb),
|
|
4638
|
+
'{providers}',
|
|
4639
|
+
(
|
|
4640
|
+
select jsonb_agg(provider order by provider)
|
|
4641
|
+
from (
|
|
4642
|
+
select distinct provider
|
|
4643
|
+
from auth.identities
|
|
4644
|
+
where user_id = $1
|
|
4645
|
+
) as providers
|
|
4646
|
+
),
|
|
4647
|
+
true
|
|
4648
|
+
),
|
|
4649
|
+
updated_at = now()
|
|
4650
|
+
where id = $1
|
|
4651
|
+
returning *`, [user.id])).rows[0];
|
|
4652
|
+
if (updated)
|
|
4653
|
+
Object.assign(user, updated);
|
|
2324
4654
|
}
|
|
2325
4655
|
async userJsonWithRelations(user, query = (sql, params) => this.db.query(sql, params)) {
|
|
2326
4656
|
return this.userJson(user, await this.getUserFactors(user.id, query), await this.getUserIdentities(user.id, query));
|
|
@@ -2406,6 +4736,14 @@ Or sign in with this link: ${link}`
|
|
|
2406
4736
|
phone: u.phone ?? "",
|
|
2407
4737
|
phone_confirmed_at: iso(u.phone_confirmed_at),
|
|
2408
4738
|
confirmed_at: iso(u.email_confirmed_at ?? u.phone_confirmed_at),
|
|
4739
|
+
...u.email_change ? {
|
|
4740
|
+
new_email: u.email_change,
|
|
4741
|
+
email_change_sent_at: iso(u.email_change_sent_at)
|
|
4742
|
+
} : {},
|
|
4743
|
+
...u.phone_change ? {
|
|
4744
|
+
new_phone: u.phone_change,
|
|
4745
|
+
phone_change_sent_at: iso(u.phone_change_sent_at)
|
|
4746
|
+
} : {},
|
|
2409
4747
|
last_sign_in_at: iso(u.last_sign_in_at),
|
|
2410
4748
|
app_metadata: u.raw_app_meta_data ?? {},
|
|
2411
4749
|
user_metadata: u.raw_user_meta_data ?? {},
|
|
@@ -3217,6 +5555,11 @@ create table if not exists auth.users (
|
|
|
3217
5555
|
email text unique,
|
|
3218
5556
|
encrypted_password text,
|
|
3219
5557
|
email_confirmed_at timestamptz,
|
|
5558
|
+
email_change_token_new text default '',
|
|
5559
|
+
email_change text,
|
|
5560
|
+
email_change_sent_at timestamptz,
|
|
5561
|
+
email_change_token_current text default '',
|
|
5562
|
+
email_change_confirm_status smallint default 0,
|
|
3220
5563
|
last_sign_in_at timestamptz,
|
|
3221
5564
|
raw_app_meta_data jsonb default '{}'::jsonb,
|
|
3222
5565
|
raw_user_meta_data jsonb default '{}'::jsonb,
|
|
@@ -3225,11 +5568,23 @@ create table if not exists auth.users (
|
|
|
3225
5568
|
updated_at timestamptz default now(),
|
|
3226
5569
|
phone text unique,
|
|
3227
5570
|
phone_confirmed_at timestamptz,
|
|
5571
|
+
phone_change text default '',
|
|
5572
|
+
phone_change_token text default '',
|
|
5573
|
+
phone_change_sent_at timestamptz,
|
|
3228
5574
|
banned_until timestamptz,
|
|
3229
5575
|
deleted_at timestamptz,
|
|
3230
5576
|
is_anonymous boolean default false
|
|
3231
5577
|
);
|
|
3232
5578
|
|
|
5579
|
+
alter table auth.users add column if not exists email_change text;
|
|
5580
|
+
alter table auth.users add column if not exists email_change_sent_at timestamptz;
|
|
5581
|
+
alter table auth.users add column if not exists email_change_token_new text default '';
|
|
5582
|
+
alter table auth.users add column if not exists email_change_token_current text default '';
|
|
5583
|
+
alter table auth.users add column if not exists email_change_confirm_status smallint default 0;
|
|
5584
|
+
alter table auth.users add column if not exists phone_change text default '';
|
|
5585
|
+
alter table auth.users add column if not exists phone_change_token text default '';
|
|
5586
|
+
alter table auth.users add column if not exists phone_change_sent_at timestamptz;
|
|
5587
|
+
|
|
3233
5588
|
create table if not exists auth.refresh_tokens (
|
|
3234
5589
|
id bigserial primary key,
|
|
3235
5590
|
token text unique not null,
|
|
@@ -3298,6 +5653,7 @@ create table if not exists auth.flow_state (
|
|
|
3298
5653
|
created_at timestamptz default now(),
|
|
3299
5654
|
expires_at timestamptz not null
|
|
3300
5655
|
);
|
|
5656
|
+
alter table auth.flow_state add column if not exists link_user_id uuid;
|
|
3301
5657
|
|
|
3302
5658
|
create table if not exists auth.mfa_factors (
|
|
3303
5659
|
id uuid primary key default gen_random_uuid(),
|
|
@@ -3318,6 +5674,25 @@ create table if not exists auth.mfa_challenges (
|
|
|
3318
5674
|
expires_at timestamptz not null
|
|
3319
5675
|
);
|
|
3320
5676
|
|
|
5677
|
+
create table if not exists auth.mfa_recovery_code_sets (
|
|
5678
|
+
id uuid primary key default gen_random_uuid(),
|
|
5679
|
+
user_id uuid not null,
|
|
5680
|
+
mfa_factor_id uuid not null unique,
|
|
5681
|
+
failed_verification_count int not null default 0,
|
|
5682
|
+
verification_locked_until timestamptz,
|
|
5683
|
+
created_at timestamptz default now(),
|
|
5684
|
+
updated_at timestamptz default now(),
|
|
5685
|
+
unique (user_id)
|
|
5686
|
+
);
|
|
5687
|
+
|
|
5688
|
+
create table if not exists auth.mfa_recovery_codes (
|
|
5689
|
+
id uuid primary key default gen_random_uuid(),
|
|
5690
|
+
mfa_recovery_code_set_id uuid not null references auth.mfa_recovery_code_sets(id) on delete cascade,
|
|
5691
|
+
code_hash text not null,
|
|
5692
|
+
consumed_at timestamptz,
|
|
5693
|
+
created_at timestamptz default now()
|
|
5694
|
+
);
|
|
5695
|
+
|
|
3321
5696
|
create table if not exists auth.audit_log_entries (
|
|
3322
5697
|
id uuid primary key default gen_random_uuid(),
|
|
3323
5698
|
payload jsonb,
|
|
@@ -3517,6 +5892,12 @@ create table if not exists auth.users (
|
|
|
3517
5892
|
is_anonymous boolean default false
|
|
3518
5893
|
);
|
|
3519
5894
|
|
|
5895
|
+
alter table auth.users add column if not exists email_change text;
|
|
5896
|
+
alter table auth.users add column if not exists email_change_sent_at timestamptz;
|
|
5897
|
+
alter table auth.users add column if not exists email_change_token_new text default '';
|
|
5898
|
+
alter table auth.users add column if not exists email_change_token_current text default '';
|
|
5899
|
+
alter table auth.users add column if not exists email_change_confirm_status smallint default 0;
|
|
5900
|
+
|
|
3520
5901
|
create table if not exists auth.refresh_tokens (
|
|
3521
5902
|
id bigint generated by default as identity primary key,
|
|
3522
5903
|
token text unique not null,
|
|
@@ -3597,6 +5978,7 @@ create table if not exists auth.flow_state (
|
|
|
3597
5978
|
created_at timestamptz default now(),
|
|
3598
5979
|
expires_at timestamptz not null
|
|
3599
5980
|
);
|
|
5981
|
+
alter table auth.flow_state add column if not exists link_user_id uuid;
|
|
3600
5982
|
|
|
3601
5983
|
create table if not exists auth.mfa_factors (
|
|
3602
5984
|
id uuid primary key default gen_random_uuid(),
|
|
@@ -3617,6 +5999,25 @@ create table if not exists auth.mfa_challenges (
|
|
|
3617
5999
|
expires_at timestamptz not null
|
|
3618
6000
|
);
|
|
3619
6001
|
|
|
6002
|
+
create table if not exists auth.mfa_recovery_code_sets (
|
|
6003
|
+
id uuid primary key default gen_random_uuid(),
|
|
6004
|
+
user_id uuid not null references auth.users(id) on delete cascade,
|
|
6005
|
+
mfa_factor_id uuid not null unique references auth.mfa_factors(id) on delete cascade,
|
|
6006
|
+
failed_verification_count int not null default 0,
|
|
6007
|
+
verification_locked_until timestamptz,
|
|
6008
|
+
created_at timestamptz default now(),
|
|
6009
|
+
updated_at timestamptz default now(),
|
|
6010
|
+
unique (user_id)
|
|
6011
|
+
);
|
|
6012
|
+
|
|
6013
|
+
create table if not exists auth.mfa_recovery_codes (
|
|
6014
|
+
id uuid primary key default gen_random_uuid(),
|
|
6015
|
+
mfa_recovery_code_set_id uuid not null references auth.mfa_recovery_code_sets(id) on delete cascade,
|
|
6016
|
+
code_hash text not null,
|
|
6017
|
+
consumed_at timestamptz,
|
|
6018
|
+
created_at timestamptz default now()
|
|
6019
|
+
);
|
|
6020
|
+
|
|
3620
6021
|
-- append-only security audit trail (GoTrue-compatible shape)
|
|
3621
6022
|
create table if not exists auth.audit_log_entries (
|
|
3622
6023
|
instance_id uuid default '00000000-0000-0000-0000-000000000000',
|
|
@@ -9116,7 +11517,7 @@ function imageTransformInputTooLarge(maxInputBytes) {
|
|
|
9116
11517
|
message: `The source image exceeds the transformation limit (${maxInputBytes} bytes)`
|
|
9117
11518
|
};
|
|
9118
11519
|
}
|
|
9119
|
-
function
|
|
11520
|
+
function invalid2(message) {
|
|
9120
11521
|
return { ok: false, status: 400, error: "InvalidTransformation", message };
|
|
9121
11522
|
}
|
|
9122
11523
|
function unsupported(message) {
|
|
@@ -9126,22 +11527,22 @@ function parsePositiveInteger(value, name) {
|
|
|
9126
11527
|
if (value === null)
|
|
9127
11528
|
return;
|
|
9128
11529
|
if (!/^[1-9]\d*$/.test(value))
|
|
9129
|
-
return
|
|
11530
|
+
return invalid2(`${name} must be a positive integer`);
|
|
9130
11531
|
const parsed = Number(value);
|
|
9131
11532
|
if (!Number.isSafeInteger(parsed))
|
|
9132
|
-
return
|
|
11533
|
+
return invalid2(`${name} is too large`);
|
|
9133
11534
|
if (parsed > MAX_TRANSFORM_DIMENSION)
|
|
9134
|
-
return
|
|
11535
|
+
return invalid2(`${name} must be between 1 and ${MAX_TRANSFORM_DIMENSION}`);
|
|
9135
11536
|
return parsed;
|
|
9136
11537
|
}
|
|
9137
11538
|
function parseQuality(value) {
|
|
9138
11539
|
if (value === null)
|
|
9139
11540
|
return;
|
|
9140
11541
|
if (!/^\d+$/.test(value))
|
|
9141
|
-
return
|
|
11542
|
+
return invalid2("quality must be an integer between 20 and 100");
|
|
9142
11543
|
const parsed = Number(value);
|
|
9143
11544
|
if (!Number.isSafeInteger(parsed) || parsed < 20 || parsed > 100) {
|
|
9144
|
-
return
|
|
11545
|
+
return invalid2("quality must be an integer between 20 and 100");
|
|
9145
11546
|
}
|
|
9146
11547
|
return parsed;
|
|
9147
11548
|
}
|
|
@@ -9156,7 +11557,7 @@ function parseImageTransform(query) {
|
|
|
9156
11557
|
if (resizeValue === "cover")
|
|
9157
11558
|
return unsupported("resize=cover is not supported by Bun.Image in this runtime");
|
|
9158
11559
|
if (resizeValue !== null && resizeValue !== "contain" && resizeValue !== "fill") {
|
|
9159
|
-
return
|
|
11560
|
+
return invalid2("resize must be one of contain or fill");
|
|
9160
11561
|
}
|
|
9161
11562
|
if (resizeValue === null && width !== undefined && height !== undefined) {
|
|
9162
11563
|
return unsupported("resize defaults to cover when width and height are both set; specify contain or fill");
|
|
@@ -9165,13 +11566,13 @@ function parseImageTransform(query) {
|
|
|
9165
11566
|
const formatValue = query.get("format");
|
|
9166
11567
|
const format = formatValue === null ? "origin" : formatValue;
|
|
9167
11568
|
if (format !== "origin" && format !== "jpeg" && format !== "png" && format !== "webp") {
|
|
9168
|
-
return
|
|
11569
|
+
return invalid2("format must be one of origin, jpeg, png, or webp");
|
|
9169
11570
|
}
|
|
9170
11571
|
const quality = parseQuality(query.get("quality"));
|
|
9171
11572
|
if (typeof quality !== "number" && quality !== undefined)
|
|
9172
11573
|
return quality;
|
|
9173
11574
|
if (quality !== undefined && format === "png") {
|
|
9174
|
-
return
|
|
11575
|
+
return invalid2("quality is supported only for jpeg and webp output");
|
|
9175
11576
|
}
|
|
9176
11577
|
return {
|
|
9177
11578
|
ok: true,
|
|
@@ -9273,7 +11674,7 @@ async function transformImage(source, options, knownSourceSize, configuredLimits
|
|
|
9273
11674
|
}
|
|
9274
11675
|
const outputFormat = options.format === "origin" ? metadata.format : options.format;
|
|
9275
11676
|
if (options.quality !== undefined && outputFormat !== "jpeg" && outputFormat !== "webp") {
|
|
9276
|
-
return
|
|
11677
|
+
return invalid2("quality is supported only for jpeg and webp output");
|
|
9277
11678
|
}
|
|
9278
11679
|
const contentType = IMAGE_FORMAT_CONTENT_TYPES[outputFormat];
|
|
9279
11680
|
if (!contentType) {
|
|
@@ -11191,7 +13592,7 @@ class GraphqlHandler {
|
|
|
11191
13592
|
throw new ApiError(415, { message: "GraphQL requests require application/json" });
|
|
11192
13593
|
}
|
|
11193
13594
|
const body = JSON.parse(await boundedBody(request, this.maxBytes));
|
|
11194
|
-
if (!
|
|
13595
|
+
if (!isObject2(body) || typeof body["query"] !== "string" || !body["query"].trim() || body["variables"] != null && !isObject2(body["variables"]) || body["extensions"] != null && !isObject2(body["extensions"]) || body["operationName"] != null && typeof body["operationName"] !== "string") {
|
|
11195
13596
|
throw new ApiError(400, { message: "Invalid GraphQL request" });
|
|
11196
13597
|
}
|
|
11197
13598
|
operationName = body["operationName"] ?? undefined;
|
|
@@ -11246,7 +13647,7 @@ async function boundedBody(request, limit) {
|
|
|
11246
13647
|
}
|
|
11247
13648
|
return Buffer.concat(chunks).toString("utf8");
|
|
11248
13649
|
}
|
|
11249
|
-
function
|
|
13650
|
+
function isObject2(value) {
|
|
11250
13651
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
11251
13652
|
}
|
|
11252
13653
|
function isGraphqlEnvelope(value) {
|
|
@@ -12436,14 +14837,14 @@ class PgWireClient {
|
|
|
12436
14837
|
if (code === 0) {} else if (code === 3) {
|
|
12437
14838
|
if (!needPassword())
|
|
12438
14839
|
return;
|
|
12439
|
-
this.socket.write(
|
|
14840
|
+
this.socket.write(message2(112, cstring(opts.password ?? "")));
|
|
12440
14841
|
} else if (code === 5) {
|
|
12441
14842
|
if (!needPassword())
|
|
12442
14843
|
return;
|
|
12443
14844
|
const salt = payload.subarray(4, 8);
|
|
12444
14845
|
const inner = md5Hex(Buffer.from((opts.password ?? "") + opts.user, "utf8"));
|
|
12445
14846
|
const token = "md5" + md5Hex(Buffer.concat([Buffer.from(inner, "utf8"), salt]));
|
|
12446
|
-
this.socket.write(
|
|
14847
|
+
this.socket.write(message2(112, cstring(token)));
|
|
12447
14848
|
} else if (code === 10) {
|
|
12448
14849
|
if (!needPassword())
|
|
12449
14850
|
return;
|
|
@@ -12455,7 +14856,7 @@ class PgWireClient {
|
|
|
12455
14856
|
clientNonce = randomBytes(18).toString("base64");
|
|
12456
14857
|
clientFirstBare = `n=,r=${clientNonce}`;
|
|
12457
14858
|
const initial = Buffer.from(`n,,${clientFirstBare}`, "utf8");
|
|
12458
|
-
this.socket.write(
|
|
14859
|
+
this.socket.write(message2(112, Buffer.concat([cstring("SCRAM-SHA-256"), int32(initial.length), initial])));
|
|
12459
14860
|
} else if (code === 11) {
|
|
12460
14861
|
const serverFirst = payload.subarray(4).toString("utf8");
|
|
12461
14862
|
const attrs = scramAttrs(serverFirst);
|
|
@@ -12470,15 +14871,15 @@ class PgWireClient {
|
|
|
12470
14871
|
const salt = Buffer.from(attrs.s, "base64");
|
|
12471
14872
|
const iterations = parseInt(attrs.i, 10);
|
|
12472
14873
|
const saltedPassword = pbkdf2Sync(opts.password, salt, iterations, 32, "sha256");
|
|
12473
|
-
const clientKey =
|
|
14874
|
+
const clientKey = hmac2(saltedPassword, "Client Key");
|
|
12474
14875
|
const storedKey = sha256(clientKey);
|
|
12475
14876
|
const finalNoProof = `c=biws,r=${attrs.r}`;
|
|
12476
14877
|
const authMessage = `${clientFirstBare},${serverFirst},${finalNoProof}`;
|
|
12477
|
-
const clientSignature =
|
|
14878
|
+
const clientSignature = hmac2(storedKey, authMessage);
|
|
12478
14879
|
const proof = xorBuffers(clientKey, clientSignature);
|
|
12479
|
-
serverSignature =
|
|
14880
|
+
serverSignature = hmac2(hmac2(saltedPassword, "Server Key"), authMessage).toString("base64");
|
|
12480
14881
|
const clientFinal = `${finalNoProof},p=${proof.toString("base64")}`;
|
|
12481
|
-
this.socket.write(
|
|
14882
|
+
this.socket.write(message2(112, Buffer.from(clientFinal, "utf8")));
|
|
12482
14883
|
} else if (code === 12) {
|
|
12483
14884
|
const v = scramAttrs(payload.subarray(4).toString("utf8")).v;
|
|
12484
14885
|
if (v && serverSignature && v !== serverSignature) {
|
|
@@ -12519,11 +14920,11 @@ class PgWireClient {
|
|
|
12519
14920
|
return op;
|
|
12520
14921
|
}
|
|
12521
14922
|
async exec(sql) {
|
|
12522
|
-
return this.run(() => this.socket.write(
|
|
14923
|
+
return this.run(() => this.socket.write(message2(81, cstring(sql))));
|
|
12523
14924
|
}
|
|
12524
14925
|
async query(sql, params = []) {
|
|
12525
14926
|
const results = await this.run(() => {
|
|
12526
|
-
const parse =
|
|
14927
|
+
const parse = message2(80, Buffer.concat([cstring(""), cstring(sql), int16(0)]));
|
|
12527
14928
|
const paramBufs = [int16(0), int16(params.length)];
|
|
12528
14929
|
for (const p of params) {
|
|
12529
14930
|
if (p === null || p === undefined) {
|
|
@@ -12534,10 +14935,10 @@ class PgWireClient {
|
|
|
12534
14935
|
}
|
|
12535
14936
|
}
|
|
12536
14937
|
paramBufs.push(int16(0));
|
|
12537
|
-
const bind =
|
|
12538
|
-
const describe =
|
|
12539
|
-
const execute =
|
|
12540
|
-
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));
|
|
12541
14942
|
this.socket.write(Buffer.concat([parse, bind, describe, execute, sync]));
|
|
12542
14943
|
});
|
|
12543
14944
|
return results[0] ?? { rows: [] };
|
|
@@ -12546,7 +14947,7 @@ class PgWireClient {
|
|
|
12546
14947
|
return new Promise((resolve) => {
|
|
12547
14948
|
if (this.closed)
|
|
12548
14949
|
return resolve();
|
|
12549
|
-
this.socket.write(
|
|
14950
|
+
this.socket.write(message2(88, Buffer.alloc(0)));
|
|
12550
14951
|
this.socket.end(() => resolve());
|
|
12551
14952
|
});
|
|
12552
14953
|
}
|
|
@@ -12650,7 +15051,7 @@ class PgWireClient {
|
|
|
12650
15051
|
}
|
|
12651
15052
|
}
|
|
12652
15053
|
}
|
|
12653
|
-
var
|
|
15054
|
+
var hmac2 = (key, data) => createHmac("sha256", key).update(data, "utf8").digest();
|
|
12654
15055
|
var sha256 = (b) => createHash("sha256").update(b).digest();
|
|
12655
15056
|
var md5Hex = (b) => createHash("md5").update(b).digest("hex");
|
|
12656
15057
|
function xorBuffers(a, b) {
|
|
@@ -12668,7 +15069,7 @@ function scramAttrs(s) {
|
|
|
12668
15069
|
}
|
|
12669
15070
|
return out;
|
|
12670
15071
|
}
|
|
12671
|
-
function
|
|
15072
|
+
function message2(type, body) {
|
|
12672
15073
|
const out = Buffer.alloc(5 + body.length);
|
|
12673
15074
|
out[0] = type;
|
|
12674
15075
|
out.writeInt32BE(4 + body.length, 1);
|
|
@@ -13717,6 +16118,7 @@ function readAuthSettings(root) {
|
|
|
13717
16118
|
const sms = tableAt(root, "auth.sms");
|
|
13718
16119
|
const mfa = tableAt(root, "auth.mfa");
|
|
13719
16120
|
const mfaTotp = tableAt(root, "auth.mfa.totp");
|
|
16121
|
+
const recoveryCodes = tableAt(root, "auth.mfa.recovery_codes");
|
|
13720
16122
|
const out = {};
|
|
13721
16123
|
const disableSignup = getBool(auth, "disable_signup");
|
|
13722
16124
|
if (disableSignup !== undefined)
|
|
@@ -13733,6 +16135,9 @@ function readAuthSettings(root) {
|
|
|
13733
16135
|
const minLen = getInt(auth, "minimum_password_length");
|
|
13734
16136
|
if (minLen !== undefined)
|
|
13735
16137
|
out.minPasswordLength = minLen;
|
|
16138
|
+
const requireReauthentication = getBool(auth, "security_update_password_require_reauthentication") ?? getBool(auth, "password_require_reauthentication");
|
|
16139
|
+
if (requireReauthentication !== undefined)
|
|
16140
|
+
out.requirePasswordReauthentication = requireReauthentication;
|
|
13736
16141
|
const otpLength = getInt(email, "otp_length");
|
|
13737
16142
|
if (otpLength !== undefined)
|
|
13738
16143
|
out.otpLength = otpLength;
|
|
@@ -13760,6 +16165,24 @@ function readAuthSettings(root) {
|
|
|
13760
16165
|
const totpVerify = getBool(mfaTotp, "verify_enabled");
|
|
13761
16166
|
if (totpVerify !== undefined)
|
|
13762
16167
|
out.totpVerifyEnabled = totpVerify;
|
|
16168
|
+
const recoveryEnroll = getBool(recoveryCodes, "enroll_enabled");
|
|
16169
|
+
if (recoveryEnroll !== undefined)
|
|
16170
|
+
out.recoveryCodesEnrollEnabled = recoveryEnroll;
|
|
16171
|
+
const recoveryVerify = getBool(recoveryCodes, "verify_enabled");
|
|
16172
|
+
if (recoveryVerify !== undefined)
|
|
16173
|
+
out.recoveryCodesVerifyEnabled = recoveryVerify;
|
|
16174
|
+
const recoveryCount = getInt(recoveryCodes, "count");
|
|
16175
|
+
if (recoveryCount !== undefined)
|
|
16176
|
+
out.recoveryCodesCount = recoveryCount;
|
|
16177
|
+
const recoveryLength = getInt(recoveryCodes, "code_length");
|
|
16178
|
+
if (recoveryLength !== undefined)
|
|
16179
|
+
out.recoveryCodeLength = recoveryLength;
|
|
16180
|
+
const recoveryAttempts = getInt(recoveryCodes, "max_verify_attempts");
|
|
16181
|
+
if (recoveryAttempts !== undefined)
|
|
16182
|
+
out.recoveryCodesMaxVerifyAttempts = recoveryAttempts;
|
|
16183
|
+
const recoveryLockout = getDurationSeconds(recoveryCodes, "lockout_duration");
|
|
16184
|
+
if (recoveryLockout !== undefined)
|
|
16185
|
+
out.recoveryCodesLockoutSeconds = recoveryLockout;
|
|
13763
16186
|
return out;
|
|
13764
16187
|
}
|
|
13765
16188
|
function readRateLimits(root) {
|
|
@@ -13804,7 +16227,11 @@ function readOAuthProviders(root, env) {
|
|
|
13804
16227
|
authorizeUrl: getString(t, "authorize_url") ?? (url ? `${url}/authorize` : undefined),
|
|
13805
16228
|
tokenUrl: getString(t, "token_url") ?? (url ? `${url}/token` : undefined),
|
|
13806
16229
|
userInfoUrl: getString(t, "userinfo_url") ?? (url ? `${url}/userinfo` : undefined),
|
|
13807
|
-
scopes: getString(t, "scopes")
|
|
16230
|
+
scopes: getString(t, "scopes"),
|
|
16231
|
+
idTokenIssuer: getString(t, "id_token_issuer") ?? getString(t, "issuer"),
|
|
16232
|
+
idTokenJwksUrl: getString(t, "id_token_jwks_url") ?? getString(t, "jwks_url"),
|
|
16233
|
+
idTokenAudience: getString(t, "id_token_audience") ?? clientId,
|
|
16234
|
+
ssoDomains: getStringArray(t, "sso_domains")
|
|
13808
16235
|
};
|
|
13809
16236
|
}
|
|
13810
16237
|
}
|
|
@@ -13836,7 +16263,10 @@ function collectEnvProviders(env, out, prefix, secretKey) {
|
|
|
13836
16263
|
authorizeUrl: g("AUTHORIZE_URL"),
|
|
13837
16264
|
tokenUrl: g("TOKEN_URL"),
|
|
13838
16265
|
userInfoUrl: g("USERINFO_URL"),
|
|
13839
|
-
scopes: g("SCOPES")
|
|
16266
|
+
scopes: g("SCOPES"),
|
|
16267
|
+
idTokenIssuer: g("ID_TOKEN_ISSUER") ?? g("ISSUER"),
|
|
16268
|
+
idTokenJwksUrl: g("ID_TOKEN_JWKS_URL") ?? g("JWKS_URL"),
|
|
16269
|
+
idTokenAudience: g("ID_TOKEN_AUDIENCE") ?? clientId
|
|
13840
16270
|
};
|
|
13841
16271
|
}
|
|
13842
16272
|
}
|