@madauth/server 0.3.0-beta.1 → 0.4.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/dist/bin.js +104 -42
- package/dist/chunk-3PACOYTG.js +1185 -0
- package/dist/{chunk-DCDTO5LG.js → chunk-A7UYZRBL.js} +1 -0
- package/dist/{chunk-CD7Y4T66.js → chunk-GA4TF7EI.js} +24 -2
- package/dist/{chunk-ODITZLMM.js → chunk-ISJGOCF3.js} +258 -84
- package/dist/{chunk-56MOVFO4.js → chunk-OMIX3ERC.js} +11 -2
- package/dist/{chunk-NPNM26J3.js → chunk-Y7ZSUROX.js} +5 -1
- package/dist/{config-DEBYi8hb.d.ts → config-DkE_2D3-.d.ts} +5 -2
- package/dist/dynamodb.js +1 -1
- package/dist/entry/azure.d.ts +3 -3
- package/dist/entry/azure.js +4 -4
- package/dist/entry/lambda.d.ts +3 -3
- package/dist/entry/lambda.js +4 -4
- package/dist/entry/node.d.ts +3 -3
- package/dist/entry/node.js +4 -4
- package/dist/index.d.ts +29 -14
- package/dist/index.js +10 -6
- package/dist/{schema-3yhCc2jH.d.ts → schema-DTeiSuQB.d.ts} +39 -2
- package/dist/sqlite.js +2 -2
- package/dist/standalone/azure.mjs +1331 -676
- package/dist/standalone/lambda.mjs +1319 -664
- package/dist/standalone/main.mjs +1191 -536
- package/dist/store/dynamodb.d.ts +1 -1
- package/dist/store/sqlite.d.ts +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +2 -0
- package/dist/{user-BMWEWREH.d.ts → user-BZs84sui.d.ts} +5 -0
- package/dist/verify.d.ts +1 -1
- package/dist/verify.js +1 -1
- package/dist/{webhook-DCDP6SaE.d.ts → webhook-B6P50ocX.d.ts} +1 -1
- package/dist/webhook.d.ts +1 -1
- package/dist/webhook.js +1 -1
- package/package.json +2 -2
- package/dist/chunk-ZSBKNV5W.js +0 -713
|
@@ -1457,9 +1457,9 @@ var require_azure_functions = __commonJS({
|
|
|
1457
1457
|
let normalizedProperties;
|
|
1458
1458
|
try {
|
|
1459
1459
|
normalizedProperties = (0, toolProperties_1.normalizeToolProperties)(mcpToolTriggerOptions.toolProperties);
|
|
1460
|
-
} catch (
|
|
1461
|
-
if (
|
|
1462
|
-
throw
|
|
1460
|
+
} catch (error2) {
|
|
1461
|
+
if (error2 instanceof Error && (error2.message.includes("Property type is required") || error2.message.includes("Property type must be specified"))) {
|
|
1462
|
+
throw error2;
|
|
1463
1463
|
}
|
|
1464
1464
|
normalizedProperties = void 0;
|
|
1465
1465
|
}
|
|
@@ -2158,8 +2158,8 @@ var require_azure_functions = __commonJS({
|
|
|
2158
2158
|
return handler(new LogHookContext_1.LogHookContext(coreContext));
|
|
2159
2159
|
});
|
|
2160
2160
|
} catch (err) {
|
|
2161
|
-
const
|
|
2162
|
-
if (
|
|
2161
|
+
const error2 = (0, errors_1.ensureErrorType)(err);
|
|
2162
|
+
if (error2.name === "RangeError" && error2.isAzureFunctionsSystemError) {
|
|
2163
2163
|
throw new errors_1.AzFuncSystemError(`Log hooks require Azure Functions Host v4.34 or higher.`);
|
|
2164
2164
|
} else {
|
|
2165
2165
|
throw err;
|
|
@@ -2261,19 +2261,19 @@ var require_azure_functions = __commonJS({
|
|
|
2261
2261
|
if (!nativeReq) {
|
|
2262
2262
|
const url = (0, nonNull_1.nonNullProp)(init, "url");
|
|
2263
2263
|
const method = (0, nonNull_1.nonNullProp)(init, "method");
|
|
2264
|
-
let
|
|
2264
|
+
let body2;
|
|
2265
2265
|
const lowerMethod = method.toLowerCase();
|
|
2266
2266
|
if (lowerMethod !== "get" && lowerMethod !== "head") {
|
|
2267
2267
|
if ((_a = init.body) === null || _a === void 0 ? void 0 : _a.bytes) {
|
|
2268
|
-
|
|
2268
|
+
body2 = Buffer.from((_b = init.body) === null || _b === void 0 ? void 0 : _b.bytes);
|
|
2269
2269
|
} else if ((_c = init.body) === null || _c === void 0 ? void 0 : _c.string) {
|
|
2270
|
-
|
|
2270
|
+
body2 = init.body.string;
|
|
2271
2271
|
}
|
|
2272
2272
|
} else if (((_e = (_d = init.body) === null || _d === void 0 ? void 0 : _d.bytes) === null || _e === void 0 ? void 0 : _e.length) || ((_f = init.body) === null || _f === void 0 ? void 0 : _f.string)) {
|
|
2273
2273
|
(0, workerSystemLog_1.workerSystemLog)("warning", `Discarding the body of the incoming ${method} request because GET and HEAD requests cannot have a body.`);
|
|
2274
2274
|
}
|
|
2275
2275
|
nativeReq = new Request(url, {
|
|
2276
|
-
body,
|
|
2276
|
+
body: body2,
|
|
2277
2277
|
method,
|
|
2278
2278
|
headers: (0, fromRpcNullable_1.fromNullableMapping)(init.nullableHeaders, init.headers)
|
|
2279
2279
|
});
|
|
@@ -2352,10 +2352,10 @@ var require_azure_functions = __commonJS({
|
|
|
2352
2352
|
throw new errors_1.AzFuncSystemError(`Expected headers "${hostHeaderName}" and "${protoHeaderName}" to be set.`);
|
|
2353
2353
|
}
|
|
2354
2354
|
const url = `${proto}://${host}${(0, nonNull_1.nonNullProp)(proxyReq, "url")}`;
|
|
2355
|
-
let
|
|
2355
|
+
let body2;
|
|
2356
2356
|
const lowerMethod = (_a = proxyReq.method) === null || _a === void 0 ? void 0 : _a.toLowerCase();
|
|
2357
2357
|
if (lowerMethod !== "get" && lowerMethod !== "head") {
|
|
2358
|
-
|
|
2358
|
+
body2 = proxyReq;
|
|
2359
2359
|
}
|
|
2360
2360
|
const { Query: rpcQueryIgnored, Headers: rpcHeaders } = triggerMetadata, rpcParams = __rest(triggerMetadata, ["Query", "Headers"]);
|
|
2361
2361
|
let headers;
|
|
@@ -2365,7 +2365,7 @@ var require_azure_functions = __commonJS({
|
|
|
2365
2365
|
}
|
|
2366
2366
|
const nativeReq = new Request(url, {
|
|
2367
2367
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
2368
|
-
body,
|
|
2368
|
+
body: body2,
|
|
2369
2369
|
duplex: "half",
|
|
2370
2370
|
method: (0, nonNull_1.nonNullProp)(proxyReq, "method"),
|
|
2371
2371
|
headers
|
|
@@ -4107,7 +4107,7 @@ var init_schema = __esm({
|
|
|
4107
4107
|
"src/store/schema.ts"() {
|
|
4108
4108
|
"use strict";
|
|
4109
4109
|
madauthSchema = {
|
|
4110
|
-
version:
|
|
4110
|
+
version: 5,
|
|
4111
4111
|
models: {
|
|
4112
4112
|
/** Someone who signed in, whichever way: the e-mail address is the identity shared by the sign-in methods. */
|
|
4113
4113
|
user: {
|
|
@@ -4126,7 +4126,10 @@ var init_schema = __esm({
|
|
|
4126
4126
|
createdAt: { type: "number" }
|
|
4127
4127
|
}
|
|
4128
4128
|
},
|
|
4129
|
-
/**
|
|
4129
|
+
/**
|
|
4130
|
+
* How a user signs in. `key` is `password:<userId>`, `google:<sub>` or `totp:<userId>` (the authenticator
|
|
4131
|
+
* app, whose `secret` is encrypted).
|
|
4132
|
+
*/
|
|
4130
4133
|
account: {
|
|
4131
4134
|
fields: {
|
|
4132
4135
|
id: { type: "string", primaryKey: true },
|
|
@@ -4137,6 +4140,25 @@ var init_schema = __esm({
|
|
|
4137
4140
|
lockedUntil: { type: "number" },
|
|
4138
4141
|
/** The address the provider reported at the last sign-in, e.g. Google's current primary address (since version 4). */
|
|
4139
4142
|
email: { type: "string", nullable: true },
|
|
4143
|
+
/** The time step of the last authenticator code that was accepted, so a code works once; 0 before (since version 5). */
|
|
4144
|
+
lastUsedStep: { type: "number" },
|
|
4145
|
+
/**
|
|
4146
|
+
* The setup an authenticator secret came from; its recovery codes carry the same id, so the app and
|
|
4147
|
+
* its codes always belong together. Empty for other accounts (since version 5).
|
|
4148
|
+
*/
|
|
4149
|
+
setupId: { type: "string", nullable: true },
|
|
4150
|
+
createdAt: { type: "number" }
|
|
4151
|
+
}
|
|
4152
|
+
},
|
|
4153
|
+
/**
|
|
4154
|
+
* A recovery code of a user's authenticator app (since version 5). `id` is the code's keyed hash; the
|
|
4155
|
+
* record goes when the code is used. It works only while the account's `setupId` is its own.
|
|
4156
|
+
*/
|
|
4157
|
+
recoveryCode: {
|
|
4158
|
+
fields: {
|
|
4159
|
+
id: { type: "string", primaryKey: true },
|
|
4160
|
+
userId: { type: "string", index: true },
|
|
4161
|
+
setupId: { type: "string" },
|
|
4140
4162
|
createdAt: { type: "number" }
|
|
4141
4163
|
}
|
|
4142
4164
|
},
|
|
@@ -4168,7 +4190,7 @@ var init_schema = __esm({
|
|
|
4168
4190
|
|
|
4169
4191
|
// src/store/sql.ts
|
|
4170
4192
|
function tableName(model) {
|
|
4171
|
-
return `madauth_${model}`;
|
|
4193
|
+
return `madauth_${columnName(model)}`;
|
|
4172
4194
|
}
|
|
4173
4195
|
function columnName(field) {
|
|
4174
4196
|
return field.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
|
@@ -4236,6 +4258,15 @@ var init_sql = __esm({
|
|
|
4236
4258
|
`DROP TABLE ${tableName("role")};`,
|
|
4237
4259
|
createTablesSql(dialect, { version: 4, models: { setting: madauthSchema.models.setting } })
|
|
4238
4260
|
].join("\n")
|
|
4261
|
+
},
|
|
4262
|
+
{
|
|
4263
|
+
// The authenticator app: which code was used last, and the recovery codes.
|
|
4264
|
+
version: 5,
|
|
4265
|
+
sql: (dialect) => [
|
|
4266
|
+
`ALTER TABLE ${tableName("account")} ADD COLUMN ${columnName("lastUsedStep")} ${columnTypes[dialect].number} NOT NULL DEFAULT 0;`,
|
|
4267
|
+
`ALTER TABLE ${tableName("account")} ADD COLUMN ${columnName("setupId")} ${columnTypes[dialect].string};`,
|
|
4268
|
+
createTablesSql(dialect, { version: 5, models: { recoveryCode: madauthSchema.models.recoveryCode } })
|
|
4269
|
+
].join("\n")
|
|
4239
4270
|
}
|
|
4240
4271
|
];
|
|
4241
4272
|
}
|
|
@@ -4388,16 +4419,16 @@ function stillMatches(where) {
|
|
|
4388
4419
|
});
|
|
4389
4420
|
return { text: parts.join(" AND "), names, values };
|
|
4390
4421
|
}
|
|
4391
|
-
function failedWrite(
|
|
4392
|
-
const { name, CancellationReasons } =
|
|
4422
|
+
function failedWrite(error2) {
|
|
4423
|
+
const { name, CancellationReasons } = error2;
|
|
4393
4424
|
if (name === "ConditionalCheckFailedException") return "refused";
|
|
4394
|
-
if (name && RETRYABLE_ERRORS.includes(name)) return { retry:
|
|
4425
|
+
if (name && RETRYABLE_ERRORS.includes(name)) return { retry: error2 };
|
|
4395
4426
|
if (name === "TransactionCanceledException") {
|
|
4396
4427
|
const reasons = (CancellationReasons ?? []).map((r) => r.Code ?? "None").filter((code) => code !== "None");
|
|
4397
4428
|
if (reasons.includes("ConditionalCheckFailed")) return "refused";
|
|
4398
|
-
if (reasons.length && reasons.every((code) => RETRYABLE_REASONS.includes(code))) return { retry:
|
|
4429
|
+
if (reasons.length && reasons.every((code) => RETRYABLE_REASONS.includes(code))) return { retry: error2 };
|
|
4399
4430
|
}
|
|
4400
|
-
throw
|
|
4431
|
+
throw error2;
|
|
4401
4432
|
}
|
|
4402
4433
|
function createDynamoDbAdapter(options) {
|
|
4403
4434
|
const { tableName: tableName2 } = options;
|
|
@@ -4918,10 +4949,10 @@ var HonoRequest = class {
|
|
|
4918
4949
|
const { bodyCache, raw: raw2 } = this;
|
|
4919
4950
|
const cachedBody = bodyCache[key];
|
|
4920
4951
|
if (cachedBody) return cachedBody;
|
|
4921
|
-
for (const anyCachedKey in bodyCache) return bodyCache[anyCachedKey].then((
|
|
4922
|
-
if (anyCachedKey === "json")
|
|
4952
|
+
for (const anyCachedKey in bodyCache) return bodyCache[anyCachedKey].then((body2) => {
|
|
4953
|
+
if (anyCachedKey === "json") body2 = JSON.stringify(body2);
|
|
4923
4954
|
const contentType = anyCachedKey === "formData" ? void 0 : raw2.headers.get("content-type");
|
|
4924
|
-
return new Response(
|
|
4955
|
+
return new Response(body2, { headers: contentType ? { "Content-Type": contentType } : void 0 })[key]();
|
|
4925
4956
|
});
|
|
4926
4957
|
return bodyCache[key] = raw2[key]();
|
|
4927
4958
|
};
|
|
@@ -5146,7 +5177,7 @@ var setDefaultContentType = (contentType, headers) => {
|
|
|
5146
5177
|
...headers
|
|
5147
5178
|
};
|
|
5148
5179
|
};
|
|
5149
|
-
var createResponseInstance = (
|
|
5180
|
+
var createResponseInstance = (body2, init) => new Response(body2, init);
|
|
5150
5181
|
var Context = class {
|
|
5151
5182
|
#rawRequest;
|
|
5152
5183
|
#req;
|
|
@@ -7522,8 +7553,8 @@ async function importWithAlgCache(cache2, jwk, entry) {
|
|
|
7522
7553
|
if (key.type !== "public")
|
|
7523
7554
|
throw new JWKSInvalid("JSON Web Key Set members must be public keys");
|
|
7524
7555
|
return cached[alg] = key, key;
|
|
7525
|
-
}).catch((
|
|
7526
|
-
throw cached[alg] === pending && delete cached[alg],
|
|
7556
|
+
}).catch((error2) => {
|
|
7557
|
+
throw cached[alg] === pending && delete cached[alg], error2;
|
|
7527
7558
|
});
|
|
7528
7559
|
cached[alg] = pending;
|
|
7529
7560
|
}
|
|
@@ -7549,14 +7580,14 @@ function createLocalJWKSet(jwks) {
|
|
|
7549
7580
|
if (!length)
|
|
7550
7581
|
throw new JWKSNoMatchingKey();
|
|
7551
7582
|
if (length !== 1) {
|
|
7552
|
-
const
|
|
7553
|
-
throw
|
|
7583
|
+
const error2 = new JWKSMultipleMatchingKeys();
|
|
7584
|
+
throw error2[Symbol.asyncIterator] = async function* () {
|
|
7554
7585
|
for (const jwk2 of candidates)
|
|
7555
7586
|
try {
|
|
7556
7587
|
yield await importWithAlgCache(cached, jwk2, entry);
|
|
7557
7588
|
} catch {
|
|
7558
7589
|
}
|
|
7559
|
-
},
|
|
7590
|
+
}, error2;
|
|
7560
7591
|
}
|
|
7561
7592
|
return importWithAlgCache(cached, jwk, entry);
|
|
7562
7593
|
}, "jwks", {
|
|
@@ -7743,182 +7774,500 @@ async function importSigningKeys(jwk) {
|
|
|
7743
7774
|
};
|
|
7744
7775
|
}
|
|
7745
7776
|
|
|
7746
|
-
// src/
|
|
7747
|
-
import {
|
|
7748
|
-
var
|
|
7749
|
-
|
|
7750
|
-
|
|
7751
|
-
|
|
7752
|
-
|
|
7753
|
-
|
|
7754
|
-
|
|
7755
|
-
|
|
7756
|
-
|
|
7757
|
-
|
|
7758
|
-
|
|
7759
|
-
|
|
7760
|
-
|
|
7761
|
-
|
|
7762
|
-
|
|
7763
|
-
|
|
7764
|
-
|
|
7765
|
-
|
|
7766
|
-
|
|
7767
|
-
|
|
7768
|
-
return ["scrypt", params.logN, params.r, params.p, salt.toString("base64url"), key.toString("base64url")].join("$");
|
|
7769
|
-
}
|
|
7770
|
-
async function verifyPassword(password, stored) {
|
|
7771
|
-
const [scheme, logN, r, p, salt, hash] = stored.split("$");
|
|
7772
|
-
const params = { logN: Number(logN), r: Number(r), p: Number(p) };
|
|
7773
|
-
if (scheme !== "scrypt" || !salt || !hash || !Object.values(params).every((n) => Number.isInteger(n) && n > 0)) {
|
|
7774
|
-
return { ok: false, needsRehash: false };
|
|
7775
|
-
}
|
|
7776
|
-
const expected = Buffer.from(hash, "base64url");
|
|
7777
|
-
const key = await derive2(password, Buffer.from(salt, "base64url"), params);
|
|
7778
|
-
const ok = key.length === expected.length && timingSafeEqual(key, expected);
|
|
7779
|
-
const needsRehash = params.logN !== HASH_PARAMS.logN || params.r !== HASH_PARAMS.r || params.p !== HASH_PARAMS.p;
|
|
7780
|
-
return { ok, needsRehash };
|
|
7777
|
+
// src/webhooks.ts
|
|
7778
|
+
import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "crypto";
|
|
7779
|
+
var WEBHOOK_TYPES = [
|
|
7780
|
+
"email.verify",
|
|
7781
|
+
"email.reset",
|
|
7782
|
+
"email.already_registered",
|
|
7783
|
+
"email.no_password",
|
|
7784
|
+
"signup.before",
|
|
7785
|
+
"user.created",
|
|
7786
|
+
"email.verified",
|
|
7787
|
+
"email.password_reset",
|
|
7788
|
+
"user.signed_in",
|
|
7789
|
+
"user.deleted",
|
|
7790
|
+
"user.claims_changed",
|
|
7791
|
+
"totp.enabled",
|
|
7792
|
+
"totp.disabled",
|
|
7793
|
+
"totp.recovery_code_used"
|
|
7794
|
+
];
|
|
7795
|
+
var WEBHOOK_TIMEOUT_MS = 1e4;
|
|
7796
|
+
var MAX_LOCALE_LENGTH = 35;
|
|
7797
|
+
function localeOf(value) {
|
|
7798
|
+
return typeof value === "string" && value.length <= MAX_LOCALE_LENGTH ? value : void 0;
|
|
7781
7799
|
}
|
|
7782
|
-
var
|
|
7783
|
-
|
|
7784
|
-
|
|
7785
|
-
|
|
7800
|
+
var REQUIRED_EMAIL_TYPES = ["email.verify", "email.reset"];
|
|
7801
|
+
var SECRET_PREFIX = "whsec_";
|
|
7802
|
+
var DEV_WEBHOOK_RECEIVER_URL = "https://github.com/inouiw/madAuth-webhooks/tree/main/dev-receiver";
|
|
7803
|
+
var WEBHOOK_TOLERANCE_SECONDS = 5 * 60;
|
|
7804
|
+
function generateWebhookSecret() {
|
|
7805
|
+
return `${SECRET_PREFIX}${randomBytes(32).toString("base64")}`;
|
|
7786
7806
|
}
|
|
7787
|
-
function
|
|
7788
|
-
if (
|
|
7789
|
-
const
|
|
7790
|
-
if (length <
|
|
7791
|
-
if (length > MAX_PASSWORD_LENGTH) return `The password must have at most ${MAX_PASSWORD_LENGTH} characters.`;
|
|
7807
|
+
function checkWebhookSecret(secret) {
|
|
7808
|
+
if (!secret.startsWith(SECRET_PREFIX)) return `must start with "${SECRET_PREFIX}"`;
|
|
7809
|
+
const key = Buffer.from(secret.slice(SECRET_PREFIX.length), "base64");
|
|
7810
|
+
if (key.length < 24 || key.length > 64) return "must contain 24 to 64 bytes in base64";
|
|
7792
7811
|
return null;
|
|
7793
7812
|
}
|
|
7794
|
-
function
|
|
7795
|
-
|
|
7813
|
+
function hmac2(secret, content) {
|
|
7814
|
+
const key = Buffer.from(secret.slice(SECRET_PREFIX.length), "base64");
|
|
7815
|
+
return createHmac("sha256", key).update(content).digest("base64");
|
|
7796
7816
|
}
|
|
7797
|
-
function
|
|
7798
|
-
return
|
|
7817
|
+
function signWebhook(secret, id, timestamp, body2) {
|
|
7818
|
+
return `v1,${hmac2(secret, `${id}.${timestamp}.${body2}`)}`;
|
|
7799
7819
|
}
|
|
7800
|
-
function
|
|
7801
|
-
|
|
7820
|
+
function createWebhookClient(settings, fetchImpl = fetch) {
|
|
7821
|
+
const wants = (type) => settings.events.has(type);
|
|
7822
|
+
return {
|
|
7823
|
+
wants,
|
|
7824
|
+
async call(type, data, timeoutMs) {
|
|
7825
|
+
if (!wants(type)) return { ok: true, body: void 0 };
|
|
7826
|
+
const id = `msg_${randomUUID()}`;
|
|
7827
|
+
const timestamp = Math.floor(Date.now() / 1e3);
|
|
7828
|
+
const body2 = JSON.stringify({ type, data });
|
|
7829
|
+
let response;
|
|
7830
|
+
try {
|
|
7831
|
+
response = await fetchImpl(settings.url, {
|
|
7832
|
+
method: "POST",
|
|
7833
|
+
headers: {
|
|
7834
|
+
"Content-Type": "application/json",
|
|
7835
|
+
"webhook-id": id,
|
|
7836
|
+
"webhook-timestamp": String(timestamp),
|
|
7837
|
+
"webhook-signature": signWebhook(settings.secret, id, timestamp, body2)
|
|
7838
|
+
},
|
|
7839
|
+
body: body2,
|
|
7840
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
7841
|
+
});
|
|
7842
|
+
} catch (e) {
|
|
7843
|
+
const reason = e.name === "TimeoutError" ? `no answer within ${timeoutMs} ms` : e.message;
|
|
7844
|
+
return { ok: false, reason };
|
|
7845
|
+
}
|
|
7846
|
+
const text = await response.text().catch(() => "");
|
|
7847
|
+
if (!response.ok) return { ok: false, reason: `HTTP ${response.status}` };
|
|
7848
|
+
let parsed;
|
|
7849
|
+
try {
|
|
7850
|
+
parsed = text ? JSON.parse(text) : void 0;
|
|
7851
|
+
} catch {
|
|
7852
|
+
parsed = void 0;
|
|
7853
|
+
}
|
|
7854
|
+
return { ok: true, body: parsed };
|
|
7855
|
+
}
|
|
7856
|
+
};
|
|
7802
7857
|
}
|
|
7803
|
-
|
|
7804
|
-
|
|
7858
|
+
|
|
7859
|
+
// src/config.ts
|
|
7860
|
+
function sendsVerificationEmails(config) {
|
|
7861
|
+
return !!config.webhook?.events.has("email.verify");
|
|
7805
7862
|
}
|
|
7806
|
-
function
|
|
7807
|
-
return
|
|
7863
|
+
function methodConfig(config) {
|
|
7864
|
+
return { google: config.google, password: config.password, emailVerification: sendsVerificationEmails(config) };
|
|
7808
7865
|
}
|
|
7809
|
-
function
|
|
7810
|
-
return
|
|
7866
|
+
async function resolveEnv(source = process.env) {
|
|
7867
|
+
return typeof source === "function" ? source() : source;
|
|
7811
7868
|
}
|
|
7812
|
-
|
|
7813
|
-
|
|
7869
|
+
var DEFAULT_SESSION_TTL = 8 * 60 * 60;
|
|
7870
|
+
var DEFAULT_RENEWAL_TTL = 30 * 24 * 60 * 60;
|
|
7871
|
+
var DEFAULT_PASSWORD_MIN_LENGTH = 8;
|
|
7872
|
+
var ConfigError = class extends Error {
|
|
7873
|
+
constructor() {
|
|
7874
|
+
super(...arguments);
|
|
7875
|
+
this.name = "ConfigError";
|
|
7876
|
+
}
|
|
7877
|
+
};
|
|
7878
|
+
function parseOrigin(name, value) {
|
|
7879
|
+
let url;
|
|
7880
|
+
try {
|
|
7881
|
+
url = new URL(value);
|
|
7882
|
+
} catch {
|
|
7883
|
+
throw new ConfigError(`${name}: "${value}" is not a URL.`);
|
|
7884
|
+
}
|
|
7885
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
7886
|
+
throw new ConfigError(`${name}: "${value}" must be an http(s) URL.`);
|
|
7887
|
+
}
|
|
7888
|
+
return url.origin;
|
|
7814
7889
|
}
|
|
7815
|
-
function
|
|
7816
|
-
|
|
7817
|
-
const y = Buffer.from(b);
|
|
7818
|
-
return x.length === y.length && timingSafeEqual(x, y);
|
|
7890
|
+
function readEnv(env, name) {
|
|
7891
|
+
return env[name]?.trim().replace(/^(['"])(.*)\1$/s, "$2").trim() || void 0;
|
|
7819
7892
|
}
|
|
7893
|
+
async function loadConfig(env, overrides = {}) {
|
|
7894
|
+
const read = (name) => readEnv(env, name);
|
|
7895
|
+
const require2 = (name) => {
|
|
7896
|
+
const value = read(name);
|
|
7897
|
+
if (!value) throw new ConfigError(`${name} is not set. See docs/server.md.`);
|
|
7898
|
+
return value;
|
|
7899
|
+
};
|
|
7900
|
+
const issuerValue = require2("MADAUTH_ISSUER");
|
|
7901
|
+
parseOrigin("MADAUTH_ISSUER", issuerValue);
|
|
7902
|
+
const issuer = issuerValue.replace(/\/+$/, "");
|
|
7903
|
+
const keyValue = read("MADAUTH_SIGNING_KEY");
|
|
7904
|
+
if (!keyValue) {
|
|
7905
|
+
const key = await generateSigningKey();
|
|
7906
|
+
throw new ConfigError(
|
|
7907
|
+
`MADAUTH_SIGNING_KEY is not set. Here is a newly generated key you can use (keep it secret and use the same key on every instance):
|
|
7820
7908
|
|
|
7821
|
-
|
|
7822
|
-
|
|
7823
|
-
|
|
7824
|
-
|
|
7825
|
-
|
|
7826
|
-
return googleJwks;
|
|
7827
|
-
}
|
|
7828
|
-
async function verifyGoogleIdToken(idToken, opts) {
|
|
7829
|
-
let payload;
|
|
7909
|
+
MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
|
|
7910
|
+
`
|
|
7911
|
+
);
|
|
7912
|
+
}
|
|
7913
|
+
let signingKey;
|
|
7830
7914
|
try {
|
|
7831
|
-
|
|
7832
|
-
|
|
7833
|
-
audience: opts.clientId,
|
|
7834
|
-
algorithms: ["RS256", "ES256"]
|
|
7835
|
-
}));
|
|
7915
|
+
signingKey = JSON.parse(keyValue);
|
|
7916
|
+
assertPrivateSigningJwk(signingKey);
|
|
7836
7917
|
} catch (e) {
|
|
7837
|
-
|
|
7918
|
+
const reason = e instanceof SyntaxError ? "is not valid JSON" : e.message;
|
|
7919
|
+
throw new ConfigError(`MADAUTH_SIGNING_KEY ${reason}. Generate one with: npx @madauth/server generate-key`);
|
|
7838
7920
|
}
|
|
7839
|
-
|
|
7840
|
-
|
|
7921
|
+
const allowedOrigins = require2("ALLOWED_ORIGINS").split(",").map((o) => o.trim()).filter(Boolean).map((o) => parseOrigin("ALLOWED_ORIGINS", o));
|
|
7922
|
+
const clientId = read("GOOGLE_CLIENT_ID");
|
|
7923
|
+
if (clientId && !clientId.endsWith(".apps.googleusercontent.com")) {
|
|
7924
|
+
throw new ConfigError(`GOOGLE_CLIENT_ID must end with ".apps.googleusercontent.com" but is "${clientId}".`);
|
|
7841
7925
|
}
|
|
7842
|
-
|
|
7843
|
-
|
|
7926
|
+
const webhook = webhookFromEnv(read("WEBHOOK_URL"), read("WEBHOOK_SECRET"), read("WEBHOOK_EVENTS"));
|
|
7927
|
+
const sendsEmails = !!webhook && REQUIRED_EMAIL_TYPES.every((type) => webhook.events.has(type));
|
|
7928
|
+
const password = sendsEmails ? { minLength: passwordMinLength(read("PASSWORD_MIN_LENGTH")) } : void 0;
|
|
7929
|
+
const emailVerification = !!webhook && webhook.events.has("email.verify");
|
|
7930
|
+
if (!clientId && !password && !emailVerification) {
|
|
7931
|
+
throw new ConfigError(
|
|
7932
|
+
`No sign-in method is configured. Set GOOGLE_CLIENT_ID for Google sign-in, and/or a webhook that sends the e-mails: e-mail & password sign-in needs email.verify and email.reset, sign-up with the authenticator app needs email.verify. For development, run the receiver at ${DEV_WEBHOOK_RECEIVER_URL} and set WEBHOOK_URL=http://localhost:8790/webhook and WEBHOOK_EVENTS=${REQUIRED_EMAIL_TYPES.join(",")},email.already_registered,email.no_password. See docs/server.md.`
|
|
7933
|
+
);
|
|
7844
7934
|
}
|
|
7845
|
-
|
|
7846
|
-
|
|
7935
|
+
const store = overrides.store ?? await storeFromDatabaseUrl(read("DATABASE_URL"));
|
|
7936
|
+
if (!store) {
|
|
7937
|
+
throw new ConfigError(
|
|
7938
|
+
"DATABASE_URL is not set. madAuth stores every user, whichever way they sign in: set e.g. DATABASE_URL=sqlite:./madauth.db or DATABASE_URL=dynamodb:<table>. See docs/server.md."
|
|
7939
|
+
);
|
|
7847
7940
|
}
|
|
7848
|
-
const
|
|
7849
|
-
|
|
7850
|
-
if (
|
|
7851
|
-
|
|
7852
|
-
}
|
|
7853
|
-
|
|
7854
|
-
// src/user.ts
|
|
7855
|
-
var SESSION_COOKIE = "madauth_session";
|
|
7856
|
-
var SESSION_TYP = "madauth-session+jwt";
|
|
7857
|
-
var RENEWAL_COOKIE = "madauth_renewal";
|
|
7858
|
-
var RENEWAL_TYP = "madauth-renewal+jwt";
|
|
7859
|
-
var EXPIRY_COOKIE = "madauth_session_expires";
|
|
7860
|
-
|
|
7861
|
-
// src/tokens.ts
|
|
7862
|
-
function randomString(bytes = 32) {
|
|
7863
|
-
return base64url_exports.encode(crypto.getRandomValues(new Uint8Array(bytes)));
|
|
7864
|
-
}
|
|
7865
|
-
async function signToken(keys, issuer, typ, claims, ttlSeconds) {
|
|
7866
|
-
return new SignJWT(claims).setProtectedHeader({ alg: SIGNING_ALG, kid: keys.kid, typ }).setIssuer(issuer).setIssuedAt().setExpirationTime(`${ttlSeconds}s`).sign(keys.privateKey);
|
|
7867
|
-
}
|
|
7868
|
-
async function readToken(keys, issuer, typ, token) {
|
|
7869
|
-
if (!token) return null;
|
|
7870
|
-
try {
|
|
7871
|
-
const { payload } = await jwtVerify(token, keys.publicKey, { issuer, typ, algorithms: [SIGNING_ALG] });
|
|
7872
|
-
return payload;
|
|
7873
|
-
} catch {
|
|
7874
|
-
return null;
|
|
7941
|
+
const ttlValue = read("SESSION_TTL");
|
|
7942
|
+
const sessionTtlSeconds = ttlValue ? Number(ttlValue) : DEFAULT_SESSION_TTL;
|
|
7943
|
+
if (!Number.isInteger(sessionTtlSeconds) || sessionTtlSeconds < 60) {
|
|
7944
|
+
throw new ConfigError(`SESSION_TTL must be a whole number of seconds (at least 60) but is "${ttlValue}".`);
|
|
7875
7945
|
}
|
|
7876
|
-
|
|
7877
|
-
|
|
7878
|
-
|
|
7879
|
-
|
|
7880
|
-
|
|
7881
|
-
|
|
7882
|
-
|
|
7883
|
-
|
|
7884
|
-
|
|
7885
|
-
|
|
7886
|
-
|
|
7887
|
-
|
|
7888
|
-
|
|
7889
|
-
|
|
7890
|
-
|
|
7891
|
-
|
|
7892
|
-
|
|
7893
|
-
|
|
7894
|
-
|
|
7895
|
-
email_verified: user.email ? true : void 0,
|
|
7896
|
-
name: user.name,
|
|
7897
|
-
picture: user.picture,
|
|
7898
|
-
amr
|
|
7946
|
+
const renewalValue = read("SESSION_RENEWAL_TTL");
|
|
7947
|
+
const renewalTtlSeconds = renewalValue ? Number(renewalValue) : Math.max(DEFAULT_RENEWAL_TTL, sessionTtlSeconds);
|
|
7948
|
+
if (!Number.isInteger(renewalTtlSeconds) || renewalTtlSeconds < sessionTtlSeconds) {
|
|
7949
|
+
throw new ConfigError(
|
|
7950
|
+
`SESSION_RENEWAL_TTL must be a whole number of seconds, not less than SESSION_TTL (${sessionTtlSeconds}), but is "${renewalValue}".`
|
|
7951
|
+
);
|
|
7952
|
+
}
|
|
7953
|
+
return {
|
|
7954
|
+
issuer,
|
|
7955
|
+
signingKey,
|
|
7956
|
+
allowedOrigins,
|
|
7957
|
+
sessionTtlSeconds,
|
|
7958
|
+
renewalTtlSeconds,
|
|
7959
|
+
cookieDomain: read("COOKIE_DOMAIN"),
|
|
7960
|
+
totpIssuer: read("TOTP_ISSUER"),
|
|
7961
|
+
google: clientId ? { clientId, clientSecret: read("GOOGLE_CLIENT_SECRET") } : void 0,
|
|
7962
|
+
store,
|
|
7963
|
+
password,
|
|
7964
|
+
webhook
|
|
7899
7965
|
};
|
|
7900
|
-
return signToken(keys, issuer, RENEWAL_TYP, claims, ttlSeconds);
|
|
7901
7966
|
}
|
|
7902
|
-
function
|
|
7903
|
-
const
|
|
7904
|
-
if (
|
|
7905
|
-
|
|
7906
|
-
|
|
7907
|
-
|
|
7908
|
-
|
|
7967
|
+
function passwordMinLength(value) {
|
|
7968
|
+
const minLength = value ? Number(value) : DEFAULT_PASSWORD_MIN_LENGTH;
|
|
7969
|
+
if (!Number.isInteger(minLength) || minLength < 1 || minLength > 128) {
|
|
7970
|
+
throw new ConfigError(`PASSWORD_MIN_LENGTH must be a whole number from 1 to 128 but is "${value}".`);
|
|
7971
|
+
}
|
|
7972
|
+
return minLength;
|
|
7973
|
+
}
|
|
7974
|
+
async function storeFromDatabaseUrl(url) {
|
|
7975
|
+
if (!url) return void 0;
|
|
7976
|
+
if (url.startsWith("dynamodb:")) return dynamoDbStore(url.slice("dynamodb:".length));
|
|
7977
|
+
if (!url.startsWith("sqlite:")) {
|
|
7978
|
+
throw new ConfigError(
|
|
7979
|
+
`DATABASE_URL must start with "sqlite:" (e.g. sqlite:/data/madauth.db) or "dynamodb:" (e.g. dynamodb:madauth) but is "${url}". For other databases, pass your own store adapter (see "Custom store adapter" in docs/server.md).`
|
|
7980
|
+
);
|
|
7981
|
+
}
|
|
7982
|
+
const path = url.slice("sqlite:".length);
|
|
7983
|
+
if (!path) throw new ConfigError("DATABASE_URL needs a file path, e.g. sqlite:/data/madauth.db");
|
|
7984
|
+
const { createSqliteAdapter: createSqliteAdapter2 } = await Promise.resolve().then(() => (init_sqlite(), sqlite_exports));
|
|
7985
|
+
try {
|
|
7986
|
+
return createSqliteAdapter2(path);
|
|
7987
|
+
} catch (e) {
|
|
7988
|
+
throw new ConfigError(`DATABASE_URL: could not open "${path}": ${e.message}`);
|
|
7989
|
+
}
|
|
7990
|
+
}
|
|
7991
|
+
async function dynamoDbStore(tableName2) {
|
|
7992
|
+
if (!tableName2) throw new ConfigError("DATABASE_URL needs a table name, e.g. dynamodb:madauth");
|
|
7993
|
+
if (!/^[\w.-]{3,255}$/.test(tableName2)) {
|
|
7994
|
+
throw new ConfigError(
|
|
7995
|
+
`DATABASE_URL=dynamodb: needs a DynamoDB table name (3 to 255 letters, digits, _ . -) but has "${tableName2}"`
|
|
7996
|
+
);
|
|
7997
|
+
}
|
|
7998
|
+
try {
|
|
7999
|
+
await import("@aws-sdk/client-dynamodb");
|
|
8000
|
+
} catch (e) {
|
|
8001
|
+
throw new ConfigError(
|
|
8002
|
+
`DATABASE_URL=dynamodb: needs the AWS SDK. Install it next to madAuth: npm install @aws-sdk/client-dynamodb (${e.message})`
|
|
8003
|
+
);
|
|
8004
|
+
}
|
|
8005
|
+
const { createDynamoDbAdapter: createDynamoDbAdapter2 } = await Promise.resolve().then(() => (init_dynamodb(), dynamodb_exports));
|
|
8006
|
+
return createDynamoDbAdapter2({ tableName: tableName2 });
|
|
8007
|
+
}
|
|
8008
|
+
var RENAMED_TYPES = { "password.reset": "email.password_reset", "roles.changed": "user.claims_changed" };
|
|
8009
|
+
var LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]", "host.docker.internal"];
|
|
8010
|
+
function webhookFromEnv(url, secret, events) {
|
|
8011
|
+
if (!url) {
|
|
8012
|
+
if (secret || events) throw new ConfigError("WEBHOOK_SECRET and WEBHOOK_EVENTS need WEBHOOK_URL.");
|
|
8013
|
+
return void 0;
|
|
8014
|
+
}
|
|
8015
|
+
let parsed;
|
|
8016
|
+
try {
|
|
8017
|
+
parsed = new URL(url);
|
|
8018
|
+
} catch {
|
|
8019
|
+
throw new ConfigError(`WEBHOOK_URL: "${url}" is not a URL.`);
|
|
8020
|
+
}
|
|
8021
|
+
if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && LOCAL_HOSTS.includes(parsed.hostname))) {
|
|
8022
|
+
throw new ConfigError(`WEBHOOK_URL must be an https URL (http only for localhost) but is "${url}".`);
|
|
8023
|
+
}
|
|
8024
|
+
if (!secret) {
|
|
8025
|
+
throw new ConfigError(
|
|
8026
|
+
`WEBHOOK_SECRET is not set. Here is a newly generated secret you can use (keep it secret; your webhook receiver needs the same one):
|
|
8027
|
+
|
|
8028
|
+
WEBHOOK_SECRET=${generateWebhookSecret()}
|
|
8029
|
+
`
|
|
8030
|
+
);
|
|
8031
|
+
}
|
|
8032
|
+
const problem = checkWebhookSecret(secret);
|
|
8033
|
+
if (problem) {
|
|
8034
|
+
throw new ConfigError(`WEBHOOK_SECRET ${problem}. Generate one with: npx @madauth/server generate-webhook-secret`);
|
|
8035
|
+
}
|
|
8036
|
+
const selected = new Set((events ?? "").split(",").map((e) => e.trim()).filter(Boolean));
|
|
8037
|
+
const allTypes = `All types: ${WEBHOOK_TYPES.join(", ")}. See "Webhooks" in docs/server.md.`;
|
|
8038
|
+
if (!selected.size) {
|
|
8039
|
+
throw new ConfigError(
|
|
8040
|
+
`WEBHOOK_EVENTS is not set. List the types your webhook receiver handles; only these are sent. E-mail & password sign-in is on when they include ${REQUIRED_EMAIL_TYPES.join(" and ")}. For a receiver that sends the e-mails:
|
|
8041
|
+
|
|
8042
|
+
WEBHOOK_EVENTS=email.verify,email.reset,email.already_registered,email.no_password
|
|
8043
|
+
|
|
8044
|
+
For one that only wants to know who signed in:
|
|
8045
|
+
|
|
8046
|
+
WEBHOOK_EVENTS=user.signed_in
|
|
8047
|
+
|
|
8048
|
+
` + allTypes
|
|
8049
|
+
);
|
|
8050
|
+
}
|
|
8051
|
+
const unknown = [...selected].filter((e) => !WEBHOOK_TYPES.includes(e));
|
|
8052
|
+
if (unknown.length) {
|
|
8053
|
+
const renamed = unknown.filter((e) => e in RENAMED_TYPES).map((e) => `${e} is now ${RENAMED_TYPES[e]}`);
|
|
8054
|
+
throw new ConfigError(
|
|
8055
|
+
`WEBHOOK_EVENTS: unknown type ${unknown.join(", ")}. ${renamed.length ? `${renamed.join("; ")}. ` : ""}Known types: ${WEBHOOK_TYPES.join(", ")}.`
|
|
8056
|
+
);
|
|
8057
|
+
}
|
|
8058
|
+
return { url: parsed.href, secret, events: selected };
|
|
8059
|
+
}
|
|
8060
|
+
|
|
8061
|
+
// src/password.ts
|
|
8062
|
+
import { createHash, createHmac as createHmac2, hkdfSync, randomBytes as randomBytes2, randomInt, scrypt, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
8063
|
+
var HASH_PARAMS = { logN: 15, r: 8, p: 3 };
|
|
8064
|
+
var KEY_LENGTH = 64;
|
|
8065
|
+
var SALT_LENGTH = 16;
|
|
8066
|
+
var MAX_PASSWORD_LENGTH = 256;
|
|
8067
|
+
var MAX_EMAIL_LENGTH = 254;
|
|
8068
|
+
function derive2(password, salt, { logN, r, p }) {
|
|
8069
|
+
const N = 2 ** logN;
|
|
8070
|
+
return new Promise(
|
|
8071
|
+
(resolve, reject) => scrypt(
|
|
8072
|
+
password.normalize("NFC"),
|
|
8073
|
+
salt,
|
|
8074
|
+
KEY_LENGTH,
|
|
8075
|
+
{ N, r, p, maxmem: 256 * N * r + 1024 * 1024 },
|
|
8076
|
+
(err, key) => err ? reject(err) : resolve(key)
|
|
8077
|
+
)
|
|
8078
|
+
);
|
|
8079
|
+
}
|
|
8080
|
+
async function hashPassword(password, params = HASH_PARAMS) {
|
|
8081
|
+
const salt = randomBytes2(SALT_LENGTH);
|
|
8082
|
+
const key = await derive2(password, salt, params);
|
|
8083
|
+
return ["scrypt", params.logN, params.r, params.p, salt.toString("base64url"), key.toString("base64url")].join("$");
|
|
8084
|
+
}
|
|
8085
|
+
async function verifyPassword(password, stored) {
|
|
8086
|
+
const [scheme, logN, r, p, salt, hash] = stored.split("$");
|
|
8087
|
+
const params = { logN: Number(logN), r: Number(r), p: Number(p) };
|
|
8088
|
+
if (scheme !== "scrypt" || !salt || !hash || !Object.values(params).every((n) => Number.isInteger(n) && n > 0)) {
|
|
8089
|
+
return { ok: false, needsRehash: false };
|
|
8090
|
+
}
|
|
8091
|
+
const expected = Buffer.from(hash, "base64url");
|
|
8092
|
+
const key = await derive2(password, Buffer.from(salt, "base64url"), params);
|
|
8093
|
+
const ok = key.length === expected.length && timingSafeEqual2(key, expected);
|
|
8094
|
+
const needsRehash = params.logN !== HASH_PARAMS.logN || params.r !== HASH_PARAMS.r || params.p !== HASH_PARAMS.p;
|
|
8095
|
+
return { ok, needsRehash };
|
|
8096
|
+
}
|
|
8097
|
+
var dummyHash;
|
|
8098
|
+
async function verifyAgainstDummy(password) {
|
|
8099
|
+
dummyHash ??= hashPassword(randomBytes2(16).toString("hex"));
|
|
8100
|
+
await verifyPassword(password, await dummyHash);
|
|
8101
|
+
}
|
|
8102
|
+
function checkPasswordPolicy(password, minLength) {
|
|
8103
|
+
if (typeof password !== "string") return "password is missing";
|
|
8104
|
+
const length = [...password].length;
|
|
8105
|
+
if (length < minLength) return `The password must have at least ${minLength} characters.`;
|
|
8106
|
+
if (length > MAX_PASSWORD_LENGTH) return `The password must have at most ${MAX_PASSWORD_LENGTH} characters.`;
|
|
8107
|
+
return null;
|
|
8108
|
+
}
|
|
8109
|
+
function normalizeEmail(email) {
|
|
8110
|
+
return email.trim().toLowerCase();
|
|
8111
|
+
}
|
|
8112
|
+
function isValidEmail(email) {
|
|
8113
|
+
return typeof email === "string" && email.trim().length <= MAX_EMAIL_LENGTH && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim());
|
|
8114
|
+
}
|
|
8115
|
+
function generateLinkToken() {
|
|
8116
|
+
return randomBytes2(32).toString("base64url");
|
|
8117
|
+
}
|
|
8118
|
+
function hashLinkToken(token) {
|
|
8119
|
+
return createHash("sha256").update(token).digest("base64url");
|
|
8120
|
+
}
|
|
8121
|
+
function generateCode() {
|
|
8122
|
+
return randomInt(0, 1e6).toString().padStart(6, "0");
|
|
8123
|
+
}
|
|
8124
|
+
function deriveCodeKey(signingKeyD) {
|
|
8125
|
+
return Buffer.from(hkdfSync("sha256", Buffer.from(signingKeyD, "base64url"), "", "madauth e-mail code", 32));
|
|
8126
|
+
}
|
|
8127
|
+
function hashCode(key, code) {
|
|
8128
|
+
return createHmac2("sha256", key).update(code).digest("base64url");
|
|
8129
|
+
}
|
|
8130
|
+
function safeEqual(a, b) {
|
|
8131
|
+
const x = Buffer.from(a);
|
|
8132
|
+
const y = Buffer.from(b);
|
|
8133
|
+
return x.length === y.length && timingSafeEqual2(x, y);
|
|
8134
|
+
}
|
|
8135
|
+
|
|
8136
|
+
// src/users.ts
|
|
8137
|
+
import { randomBytes as randomBytes3 } from "crypto";
|
|
8138
|
+
|
|
8139
|
+
// src/settings.ts
|
|
8140
|
+
var SIGN_IN_METHODS = ["google", "password", "totp"];
|
|
8141
|
+
var METHODS_WITH_SECOND_FACTOR = ["google", "password"];
|
|
8142
|
+
var SECOND_FACTOR_POLICIES = ["none", "optional", "required"];
|
|
8143
|
+
var METHODS_SETTING = "methods";
|
|
8144
|
+
var isSignInMethod = (value) => SIGN_IN_METHODS.includes(value);
|
|
8145
|
+
var isPolicy = (value) => SECOND_FACTOR_POLICIES.includes(value);
|
|
8146
|
+
function parseMethodSettings(input, options = {}) {
|
|
8147
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) return null;
|
|
8148
|
+
const settings = {};
|
|
8149
|
+
for (const [method, value] of Object.entries(input)) {
|
|
8150
|
+
if (!isSignInMethod(method)) return null;
|
|
8151
|
+
if (typeof value === "boolean") {
|
|
8152
|
+
if (!options.booleans) return null;
|
|
8153
|
+
if (value) settings[method] = {};
|
|
8154
|
+
continue;
|
|
8155
|
+
}
|
|
8156
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
8157
|
+
const setting = {};
|
|
8158
|
+
for (const [field, fieldValue] of Object.entries(value)) {
|
|
8159
|
+
if (field !== "secondFactor" || method === "totp" || !isPolicy(fieldValue)) return null;
|
|
8160
|
+
setting.secondFactor = fieldValue;
|
|
8161
|
+
}
|
|
8162
|
+
settings[method] = setting;
|
|
8163
|
+
}
|
|
8164
|
+
return settings;
|
|
8165
|
+
}
|
|
8166
|
+
function isConfigured(config, method) {
|
|
8167
|
+
if (method === "google") return !!config.google;
|
|
8168
|
+
if (method === "password") return !!config.password;
|
|
8169
|
+
return true;
|
|
8170
|
+
}
|
|
8171
|
+
function canSignUp(config, method) {
|
|
8172
|
+
return method === "totp" ? !!config.emailVerification : isConfigured(config, method);
|
|
8173
|
+
}
|
|
8174
|
+
function initialMethods(config) {
|
|
8175
|
+
const methods = {};
|
|
8176
|
+
if (config.google) methods.google = { secondFactor: "none" };
|
|
8177
|
+
if (config.password) methods.password = { secondFactor: "none" };
|
|
8178
|
+
if (!config.google && !config.password && config.emailVerification) methods.totp = {};
|
|
8179
|
+
return methods;
|
|
8180
|
+
}
|
|
8181
|
+
var MAX_TRIES = 3;
|
|
8182
|
+
var Settings = class {
|
|
8183
|
+
/** `initial` is what counts until the methods are set, see {@link initialMethods}. */
|
|
8184
|
+
constructor(store, initial = {}) {
|
|
8185
|
+
this.store = store;
|
|
8186
|
+
this.initial = initial;
|
|
8187
|
+
this.warned = false;
|
|
8188
|
+
}
|
|
8189
|
+
/** The setting's value, or undefined if it was never set. */
|
|
8190
|
+
async get(id) {
|
|
8191
|
+
return (await this.read(id)).value;
|
|
8192
|
+
}
|
|
8193
|
+
/** The stored JSON and what it holds; both undefined if the setting was never set. */
|
|
8194
|
+
async read(id) {
|
|
8195
|
+
const record = await this.store.findOne("setting", { id });
|
|
8196
|
+
if (typeof record?.value !== "string") return {};
|
|
8197
|
+
try {
|
|
8198
|
+
return { json: record.value, value: JSON.parse(record.value) };
|
|
8199
|
+
} catch {
|
|
8200
|
+
return { json: record.value };
|
|
8201
|
+
}
|
|
8202
|
+
}
|
|
8203
|
+
/**
|
|
8204
|
+
* Changes the setting: `apply` gets the current value and returns the new one, or null to refuse the
|
|
8205
|
+
* change. The write only lands if nobody changed the setting since `apply` read it; otherwise `apply` runs
|
|
8206
|
+
* again with the newer value. Resolves to the new value, or null if `apply` refused.
|
|
8207
|
+
* `updatedBy` is the address of the admin, or null for the command line.
|
|
8208
|
+
*/
|
|
8209
|
+
async change(id, apply, updatedBy) {
|
|
8210
|
+
for (let attempt = 0; attempt < MAX_TRIES; attempt++) {
|
|
8211
|
+
const { json, value } = await this.read(id);
|
|
8212
|
+
const next = apply(value);
|
|
8213
|
+
if (next === null) return null;
|
|
8214
|
+
const patch = { value: JSON.stringify(next), updatedAt: Date.now(), updatedBy };
|
|
8215
|
+
const written = json === void 0 ? await this.store.create("setting", { id, ...patch }) : await this.store.update("setting", { id, value: json }, patch) === 1;
|
|
8216
|
+
if (written) return next;
|
|
8217
|
+
}
|
|
8218
|
+
throw new Error(`Setting "${id}" kept changing; try again.`);
|
|
8219
|
+
}
|
|
8220
|
+
/** Replaces the setting. */
|
|
8221
|
+
async set(id, value, updatedBy) {
|
|
8222
|
+
await this.change(id, () => value, updatedBy);
|
|
8223
|
+
}
|
|
8224
|
+
/** Which sign-in methods are on, with their settings; the initial ones until an admin set them. */
|
|
8225
|
+
async methods() {
|
|
8226
|
+
return this.current(await this.get(METHODS_SETTING));
|
|
8227
|
+
}
|
|
8228
|
+
/** Changes which methods are on, see {@link change}; `apply` gets the methods as they are now. */
|
|
8229
|
+
async changeMethods(apply, updatedBy) {
|
|
8230
|
+
return this.change(METHODS_SETTING, (current) => apply(this.current(current)), updatedBy);
|
|
8231
|
+
}
|
|
8232
|
+
/** The stored methods, or the initial ones if nothing (usable) is stored. */
|
|
8233
|
+
current(stored) {
|
|
8234
|
+
if (stored === void 0) return this.initial;
|
|
8235
|
+
const parsed = parseMethodSettings(stored);
|
|
8236
|
+
if (parsed) return parsed;
|
|
8237
|
+
if (!this.warned) {
|
|
8238
|
+
this.warned = true;
|
|
8239
|
+
console.warn("[madauth] The stored sign-in methods are not in the form this version expects (they were set by an older one?). Set them again with set-methods; until then the initial methods count.");
|
|
8240
|
+
}
|
|
8241
|
+
return this.initial;
|
|
8242
|
+
}
|
|
8243
|
+
};
|
|
8244
|
+
function isOn(method, configured, methods) {
|
|
8245
|
+
return configured(method) && methods[method] !== void 0;
|
|
8246
|
+
}
|
|
8247
|
+
function policyOf(method, methods) {
|
|
8248
|
+
return methods[method]?.secondFactor ?? "none";
|
|
8249
|
+
}
|
|
8250
|
+
function anyOn(signsUp, methods) {
|
|
8251
|
+
return SIGN_IN_METHODS.some((method) => signsUp(method) && methods[method] !== void 0);
|
|
8252
|
+
}
|
|
8253
|
+
function authenticatorOn(configured, methods) {
|
|
8254
|
+
if (isOn("totp", configured, methods)) return true;
|
|
8255
|
+
return METHODS_WITH_SECOND_FACTOR.some((method) => isOn(method, configured, methods) && policyOf(method, methods) !== "none");
|
|
7909
8256
|
}
|
|
7910
8257
|
|
|
7911
8258
|
// src/users.ts
|
|
7912
|
-
import { randomBytes as randomBytes2 } from "crypto";
|
|
7913
8259
|
var MAX_CODE_ATTEMPTS = 5;
|
|
7914
8260
|
var MAX_WRONG_CODES = 10;
|
|
7915
|
-
var newId = (prefix) => `${prefix}_${
|
|
8261
|
+
var newId = (prefix) => `${prefix}_${randomBytes3(16).toString("base64url")}`;
|
|
7916
8262
|
function passwordAccountKey(userId) {
|
|
7917
8263
|
return `password:${userId}`;
|
|
7918
8264
|
}
|
|
7919
8265
|
function googleAccountKey(sub) {
|
|
7920
8266
|
return `google:${sub}`;
|
|
7921
8267
|
}
|
|
8268
|
+
function totpAccountKey(userId) {
|
|
8269
|
+
return `totp:${userId}`;
|
|
8270
|
+
}
|
|
7922
8271
|
function toMadauthUser(user, profile = {}) {
|
|
7923
8272
|
const result = { id: user.id, email: user.email };
|
|
7924
8273
|
const name = user.name ?? profile.name;
|
|
@@ -7947,12 +8296,32 @@ var Users = class {
|
|
|
7947
8296
|
async passwordAccount(userId) {
|
|
7948
8297
|
return this.findAccountByKey(passwordAccountKey(userId));
|
|
7949
8298
|
}
|
|
8299
|
+
/** The user's authenticator app, if they set one up. */
|
|
8300
|
+
async totpAccount(userId) {
|
|
8301
|
+
return this.findAccountByKey(totpAccountKey(userId));
|
|
8302
|
+
}
|
|
8303
|
+
/** Whether the user has any way to sign in; a sign-up with the authenticator app has none until it is set up. */
|
|
8304
|
+
async hasAccounts(userId) {
|
|
8305
|
+
return (await this.store.findMany("account", { userId }, { limit: 1 })).length > 0;
|
|
8306
|
+
}
|
|
8307
|
+
/**
|
|
8308
|
+
* The ways the user can sign in, from their accounts: e.g. `['google']` for a user without a password.
|
|
8309
|
+
* The authenticator app counts only when it signs in on its own (`methods` lists it); as a second
|
|
8310
|
+
* factor it is a step of another method, not a way in.
|
|
8311
|
+
*/
|
|
8312
|
+
async signInMethods(userId, methods) {
|
|
8313
|
+
const accounts = await this.store.findMany("account", { userId });
|
|
8314
|
+
const keys = accounts.map((account) => String(account.key).split(":")[0]);
|
|
8315
|
+
return SIGN_IN_METHODS.filter((method) => keys.includes(method) && (method !== "totp" || methods.totp !== void 0));
|
|
8316
|
+
}
|
|
7950
8317
|
async findAccountByKey(key) {
|
|
7951
8318
|
return await this.store.findOne("account", { key });
|
|
7952
8319
|
}
|
|
7953
8320
|
/**
|
|
7954
|
-
* Creates a user with their first account. Resolves to null if the e-mail address or the
|
|
7955
|
-
* taken. `account.key` is `password:<userId>` for a password (its hash as `secret`), or
|
|
8321
|
+
* Creates a user, with their first account if given. Resolves to null if the e-mail address or the
|
|
8322
|
+
* account key is taken. `account.key` is `password:<userId>` for a password (its hash as `secret`), or
|
|
8323
|
+
* e.g. `google:<sub>`. A sign-up with the authenticator app creates the user without an account: the app
|
|
8324
|
+
* is set up once the address is confirmed.
|
|
7956
8325
|
*/
|
|
7957
8326
|
async createUser(data) {
|
|
7958
8327
|
const user = {
|
|
@@ -7968,6 +8337,7 @@ var Users = class {
|
|
|
7968
8337
|
createdAt: Date.now()
|
|
7969
8338
|
};
|
|
7970
8339
|
if (!await this.store.create("user", user)) return null;
|
|
8340
|
+
if (!data.account) return user;
|
|
7971
8341
|
const account = typeof data.account === "function" ? data.account(user.id) : data.account;
|
|
7972
8342
|
if (!await this.linkAccount(user.id, account)) {
|
|
7973
8343
|
await this.store.delete("user", { id: user.id });
|
|
@@ -7989,6 +8359,16 @@ var Users = class {
|
|
|
7989
8359
|
async deleteAccount(id) {
|
|
7990
8360
|
await this.store.delete("account", { id });
|
|
7991
8361
|
}
|
|
8362
|
+
/** Removes a sign-in method by its key. Resolves to false if there was none. */
|
|
8363
|
+
async unlinkAccount(key) {
|
|
8364
|
+
return await this.store.delete("account", { key }) === 1;
|
|
8365
|
+
}
|
|
8366
|
+
/** Removes the user's authenticator app and recovery codes. Resolves to false if they had no app. */
|
|
8367
|
+
async removeTotp(userId) {
|
|
8368
|
+
const removed = await this.unlinkAccount(totpAccountKey(userId));
|
|
8369
|
+
await this.clearRecoveryCodes(userId);
|
|
8370
|
+
return removed;
|
|
8371
|
+
}
|
|
7992
8372
|
/** Adds a sign-in method to a user. Resolves to null if the account key is taken. */
|
|
7993
8373
|
async linkAccount(userId, account) {
|
|
7994
8374
|
const record = {
|
|
@@ -7999,10 +8379,70 @@ var Users = class {
|
|
|
7999
8379
|
failedAttempts: 0,
|
|
8000
8380
|
lockedUntil: 0,
|
|
8001
8381
|
email: account.email ?? null,
|
|
8382
|
+
lastUsedStep: account.lastUsedStep ?? 0,
|
|
8383
|
+
setupId: account.setupId ?? null,
|
|
8002
8384
|
createdAt: Date.now()
|
|
8003
8385
|
};
|
|
8004
8386
|
return await this.store.create("account", record) ? record : null;
|
|
8005
8387
|
}
|
|
8388
|
+
/**
|
|
8389
|
+
* Writes the secret of a confirmed setup over the authenticator account, only if the account still has the
|
|
8390
|
+
* setup it had when this setup started (`replaces`): the write count decides, so of two setups confirmed
|
|
8391
|
+
* at once exactly one wins, and the other is told to start again.
|
|
8392
|
+
*/
|
|
8393
|
+
async confirmTotpSetup(accountId, replaces, patch) {
|
|
8394
|
+
return await this.store.update("account", { id: accountId, setupId: replaces }, patch) === 1;
|
|
8395
|
+
}
|
|
8396
|
+
/**
|
|
8397
|
+
* Marks the time step of an accepted authenticator code as used and resets the failed attempts, only if
|
|
8398
|
+
* no other request accepted a code meanwhile: the write count decides, so the same code sent twice signs
|
|
8399
|
+
* in once.
|
|
8400
|
+
*/
|
|
8401
|
+
async useTotpStep(account, step) {
|
|
8402
|
+
const changed = await this.store.update(
|
|
8403
|
+
"account",
|
|
8404
|
+
{ id: account.id, lastUsedStep: account.lastUsedStep ?? null },
|
|
8405
|
+
{ lastUsedStep: step, failedAttempts: 0, lockedUntil: 0 }
|
|
8406
|
+
);
|
|
8407
|
+
return changed === 1;
|
|
8408
|
+
}
|
|
8409
|
+
/** Replaces the user's recovery codes with new ones, given as their hashes. */
|
|
8410
|
+
/** Stores the recovery codes of a setup (their hashes), next to whatever codes the user has. */
|
|
8411
|
+
async addRecoveryCodes(userId, setupId, hashes) {
|
|
8412
|
+
const createdAt = Date.now();
|
|
8413
|
+
for (const id of hashes) await this.store.create("recoveryCode", { id, userId, setupId, createdAt });
|
|
8414
|
+
}
|
|
8415
|
+
/** New recovery codes for the user's current app: the codes they had before stop working. */
|
|
8416
|
+
async replaceRecoveryCodes(account, hashes) {
|
|
8417
|
+
const before = await this.store.findMany("recoveryCode", { userId: account.userId });
|
|
8418
|
+
await this.addRecoveryCodes(account.userId, account.setupId ?? "", hashes);
|
|
8419
|
+
for (const old of before) await this.store.delete("recoveryCode", { id: old.id });
|
|
8420
|
+
}
|
|
8421
|
+
/**
|
|
8422
|
+
* Uses up the recovery code with this hash for the user's authenticator account. Resolves to false if it
|
|
8423
|
+
* is not theirs, belongs to another setup than the account's, or was used already.
|
|
8424
|
+
*/
|
|
8425
|
+
async consumeRecoveryCode(account, hash) {
|
|
8426
|
+
const record = await this.store.findOne("recoveryCode", { id: hash });
|
|
8427
|
+
if (!record || record.userId !== account.userId || record.setupId !== (account.setupId ?? "")) return false;
|
|
8428
|
+
return await this.store.delete("recoveryCode", { id: hash }) === 1;
|
|
8429
|
+
}
|
|
8430
|
+
/** How many recovery codes of the account's setup are left. */
|
|
8431
|
+
async countRecoveryCodes(account) {
|
|
8432
|
+
const codes = await this.store.findMany("recoveryCode", { userId: account.userId });
|
|
8433
|
+
return codes.filter((code) => code.setupId === (account.setupId ?? "")).length;
|
|
8434
|
+
}
|
|
8435
|
+
/** Deletes the user's recovery codes: all of them, or only those of one setup, or all but one setup's. */
|
|
8436
|
+
async clearRecoveryCodes(userId, which = {}) {
|
|
8437
|
+
if (which.only === void 0 && which.except === void 0) {
|
|
8438
|
+
await this.store.delete("recoveryCode", { userId });
|
|
8439
|
+
return;
|
|
8440
|
+
}
|
|
8441
|
+
for (const code of await this.store.findMany("recoveryCode", { userId })) {
|
|
8442
|
+
const match2 = which.only !== void 0 ? code.setupId === which.only : code.setupId !== which.except;
|
|
8443
|
+
if (match2) await this.store.delete("recoveryCode", { id: code.id });
|
|
8444
|
+
}
|
|
8445
|
+
}
|
|
8006
8446
|
/**
|
|
8007
8447
|
* Deletes the user and everything stored for them. Resolves to false if there is no such user (any more).
|
|
8008
8448
|
*/
|
|
@@ -8013,6 +8453,7 @@ var Users = class {
|
|
|
8013
8453
|
try {
|
|
8014
8454
|
await this.store.delete("account", { userId: user.id });
|
|
8015
8455
|
await this.store.delete("verification", { userId: user.id });
|
|
8456
|
+
await this.store.delete("recoveryCode", { userId: user.id });
|
|
8016
8457
|
} catch (e) {
|
|
8017
8458
|
console.error(`[madauth] Deleted user ${user.id}, but not all of their records: ${e.message}`);
|
|
8018
8459
|
}
|
|
@@ -8021,8 +8462,9 @@ var Users = class {
|
|
|
8021
8462
|
async updateUser(id, patch) {
|
|
8022
8463
|
await this.store.update("user", { id }, patch);
|
|
8023
8464
|
}
|
|
8465
|
+
/** Changes an account. Resolves to false if it is gone meanwhile. */
|
|
8024
8466
|
async updateAccount(id, patch) {
|
|
8025
|
-
await this.store.update("account", { id }, patch);
|
|
8467
|
+
return await this.store.update("account", { id }, patch) === 1;
|
|
8026
8468
|
}
|
|
8027
8469
|
/**
|
|
8028
8470
|
* Counts a sign-in attempt: writes `patch` only if the account's `failedAttempts` is still the value that
|
|
@@ -8055,6 +8497,11 @@ var Users = class {
|
|
|
8055
8497
|
await this.store.create("verification", record);
|
|
8056
8498
|
return { token, code };
|
|
8057
8499
|
}
|
|
8500
|
+
/** The user ID a link token belongs to, without using the verification up; null if invalid, expired or used. */
|
|
8501
|
+
async peekLinkToken(token, purpose) {
|
|
8502
|
+
const record = await this.store.findOne("verification", { id: hashLinkToken(token), purpose });
|
|
8503
|
+
return record && record.expiresAt > Date.now() ? record.userId : null;
|
|
8504
|
+
}
|
|
8058
8505
|
/** Uses up the verification a link token belongs to. Resolves to its user ID, or null if invalid, expired or used. */
|
|
8059
8506
|
async consumeLinkToken(token, purpose) {
|
|
8060
8507
|
const id = hashLinkToken(token);
|
|
@@ -8101,82 +8548,213 @@ var Users = class {
|
|
|
8101
8548
|
}
|
|
8102
8549
|
};
|
|
8103
8550
|
|
|
8104
|
-
// src/
|
|
8105
|
-
|
|
8106
|
-
|
|
8107
|
-
"
|
|
8108
|
-
|
|
8109
|
-
|
|
8110
|
-
|
|
8111
|
-
|
|
8112
|
-
|
|
8113
|
-
|
|
8114
|
-
|
|
8115
|
-
|
|
8116
|
-
|
|
8117
|
-
|
|
8118
|
-
|
|
8119
|
-
var
|
|
8120
|
-
function
|
|
8121
|
-
return typeof value === "string" && value.
|
|
8551
|
+
// src/routes/helpers.ts
|
|
8552
|
+
async function body(c) {
|
|
8553
|
+
const data = await c.req.json().catch(() => null);
|
|
8554
|
+
return data && typeof data === "object" ? data : {};
|
|
8555
|
+
}
|
|
8556
|
+
function error(c, status, code, message2) {
|
|
8557
|
+
return c.json({ error: code, message: message2 }, status);
|
|
8558
|
+
}
|
|
8559
|
+
var unavailable = (c) => error(c, 503, "temporarily_unavailable", "E-mails can not be sent right now. Please try again later.");
|
|
8560
|
+
var accepted = (c) => c.json({}, 202);
|
|
8561
|
+
var noSession = (c) => error(c, 401, "no_session", "Please sign in first.");
|
|
8562
|
+
function redirectTarget(ctx, c, value) {
|
|
8563
|
+
const url = ctx.allowedUrl(value);
|
|
8564
|
+
return url ?? error(c, 400, "invalid_options", "redirectTo must be an absolute URL whose origin is in ALLOWED_ORIGINS.");
|
|
8565
|
+
}
|
|
8566
|
+
var MAX_NAME_LENGTH = 100;
|
|
8567
|
+
function nameOf(value) {
|
|
8568
|
+
return typeof value === "string" && value.trim() ? value.trim().slice(0, MAX_NAME_LENGTH) : null;
|
|
8569
|
+
}
|
|
8570
|
+
function signInAnswer(c, step) {
|
|
8571
|
+
if ("disabled" in step) return error(c, 403, "method_disabled", "This way of signing in is switched off.");
|
|
8572
|
+
if ("next" in step) {
|
|
8573
|
+
return step.next === "totp" ? c.json({ error: "totp_required", message: "Enter the code from your authenticator app to finish signing in.", method: step.method }, 401) : c.json({ error: "totp_setup_required", message: "Set up an authenticator app to finish signing in.", method: step.method }, 401);
|
|
8574
|
+
}
|
|
8575
|
+
return c.json({ user: step.user });
|
|
8576
|
+
}
|
|
8577
|
+
|
|
8578
|
+
// src/routes/emails.ts
|
|
8579
|
+
var MAIL_INTERVAL_MS = 60 * 1e3;
|
|
8580
|
+
var VERIFY_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
8581
|
+
var RESET_TTL_MS = 30 * 60 * 1e3;
|
|
8582
|
+
var VERIFY_LINK_PARAM = "madauth_verify";
|
|
8583
|
+
var RESET_LINK_PARAM = "madauth_reset";
|
|
8584
|
+
function createEmails(ctx, users, webhook) {
|
|
8585
|
+
const sendEmail = async (user, type, data) => {
|
|
8586
|
+
if (Date.now() - user.lastMailAt < MAIL_INTERVAL_MS) return true;
|
|
8587
|
+
const result = await webhook.call(type, { to: user.email, user: { id: user.id, name: user.name ?? void 0 }, ...data }, WEBHOOK_TIMEOUT_MS);
|
|
8588
|
+
if (!result.ok) {
|
|
8589
|
+
console.error(`[madauth] Webhook "${type}" for ${user.email} failed: ${result.reason}`);
|
|
8590
|
+
return false;
|
|
8591
|
+
}
|
|
8592
|
+
await users.updateUser(user.id, { lastMailAt: Date.now() });
|
|
8593
|
+
return true;
|
|
8594
|
+
};
|
|
8595
|
+
return {
|
|
8596
|
+
sendEmail,
|
|
8597
|
+
async sendLinkEmail(user, purpose, target, locale) {
|
|
8598
|
+
if (Date.now() - user.lastMailAt < MAIL_INTERVAL_MS) return true;
|
|
8599
|
+
const ttl = purpose === "verify" ? VERIFY_TTL_MS : RESET_TTL_MS;
|
|
8600
|
+
const { token, code } = await users.issueVerification(user.id, purpose, ttl);
|
|
8601
|
+
const link = new URL(target);
|
|
8602
|
+
link.hash = `${purpose === "verify" ? VERIFY_LINK_PARAM : RESET_LINK_PARAM}=${token}`;
|
|
8603
|
+
return sendEmail(user, purpose === "verify" ? "email.verify" : "email.reset", {
|
|
8604
|
+
link: link.href,
|
|
8605
|
+
code,
|
|
8606
|
+
expiresAt: Date.now() + ttl,
|
|
8607
|
+
site: target.host,
|
|
8608
|
+
locale
|
|
8609
|
+
});
|
|
8610
|
+
},
|
|
8611
|
+
async sendAccountEmail(user, type, target, locale) {
|
|
8612
|
+
if (!webhook.wants(type)) return true;
|
|
8613
|
+
const methods = await users.signInMethods(user.id, await ctx.settings.methods());
|
|
8614
|
+
return sendEmail(user, type, { link: target.href, site: target.host, locale, methods });
|
|
8615
|
+
},
|
|
8616
|
+
async consume(data, purpose) {
|
|
8617
|
+
if (typeof data.token === "string" && data.token) {
|
|
8618
|
+
return { userId: await users.consumeLinkToken(data.token, purpose), via: "link" };
|
|
8619
|
+
}
|
|
8620
|
+
if (isValidEmail(data.email) && typeof data.code === "string") {
|
|
8621
|
+
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
8622
|
+
if (!user) return { userId: null, via: "code" };
|
|
8623
|
+
return { ...await users.consumeCode(user, purpose, data.code.replace(/\s/g, "")), via: "code" };
|
|
8624
|
+
}
|
|
8625
|
+
return { userId: null, via: "code" };
|
|
8626
|
+
},
|
|
8627
|
+
invalidVerification(c, { via, locked }) {
|
|
8628
|
+
if (locked) {
|
|
8629
|
+
return error(c, 429, "codes_locked", "Too many wrong codes. Please ask for a new e-mail and use the link in it.");
|
|
8630
|
+
}
|
|
8631
|
+
return via === "link" ? error(c, 400, "link_invalid", "The link is invalid, expired or was already used.") : error(c, 400, "code_invalid", "The code is wrong or expired.");
|
|
8632
|
+
}
|
|
8633
|
+
};
|
|
8634
|
+
}
|
|
8635
|
+
|
|
8636
|
+
// src/routes/email.ts
|
|
8637
|
+
function emailRoutes(app2, ctx, users, webhook) {
|
|
8638
|
+
const emails = createEmails(ctx, users, webhook);
|
|
8639
|
+
const signUpMethod = async (userId) => await users.passwordAccount(userId) ? "password" : "totp";
|
|
8640
|
+
app2.post("/auth/email/send-verification", async (c) => {
|
|
8641
|
+
const data = await body(c);
|
|
8642
|
+
const target = redirectTarget(ctx, c, data.redirectTo);
|
|
8643
|
+
if (target instanceof Response) return target;
|
|
8644
|
+
if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
|
|
8645
|
+
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
8646
|
+
if (user && !user.emailVerified && await ctx.enabled(c, await signUpMethod(user.id))) {
|
|
8647
|
+
if (!await emails.sendLinkEmail(user, "verify", target, localeOf(data.locale))) return unavailable(c);
|
|
8648
|
+
}
|
|
8649
|
+
return accepted(c);
|
|
8650
|
+
});
|
|
8651
|
+
const disabled = (c) => error(c, 403, "method_disabled", "This way of signing in is switched off.");
|
|
8652
|
+
app2.post("/auth/email/verify", async (c) => {
|
|
8653
|
+
const data = await body(c);
|
|
8654
|
+
const waiting = typeof data.token === "string" && data.token ? await users.peekLinkToken(data.token, "verify") : isValidEmail(data.email) ? (await users.findByEmail(normalizeEmail(data.email)))?.id ?? null : null;
|
|
8655
|
+
if (waiting && !await ctx.enabled(c, await signUpMethod(waiting))) return disabled(c);
|
|
8656
|
+
const consumed = await emails.consume(data, "verify");
|
|
8657
|
+
const { userId, via } = consumed;
|
|
8658
|
+
const user = userId ? await users.findById(userId) : null;
|
|
8659
|
+
if (!user) return emails.invalidVerification(c, consumed);
|
|
8660
|
+
const method = await signUpMethod(user.id);
|
|
8661
|
+
if (!await ctx.enabled(c, method)) return disabled(c);
|
|
8662
|
+
await users.updateUser(user.id, { emailVerified: true, wrongCodes: 0 });
|
|
8663
|
+
const verified = { ...user, emailVerified: true, wrongCodes: 0 };
|
|
8664
|
+
await ctx.emit("email.verified", { user: toMadauthUser(verified), via });
|
|
8665
|
+
return signInAnswer(c, await ctx.secondStep(c, verified, method === "password" ? ["pwd"] : [], method));
|
|
8666
|
+
});
|
|
8667
|
+
}
|
|
8668
|
+
|
|
8669
|
+
// src/google.ts
|
|
8670
|
+
var GOOGLE_ISSUERS = ["https://accounts.google.com", "accounts.google.com"];
|
|
8671
|
+
var googleJwks;
|
|
8672
|
+
function googleKeys() {
|
|
8673
|
+
googleJwks ??= createRemoteJWKSet(new URL("https://www.googleapis.com/oauth2/v3/certs"));
|
|
8674
|
+
return googleJwks;
|
|
8675
|
+
}
|
|
8676
|
+
async function verifyGoogleIdToken(idToken, opts) {
|
|
8677
|
+
let payload;
|
|
8678
|
+
try {
|
|
8679
|
+
({ payload } = await jwtVerify(idToken, opts.keys ?? googleKeys(), {
|
|
8680
|
+
issuer: GOOGLE_ISSUERS,
|
|
8681
|
+
audience: opts.clientId,
|
|
8682
|
+
algorithms: ["RS256", "ES256"]
|
|
8683
|
+
}));
|
|
8684
|
+
} catch (e) {
|
|
8685
|
+
return { ok: false, error: "verification_failed", reason: e.message };
|
|
8686
|
+
}
|
|
8687
|
+
if (typeof payload.sub !== "string" || !payload.sub) {
|
|
8688
|
+
return { ok: false, error: "verification_failed", reason: "missing sub" };
|
|
8689
|
+
}
|
|
8690
|
+
if (payload.nonce !== opts.nonce) {
|
|
8691
|
+
return { ok: false, error: "verification_failed", reason: "nonce mismatch" };
|
|
8692
|
+
}
|
|
8693
|
+
if (typeof payload.email !== "string" || !payload.email || payload.email_verified !== true) {
|
|
8694
|
+
return { ok: false, error: "email_unverified", reason: "e-mail address not verified" };
|
|
8695
|
+
}
|
|
8696
|
+
const profile = { sub: payload.sub, email: payload.email };
|
|
8697
|
+
if (typeof payload.name === "string") profile.name = payload.name;
|
|
8698
|
+
if (typeof payload.picture === "string") profile.picture = payload.picture;
|
|
8699
|
+
return { ok: true, profile };
|
|
8122
8700
|
}
|
|
8123
|
-
|
|
8124
|
-
|
|
8125
|
-
var
|
|
8126
|
-
var
|
|
8127
|
-
|
|
8128
|
-
|
|
8701
|
+
|
|
8702
|
+
// src/user.ts
|
|
8703
|
+
var SESSION_COOKIE = "madauth_session";
|
|
8704
|
+
var SESSION_TYP = "madauth-session+jwt";
|
|
8705
|
+
var RENEWAL_COOKIE = "madauth_renewal";
|
|
8706
|
+
var RENEWAL_TYP = "madauth-renewal+jwt";
|
|
8707
|
+
var EXPIRY_COOKIE = "madauth_session_expires";
|
|
8708
|
+
|
|
8709
|
+
// src/tokens.ts
|
|
8710
|
+
function randomString(bytes = 32) {
|
|
8711
|
+
return base64url_exports.encode(crypto.getRandomValues(new Uint8Array(bytes)));
|
|
8129
8712
|
}
|
|
8130
|
-
function
|
|
8131
|
-
|
|
8132
|
-
const key = Buffer.from(secret.slice(SECRET_PREFIX.length), "base64");
|
|
8133
|
-
if (key.length < 24 || key.length > 64) return "must contain 24 to 64 bytes in base64";
|
|
8134
|
-
return null;
|
|
8713
|
+
async function signToken(keys, issuer, typ, claims, ttlSeconds) {
|
|
8714
|
+
return new SignJWT(claims).setProtectedHeader({ alg: SIGNING_ALG, kid: keys.kid, typ }).setIssuer(issuer).setIssuedAt().setExpirationTime(`${ttlSeconds}s`).sign(keys.privateKey);
|
|
8135
8715
|
}
|
|
8136
|
-
function
|
|
8137
|
-
|
|
8138
|
-
|
|
8716
|
+
async function readToken(keys, issuer, typ, token) {
|
|
8717
|
+
if (!token) return null;
|
|
8718
|
+
try {
|
|
8719
|
+
const { payload } = await jwtVerify(token, keys.publicKey, { issuer, typ, algorithms: [SIGNING_ALG] });
|
|
8720
|
+
return payload;
|
|
8721
|
+
} catch {
|
|
8722
|
+
return null;
|
|
8723
|
+
}
|
|
8139
8724
|
}
|
|
8140
|
-
function
|
|
8141
|
-
|
|
8725
|
+
function signSession(keys, issuer, ttlSeconds, user, amr, extra = {}) {
|
|
8726
|
+
const claims = {
|
|
8727
|
+
...extra,
|
|
8728
|
+
sub: user.id,
|
|
8729
|
+
email: user.email,
|
|
8730
|
+
email_verified: user.email ? true : void 0,
|
|
8731
|
+
name: user.name,
|
|
8732
|
+
picture: user.picture,
|
|
8733
|
+
claims: user.claims && Object.keys(user.claims).length ? user.claims : void 0,
|
|
8734
|
+
amr
|
|
8735
|
+
};
|
|
8736
|
+
return signToken(keys, issuer, SESSION_TYP, claims, ttlSeconds);
|
|
8142
8737
|
}
|
|
8143
|
-
function
|
|
8144
|
-
const
|
|
8145
|
-
|
|
8146
|
-
|
|
8147
|
-
|
|
8148
|
-
|
|
8149
|
-
|
|
8150
|
-
|
|
8151
|
-
|
|
8152
|
-
let response;
|
|
8153
|
-
try {
|
|
8154
|
-
response = await fetchImpl(settings.url, {
|
|
8155
|
-
method: "POST",
|
|
8156
|
-
headers: {
|
|
8157
|
-
"Content-Type": "application/json",
|
|
8158
|
-
"webhook-id": id,
|
|
8159
|
-
"webhook-timestamp": String(timestamp),
|
|
8160
|
-
"webhook-signature": signWebhook(settings.secret, id, timestamp, body)
|
|
8161
|
-
},
|
|
8162
|
-
body,
|
|
8163
|
-
signal: AbortSignal.timeout(timeoutMs)
|
|
8164
|
-
});
|
|
8165
|
-
} catch (e) {
|
|
8166
|
-
const reason = e.name === "TimeoutError" ? `no answer within ${timeoutMs} ms` : e.message;
|
|
8167
|
-
return { ok: false, reason };
|
|
8168
|
-
}
|
|
8169
|
-
const text = await response.text().catch(() => "");
|
|
8170
|
-
if (!response.ok) return { ok: false, reason: `HTTP ${response.status}` };
|
|
8171
|
-
let parsed;
|
|
8172
|
-
try {
|
|
8173
|
-
parsed = text ? JSON.parse(text) : void 0;
|
|
8174
|
-
} catch {
|
|
8175
|
-
parsed = void 0;
|
|
8176
|
-
}
|
|
8177
|
-
return { ok: true, body: parsed };
|
|
8178
|
-
}
|
|
8738
|
+
function signRenewal(keys, issuer, ttlSeconds, user, amr, extra = {}) {
|
|
8739
|
+
const claims = {
|
|
8740
|
+
...extra,
|
|
8741
|
+
sub: user.id,
|
|
8742
|
+
email: user.email,
|
|
8743
|
+
email_verified: user.email ? true : void 0,
|
|
8744
|
+
name: user.name,
|
|
8745
|
+
picture: user.picture,
|
|
8746
|
+
amr
|
|
8179
8747
|
};
|
|
8748
|
+
return signToken(keys, issuer, RENEWAL_TYP, claims, ttlSeconds);
|
|
8749
|
+
}
|
|
8750
|
+
function userFromClaims(claims) {
|
|
8751
|
+
const user = { id: claims.sub };
|
|
8752
|
+
if (claims.email) user.email = claims.email;
|
|
8753
|
+
if (claims.name) user.name = claims.name;
|
|
8754
|
+
if (claims.picture) user.picture = claims.picture;
|
|
8755
|
+
if (claims.claims && typeof claims.claims === "object" && Object.keys(claims.claims).length) user.claims = claims.claims;
|
|
8756
|
+
if (Array.isArray(claims.amr)) user.amr = claims.amr.filter((value) => typeof value === "string");
|
|
8757
|
+
return user;
|
|
8180
8758
|
}
|
|
8181
8759
|
|
|
8182
8760
|
// src/routes/google.ts
|
|
@@ -8189,6 +8767,7 @@ var OAUTH_TTL = 10 * 60;
|
|
|
8189
8767
|
var GOOGLE_AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
8190
8768
|
var GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
|
|
8191
8769
|
var REDIRECT_ERROR_PARAM = "madauth_error";
|
|
8770
|
+
var REDIRECT_NEXT_PARAM = "madauth_next";
|
|
8192
8771
|
async function pkceChallenge(verifier) {
|
|
8193
8772
|
const digest2 = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
8194
8773
|
return base64url_exports.encode(new Uint8Array(digest2));
|
|
@@ -8200,7 +8779,7 @@ function googleRoutes(app2, ctx, google) {
|
|
|
8200
8779
|
const resolveUser = async (profile, locale) => {
|
|
8201
8780
|
const key = googleAccountKey(profile.sub);
|
|
8202
8781
|
const emailNormalized = normalizeEmail(profile.email);
|
|
8203
|
-
const signedIn = (user) => ({ ok: true, user
|
|
8782
|
+
const signedIn = (user) => ({ ok: true, user });
|
|
8204
8783
|
const account = await users.findAccountByKey(key);
|
|
8205
8784
|
if (account) {
|
|
8206
8785
|
const user = await users.findById(account.userId);
|
|
@@ -8213,6 +8792,11 @@ function googleRoutes(app2, ctx, google) {
|
|
|
8213
8792
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
8214
8793
|
const existing = await users.findByEmail(emailNormalized);
|
|
8215
8794
|
if (existing) {
|
|
8795
|
+
if (existing.emailVerified && await users.hasAccounts(existing.id)) {
|
|
8796
|
+
const methods = await users.signInMethods(existing.id, await ctx.settings.methods());
|
|
8797
|
+
const how = methods.map((method) => ({ password: "a password", totp: "an authenticator app", google: "Google" })[method]).join(" or ");
|
|
8798
|
+
return { ok: false, error: "other_method", message: `This e-mail address signs in with ${how || "another method"}. Use that instead of Google.`, methods };
|
|
8799
|
+
}
|
|
8216
8800
|
await users.verifyByProvider(existing);
|
|
8217
8801
|
if (!await users.linkAccount(existing.id, { key, email: profile.email })) {
|
|
8218
8802
|
const linked = await users.findAccountByKey(key);
|
|
@@ -8244,7 +8828,7 @@ function googleRoutes(app2, ctx, google) {
|
|
|
8244
8828
|
};
|
|
8245
8829
|
const disabled = (c) => c.json({ error: "method_disabled", message: "Google sign-in is switched off." }, 403);
|
|
8246
8830
|
app2.post("/auth/google/nonce", async (c) => {
|
|
8247
|
-
if (!await ctx.enabled("google")) return disabled(c);
|
|
8831
|
+
if (!await ctx.enabled(c, "google")) return disabled(c);
|
|
8248
8832
|
const nonce = randomString();
|
|
8249
8833
|
const token = await signToken(await keys, issuer, NONCE_TYP, { nonce }, NONCE_TTL);
|
|
8250
8834
|
setCookie(c, NONCE_COOKIE, token, {
|
|
@@ -8257,16 +8841,16 @@ function googleRoutes(app2, ctx, google) {
|
|
|
8257
8841
|
return c.json({ nonce });
|
|
8258
8842
|
});
|
|
8259
8843
|
app2.post("/auth/google/verify", async (c) => {
|
|
8260
|
-
if (!await ctx.enabled("google")) return disabled(c);
|
|
8261
|
-
const
|
|
8262
|
-
if (typeof
|
|
8844
|
+
if (!await ctx.enabled(c, "google")) return disabled(c);
|
|
8845
|
+
const body2 = await c.req.json().catch(() => ({}));
|
|
8846
|
+
if (typeof body2.credential !== "string" || !body2.credential) {
|
|
8263
8847
|
return c.json({ error: "verification_failed", message: "credential is missing" }, 400);
|
|
8264
8848
|
}
|
|
8265
8849
|
const nonceClaims = await readToken(await keys, issuer, NONCE_TYP, getCookie(c, NONCE_COOKIE));
|
|
8266
8850
|
if (!nonceClaims) {
|
|
8267
8851
|
return c.json({ error: "verification_failed", message: "nonce cookie missing or expired" }, 401);
|
|
8268
8852
|
}
|
|
8269
|
-
const result = await verifyGoogleIdToken(
|
|
8853
|
+
const result = await verifyGoogleIdToken(body2.credential, {
|
|
8270
8854
|
clientId: google.clientId,
|
|
8271
8855
|
nonce: nonceClaims.nonce,
|
|
8272
8856
|
keys: config.jwksResolver
|
|
@@ -8275,17 +8859,16 @@ function googleRoutes(app2, ctx, google) {
|
|
|
8275
8859
|
return c.json({ error: result.error, message: result.reason }, result.error === "email_unverified" ? 403 : 401);
|
|
8276
8860
|
}
|
|
8277
8861
|
deleteCookie(c, NONCE_COOKIE, { path: "/auth/google", secure });
|
|
8278
|
-
const resolved = await resolveUser(result.profile, localeOf(
|
|
8862
|
+
const resolved = await resolveUser(result.profile, localeOf(body2.locale));
|
|
8279
8863
|
if (!resolved.ok) {
|
|
8864
|
+
if (resolved.error === "other_method") return c.json({ error: resolved.error, message: resolved.message, methods: resolved.methods }, 403);
|
|
8280
8865
|
return c.json({ error: resolved.error, message: resolved.message }, resolved.error === "signup_rejected" ? 403 : 503);
|
|
8281
8866
|
}
|
|
8282
|
-
|
|
8283
|
-
await ctx.emit("user.signed_in", { user, method: "google" });
|
|
8284
|
-
return c.json({ user });
|
|
8867
|
+
return signInAnswer(c, await ctx.secondStep(c, resolved.user, ["google"], "google", result.profile));
|
|
8285
8868
|
});
|
|
8286
8869
|
const requireCodeFlow = async (c, next) => {
|
|
8287
8870
|
if (!google.clientSecret) return c.json({ error: "not_found" }, 404);
|
|
8288
|
-
if (!await ctx.enabled("google")) return disabled(c);
|
|
8871
|
+
if (!await ctx.enabled(c, "google")) return disabled(c);
|
|
8289
8872
|
await next();
|
|
8290
8873
|
};
|
|
8291
8874
|
app2.get("/auth/google/start", requireCodeFlow, async (c) => {
|
|
@@ -8323,9 +8906,10 @@ function googleRoutes(app2, ctx, google) {
|
|
|
8323
8906
|
const oauth = await readToken(await keys, issuer, OAUTH_TYP, getCookie(c, OAUTH_COOKIE));
|
|
8324
8907
|
if (!oauth) return c.text("Sign-in expired or was started in another browser. Please try again.", 400);
|
|
8325
8908
|
deleteCookie(c, OAUTH_COOKIE, { path: "/auth/google", secure });
|
|
8326
|
-
const back = (
|
|
8909
|
+
const back = (error2, next) => {
|
|
8327
8910
|
const url = new URL(oauth.returnTo);
|
|
8328
|
-
if (
|
|
8911
|
+
if (error2) url.hash = `${REDIRECT_ERROR_PARAM}=${error2}`;
|
|
8912
|
+
if (next) url.hash = `${REDIRECT_NEXT_PARAM}=${next}`;
|
|
8329
8913
|
return c.redirect(url.href, 302);
|
|
8330
8914
|
};
|
|
8331
8915
|
if (c.req.query("error")) return back(c.req.query("error") === "access_denied" ? "cancelled" : "verification_failed");
|
|
@@ -8359,80 +8943,38 @@ function googleRoutes(app2, ctx, google) {
|
|
|
8359
8943
|
if (!result.ok) return back(result.error);
|
|
8360
8944
|
const resolved = await resolveUser(result.profile, typeof oauth.locale === "string" ? oauth.locale : void 0);
|
|
8361
8945
|
if (!resolved.ok) return back(resolved.error);
|
|
8362
|
-
const
|
|
8363
|
-
|
|
8946
|
+
const step = await ctx.secondStep(c, resolved.user, ["google"], "google", result.profile);
|
|
8947
|
+
if ("disabled" in step) return back("method_disabled");
|
|
8948
|
+
if ("next" in step) return back(void 0, step.next);
|
|
8364
8949
|
return back();
|
|
8365
8950
|
});
|
|
8366
8951
|
}
|
|
8367
8952
|
|
|
8368
|
-
// src/routes/
|
|
8953
|
+
// src/routes/lockout.ts
|
|
8369
8954
|
var FREE_ATTEMPTS = 5;
|
|
8370
8955
|
var MAX_LOCK_MS = 15 * 60 * 1e3;
|
|
8371
|
-
|
|
8372
|
-
|
|
8373
|
-
|
|
8374
|
-
|
|
8375
|
-
|
|
8376
|
-
|
|
8956
|
+
async function countSignInAttempt(c, users, account, now = Date.now()) {
|
|
8957
|
+
const tooMany = (message2) => c.json({ error: "too_many_attempts", message: message2 }, 429);
|
|
8958
|
+
if (account.lockedUntil > now) {
|
|
8959
|
+
const seconds = Math.ceil((account.lockedUntil - now) / 1e3);
|
|
8960
|
+
return tooMany(`Too many failed attempts. Try again in ${seconds} seconds.`);
|
|
8961
|
+
}
|
|
8962
|
+
const failed = account.failedAttempts + 1;
|
|
8963
|
+
const lockedUntil = failed >= FREE_ATTEMPTS ? now + Math.min(2 ** (failed - FREE_ATTEMPTS) * 1e3, MAX_LOCK_MS) : 0;
|
|
8964
|
+
if (!await users.countAttempt(account, { failedAttempts: failed, lockedUntil })) {
|
|
8965
|
+
return tooMany("Too many attempts at once. Please try again.");
|
|
8966
|
+
}
|
|
8967
|
+
return null;
|
|
8968
|
+
}
|
|
8969
|
+
|
|
8970
|
+
// src/routes/password.ts
|
|
8377
8971
|
function passwordRoutes(app2, ctx, password, users, webhook) {
|
|
8378
8972
|
const { minLength } = password;
|
|
8379
|
-
const
|
|
8973
|
+
const emails = createEmails(ctx, users, webhook);
|
|
8380
8974
|
app2.use("/auth/password/*", async (c, next) => {
|
|
8381
|
-
if (!await ctx.enabled("password")) return
|
|
8975
|
+
if (!await ctx.enabled(c, "password")) return error(c, 403, "method_disabled", "E-mail & password sign-in is switched off.");
|
|
8382
8976
|
await next();
|
|
8383
8977
|
});
|
|
8384
|
-
const body = async (c) => {
|
|
8385
|
-
const data = await c.req.json().catch(() => null);
|
|
8386
|
-
return data && typeof data === "object" ? data : {};
|
|
8387
|
-
};
|
|
8388
|
-
const error = (c, status, code, message2) => c.json({ error: code, message: message2 }, status);
|
|
8389
|
-
const unavailable = (c) => error(c, 503, "temporarily_unavailable", "E-mails can not be sent right now. Please try again later.");
|
|
8390
|
-
const accepted = (c) => c.json({}, 202);
|
|
8391
|
-
const redirectTarget = (c, value) => {
|
|
8392
|
-
const url = ctx.allowedUrl(value);
|
|
8393
|
-
return url ?? error(c, 400, "invalid_options", "redirectTo must be an absolute URL whose origin is in ALLOWED_ORIGINS.");
|
|
8394
|
-
};
|
|
8395
|
-
const sendEmail = async (user, type, data) => {
|
|
8396
|
-
if (Date.now() - user.lastMailAt < MAIL_INTERVAL_MS) return true;
|
|
8397
|
-
const result = await webhook.call(type, { to: user.email, user: { id: user.id, name: user.name ?? void 0 }, ...data }, WEBHOOK_TIMEOUT_MS);
|
|
8398
|
-
if (!result.ok) {
|
|
8399
|
-
console.error(`[madauth] Webhook "${type}" for ${user.email} failed: ${result.reason}`);
|
|
8400
|
-
return false;
|
|
8401
|
-
}
|
|
8402
|
-
await users.updateUser(user.id, { lastMailAt: Date.now() });
|
|
8403
|
-
return true;
|
|
8404
|
-
};
|
|
8405
|
-
const sendLinkEmail = async (user, purpose, target, locale) => {
|
|
8406
|
-
if (Date.now() - user.lastMailAt < MAIL_INTERVAL_MS) return true;
|
|
8407
|
-
const ttl = purpose === "verify" ? VERIFY_TTL_MS : RESET_TTL_MS;
|
|
8408
|
-
const { token, code } = await users.issueVerification(user.id, purpose, ttl);
|
|
8409
|
-
const link = new URL(target);
|
|
8410
|
-
link.hash = `${purpose === "verify" ? VERIFY_LINK_PARAM : RESET_LINK_PARAM}=${token}`;
|
|
8411
|
-
return sendEmail(user, purpose === "verify" ? "email.verify" : "email.reset", {
|
|
8412
|
-
link: link.href,
|
|
8413
|
-
code,
|
|
8414
|
-
expiresAt: Date.now() + ttl,
|
|
8415
|
-
site: target.host,
|
|
8416
|
-
locale
|
|
8417
|
-
});
|
|
8418
|
-
};
|
|
8419
|
-
const consume = async (data, purpose) => {
|
|
8420
|
-
if (typeof data.token === "string" && data.token) {
|
|
8421
|
-
return { userId: await users.consumeLinkToken(data.token, purpose), via: "link" };
|
|
8422
|
-
}
|
|
8423
|
-
if (isValidEmail(data.email) && typeof data.code === "string") {
|
|
8424
|
-
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
8425
|
-
if (!user) return { userId: null, via: "code" };
|
|
8426
|
-
return { ...await users.consumeCode(user, purpose, data.code.replace(/\s/g, "")), via: "code" };
|
|
8427
|
-
}
|
|
8428
|
-
return { userId: null, via: "code" };
|
|
8429
|
-
};
|
|
8430
|
-
const invalidVerification = (c, { via, locked }) => {
|
|
8431
|
-
if (locked) {
|
|
8432
|
-
return error(c, 429, "codes_locked", "Too many wrong codes. Please ask for a new e-mail and use the link in it.");
|
|
8433
|
-
}
|
|
8434
|
-
return via === "link" ? error(c, 400, "link_invalid", "The link is invalid, expired or was already used.") : error(c, 400, "code_invalid", "The code is wrong or expired.");
|
|
8435
|
-
};
|
|
8436
8978
|
app2.post("/auth/password/signin", async (c) => {
|
|
8437
8979
|
const data = await body(c);
|
|
8438
8980
|
const wrong = () => error(c, 401, "invalid_credentials", "E-mail or password is wrong.");
|
|
@@ -8444,16 +8986,8 @@ function passwordRoutes(app2, ctx, password, users, webhook) {
|
|
|
8444
8986
|
await verifyAgainstDummy(pw);
|
|
8445
8987
|
return wrong();
|
|
8446
8988
|
}
|
|
8447
|
-
const
|
|
8448
|
-
if (
|
|
8449
|
-
const seconds = Math.ceil((account.lockedUntil - now) / 1e3);
|
|
8450
|
-
return error(c, 429, "too_many_attempts", `Too many failed attempts. Try again in ${seconds} seconds.`);
|
|
8451
|
-
}
|
|
8452
|
-
const failed = account.failedAttempts + 1;
|
|
8453
|
-
const lockedUntil = failed >= FREE_ATTEMPTS ? now + Math.min(2 ** (failed - FREE_ATTEMPTS) * 1e3, MAX_LOCK_MS) : 0;
|
|
8454
|
-
if (!await users.countAttempt(account, { failedAttempts: failed, lockedUntil })) {
|
|
8455
|
-
return error(c, 429, "too_many_attempts", "Too many attempts at once. Please try again.");
|
|
8456
|
-
}
|
|
8989
|
+
const refused = await countSignInAttempt(c, users, account);
|
|
8990
|
+
if (refused) return refused;
|
|
8457
8991
|
const { ok, needsRehash } = await verifyPassword(pw, account.secret);
|
|
8458
8992
|
if (!ok) return wrong();
|
|
8459
8993
|
await users.updateAccount(account.id, {
|
|
@@ -8464,28 +8998,24 @@ function passwordRoutes(app2, ctx, password, users, webhook) {
|
|
|
8464
8998
|
if (!user.emailVerified) {
|
|
8465
8999
|
return error(c, 403, "email_unverified", "Please confirm your e-mail address first. We can send the e-mail again.");
|
|
8466
9000
|
}
|
|
8467
|
-
|
|
8468
|
-
await ctx.emit("user.signed_in", { user: result, method: "password" });
|
|
8469
|
-
return c.json({ user: result });
|
|
9001
|
+
return signInAnswer(c, await ctx.secondStep(c, user, ["pwd"], "password"));
|
|
8470
9002
|
});
|
|
8471
9003
|
app2.post("/auth/password/signup", async (c) => {
|
|
8472
9004
|
const data = await body(c);
|
|
8473
|
-
const target = redirectTarget(c, data.redirectTo);
|
|
9005
|
+
const target = redirectTarget(ctx, c, data.redirectTo);
|
|
8474
9006
|
if (target instanceof Response) return target;
|
|
8475
9007
|
if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
|
|
8476
9008
|
const policy = checkPasswordPolicy(data.password, minLength);
|
|
8477
9009
|
if (policy) return error(c, 400, "weak_password", policy);
|
|
8478
|
-
const name =
|
|
9010
|
+
const name = nameOf(data.name);
|
|
8479
9011
|
const email = data.email.trim();
|
|
8480
9012
|
const locale = localeOf(data.locale);
|
|
8481
9013
|
const check2 = await ctx.checkSignup({ email, name: name ?? void 0, locale, method: "password" });
|
|
8482
9014
|
if (!check2.ok) return check2.reason === "rejected" ? error(c, 403, "signup_rejected", check2.message) : unavailable(c);
|
|
8483
9015
|
const passwordHash = await hashPassword(data.password);
|
|
8484
9016
|
const existing = await users.findByEmail(normalizeEmail(email));
|
|
8485
|
-
if (existing?.emailVerified) {
|
|
8486
|
-
|
|
8487
|
-
const sent = await sendEmail(existing, "email.already_registered", { link: target.href, site: target.host, locale });
|
|
8488
|
-
return sent ? accepted(c) : unavailable(c);
|
|
9017
|
+
if (existing?.emailVerified && await users.hasAccounts(existing.id)) {
|
|
9018
|
+
return await emails.sendAccountEmail(existing, "email.already_registered", target, locale) ? accepted(c) : unavailable(c);
|
|
8489
9019
|
}
|
|
8490
9020
|
if (existing) {
|
|
8491
9021
|
if (Date.now() - existing.lastMailAt < MAIL_INTERVAL_MS) return accepted(c);
|
|
@@ -8493,7 +9023,7 @@ function passwordRoutes(app2, ctx, password, users, webhook) {
|
|
|
8493
9023
|
if (account) await users.updateAccount(account.id, { secret: passwordHash, failedAttempts: 0, lockedUntil: 0 });
|
|
8494
9024
|
else await users.linkAccount(existing.id, { key: passwordAccountKey(existing.id), secret: passwordHash });
|
|
8495
9025
|
await users.updateUser(existing.id, { name });
|
|
8496
|
-
return await sendLinkEmail({ ...existing, name }, "verify", target, locale) ? accepted(c) : unavailable(c);
|
|
9026
|
+
return await emails.sendLinkEmail({ ...existing, name }, "verify", target, locale) ? accepted(c) : unavailable(c);
|
|
8497
9027
|
}
|
|
8498
9028
|
const user = await users.createUser({
|
|
8499
9029
|
email,
|
|
@@ -8504,38 +9034,19 @@ function passwordRoutes(app2, ctx, password, users, webhook) {
|
|
|
8504
9034
|
});
|
|
8505
9035
|
if (!user) return accepted(c);
|
|
8506
9036
|
await ctx.emit("user.created", { user: toMadauthUser(user), method: "password" });
|
|
8507
|
-
return await sendLinkEmail(user, "verify", target, locale) ? accepted(c) : unavailable(c);
|
|
8508
|
-
});
|
|
8509
|
-
app2.post("/auth/password/send-verification", async (c) => {
|
|
8510
|
-
const data = await body(c);
|
|
8511
|
-
const target = redirectTarget(c, data.redirectTo);
|
|
8512
|
-
if (target instanceof Response) return target;
|
|
8513
|
-
if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
|
|
8514
|
-
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
8515
|
-
if (user && !user.emailVerified && await users.passwordAccount(user.id)) {
|
|
8516
|
-
if (!await sendLinkEmail(user, "verify", target, localeOf(data.locale))) return unavailable(c);
|
|
8517
|
-
}
|
|
8518
|
-
return accepted(c);
|
|
8519
|
-
});
|
|
8520
|
-
app2.post("/auth/password/verify-email", async (c) => {
|
|
8521
|
-
const consumed = await consume(await body(c), "verify");
|
|
8522
|
-
const { userId, via } = consumed;
|
|
8523
|
-
const user = userId ? await users.findById(userId) : null;
|
|
8524
|
-
if (!user) return invalidVerification(c, consumed);
|
|
8525
|
-
await users.updateUser(user.id, { emailVerified: true, wrongCodes: 0 });
|
|
8526
|
-
const result = await ctx.startSession(c, toMadauthUser(user), ["pwd"], { sv: user.sessionVersion });
|
|
8527
|
-
await ctx.emit("email.verified", { user: result, via });
|
|
8528
|
-
await ctx.emit("user.signed_in", { user: result, method: "password" });
|
|
8529
|
-
return c.json({ user: result });
|
|
9037
|
+
return await emails.sendLinkEmail(user, "verify", target, locale) ? accepted(c) : unavailable(c);
|
|
8530
9038
|
});
|
|
8531
9039
|
app2.post("/auth/password/send-reset", async (c) => {
|
|
8532
9040
|
const data = await body(c);
|
|
8533
|
-
const target = redirectTarget(c, data.redirectTo);
|
|
9041
|
+
const target = redirectTarget(ctx, c, data.redirectTo);
|
|
8534
9042
|
if (target instanceof Response) return target;
|
|
8535
9043
|
if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
|
|
8536
9044
|
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
8537
|
-
|
|
8538
|
-
|
|
9045
|
+
const locale = localeOf(data.locale);
|
|
9046
|
+
if (user && await users.passwordAccount(user.id)) {
|
|
9047
|
+
if (!await emails.sendLinkEmail(user, "reset", target, locale)) return unavailable(c);
|
|
9048
|
+
} else if (user) {
|
|
9049
|
+
if (!await emails.sendAccountEmail(user, "email.no_password", target, locale)) return unavailable(c);
|
|
8539
9050
|
}
|
|
8540
9051
|
return accepted(c);
|
|
8541
9052
|
});
|
|
@@ -8543,89 +9054,359 @@ function passwordRoutes(app2, ctx, password, users, webhook) {
|
|
|
8543
9054
|
const data = await body(c);
|
|
8544
9055
|
const policy = checkPasswordPolicy(data.password, minLength);
|
|
8545
9056
|
if (policy) return error(c, 400, "weak_password", policy);
|
|
8546
|
-
const consumed = await consume(data, "reset");
|
|
9057
|
+
const consumed = await emails.consume(data, "reset");
|
|
8547
9058
|
const user = consumed.userId ? await users.findById(consumed.userId) : null;
|
|
8548
|
-
|
|
9059
|
+
const account = user ? await users.passwordAccount(user.id) : null;
|
|
9060
|
+
if (!user || !account) return emails.invalidVerification(c, consumed);
|
|
8549
9061
|
const secret = await hashPassword(data.password);
|
|
8550
|
-
|
|
8551
|
-
if (account) await users.updateAccount(account.id, { secret, failedAttempts: 0, lockedUntil: 0 });
|
|
8552
|
-
else await users.linkAccount(user.id, { key: passwordAccountKey(user.id), secret });
|
|
9062
|
+
if (!await users.updateAccount(account.id, { secret, failedAttempts: 0, lockedUntil: 0 })) return emails.invalidVerification(c, consumed);
|
|
8553
9063
|
const sessionVersion = user.sessionVersion + 1;
|
|
8554
9064
|
await users.updateUser(user.id, { emailVerified: true, sessionVersion, wrongCodes: 0 });
|
|
8555
9065
|
await users.clearVerifications(user.id);
|
|
8556
|
-
const
|
|
8557
|
-
await ctx.emit("email.password_reset", { user:
|
|
8558
|
-
await ctx.
|
|
8559
|
-
return c.json({ user: result });
|
|
9066
|
+
const reset = { ...user, emailVerified: true, sessionVersion, wrongCodes: 0 };
|
|
9067
|
+
await ctx.emit("email.password_reset", { user: toMadauthUser(reset) });
|
|
9068
|
+
return signInAnswer(c, await ctx.secondStep(c, reset, ["pwd"], "password"));
|
|
8560
9069
|
});
|
|
8561
9070
|
}
|
|
8562
9071
|
|
|
8563
|
-
// src/
|
|
8564
|
-
|
|
8565
|
-
var
|
|
8566
|
-
|
|
8567
|
-
|
|
8568
|
-
|
|
8569
|
-
|
|
8570
|
-
|
|
8571
|
-
|
|
9072
|
+
// src/totp.ts
|
|
9073
|
+
import { createCipheriv, createDecipheriv, createHmac as createHmac3, hkdfSync as hkdfSync2, randomBytes as randomBytes4, randomInt as randomInt2 } from "crypto";
|
|
9074
|
+
var TOTP_STEP_SECONDS = 30;
|
|
9075
|
+
var TOTP_DIGITS = 6;
|
|
9076
|
+
var TOTP_WINDOW = 1;
|
|
9077
|
+
var RECOVERY_CODES = 10;
|
|
9078
|
+
var SECRET_BYTES = 20;
|
|
9079
|
+
var BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
9080
|
+
var RECOVERY_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
|
|
9081
|
+
var RECOVERY_CODE_LENGTH = 10;
|
|
9082
|
+
var ENCRYPTION_VERSION = "v1";
|
|
9083
|
+
var IV_BYTES = 12;
|
|
9084
|
+
var TAG_BYTES = 16;
|
|
9085
|
+
function base32Encode(bytes) {
|
|
9086
|
+
let bits = 0;
|
|
9087
|
+
let value = 0;
|
|
9088
|
+
let out = "";
|
|
9089
|
+
for (const byte of bytes) {
|
|
9090
|
+
value = (value << 8 | byte) & 65535;
|
|
9091
|
+
bits += 8;
|
|
9092
|
+
while (bits >= 5) {
|
|
9093
|
+
out += BASE32[value >>> bits - 5 & 31];
|
|
9094
|
+
bits -= 5;
|
|
9095
|
+
}
|
|
8572
9096
|
}
|
|
8573
|
-
|
|
9097
|
+
if (bits > 0) out += BASE32[value << 5 - bits & 31];
|
|
9098
|
+
return out;
|
|
8574
9099
|
}
|
|
8575
|
-
|
|
8576
|
-
|
|
8577
|
-
|
|
8578
|
-
|
|
9100
|
+
function hotp(secret, counter, digits = TOTP_DIGITS) {
|
|
9101
|
+
const message2 = Buffer.alloc(8);
|
|
9102
|
+
message2.writeBigUInt64BE(BigInt(counter));
|
|
9103
|
+
const digest2 = createHmac3("sha1", secret).update(message2).digest();
|
|
9104
|
+
const offset = digest2[digest2.length - 1] & 15;
|
|
9105
|
+
const binary = (digest2[offset] & 127) << 24 | digest2[offset + 1] << 16 | digest2[offset + 2] << 8 | digest2[offset + 3];
|
|
9106
|
+
return (binary % 10 ** digits).toString().padStart(digits, "0");
|
|
9107
|
+
}
|
|
9108
|
+
function stepOf(nowMs) {
|
|
9109
|
+
return Math.floor(nowMs / 1e3 / TOTP_STEP_SECONDS);
|
|
9110
|
+
}
|
|
9111
|
+
function totp(secret, step, digits = TOTP_DIGITS) {
|
|
9112
|
+
return hotp(secret, step, digits);
|
|
9113
|
+
}
|
|
9114
|
+
function verifyTotp(secret, code, nowMs, window = TOTP_WINDOW) {
|
|
9115
|
+
const given = code.replace(/\s/g, "");
|
|
9116
|
+
if (!new RegExp(`^\\d{${TOTP_DIGITS}}$`).test(given)) return null;
|
|
9117
|
+
const now = stepOf(nowMs);
|
|
9118
|
+
let found = null;
|
|
9119
|
+
for (let step = now - window; step <= now + window; step++) {
|
|
9120
|
+
if (safeEqual(totp(secret, step), given) && found === null) found = step;
|
|
9121
|
+
}
|
|
9122
|
+
return found;
|
|
9123
|
+
}
|
|
9124
|
+
function generateTotpSecret() {
|
|
9125
|
+
return randomBytes4(SECRET_BYTES);
|
|
9126
|
+
}
|
|
9127
|
+
function otpauthUri(options) {
|
|
9128
|
+
const issuer = encodeURIComponent(options.issuer);
|
|
9129
|
+
const account = encodeURIComponent(options.account);
|
|
9130
|
+
const secret = base32Encode(options.secret);
|
|
9131
|
+
return `otpauth://totp/${issuer}:${account}?secret=${secret}&issuer=${issuer}&algorithm=SHA1&digits=${TOTP_DIGITS}&period=${TOTP_STEP_SECONDS}`;
|
|
9132
|
+
}
|
|
9133
|
+
function deriveTotpKey(signingKeyD) {
|
|
9134
|
+
return Buffer.from(hkdfSync2("sha256", Buffer.from(signingKeyD, "base64url"), "", "madauth totp secret", 32));
|
|
9135
|
+
}
|
|
9136
|
+
function encryptSecret(key, secret) {
|
|
9137
|
+
const iv = randomBytes4(IV_BYTES);
|
|
9138
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
9139
|
+
const encrypted = Buffer.concat([cipher.update(secret), cipher.final()]);
|
|
9140
|
+
const parts = [iv, encrypted, cipher.getAuthTag()].map((part) => part.toString("base64url"));
|
|
9141
|
+
return [ENCRYPTION_VERSION, ...parts].join(".");
|
|
9142
|
+
}
|
|
9143
|
+
function decryptSecret(key, stored) {
|
|
9144
|
+
const [version, iv, encrypted, tag2, ...rest] = stored.split(".");
|
|
9145
|
+
if (version !== ENCRYPTION_VERSION || !iv || !encrypted || !tag2 || rest.length) return null;
|
|
9146
|
+
const ivBytes = Buffer.from(iv, "base64url");
|
|
9147
|
+
const tagBytes = Buffer.from(tag2, "base64url");
|
|
9148
|
+
if (ivBytes.length !== IV_BYTES || tagBytes.length !== TAG_BYTES) return null;
|
|
9149
|
+
try {
|
|
9150
|
+
const decipher = createDecipheriv("aes-256-gcm", key, ivBytes);
|
|
9151
|
+
decipher.setAuthTag(tagBytes);
|
|
9152
|
+
return Buffer.concat([decipher.update(Buffer.from(encrypted, "base64url")), decipher.final()]);
|
|
9153
|
+
} catch {
|
|
9154
|
+
return null;
|
|
8579
9155
|
}
|
|
8580
|
-
|
|
8581
|
-
|
|
8582
|
-
|
|
9156
|
+
}
|
|
9157
|
+
function generateRecoveryCodes() {
|
|
9158
|
+
const codes = /* @__PURE__ */ new Set();
|
|
9159
|
+
while (codes.size < RECOVERY_CODES) {
|
|
9160
|
+
let code = "";
|
|
9161
|
+
for (let i = 0; i < RECOVERY_CODE_LENGTH; i++) code += RECOVERY_ALPHABET[randomInt2(RECOVERY_ALPHABET.length)];
|
|
9162
|
+
codes.add(`${code.slice(0, 5)}-${code.slice(5)}`);
|
|
8583
9163
|
}
|
|
8584
|
-
|
|
8585
|
-
|
|
8586
|
-
|
|
8587
|
-
|
|
8588
|
-
|
|
8589
|
-
|
|
8590
|
-
|
|
8591
|
-
|
|
8592
|
-
|
|
9164
|
+
return [...codes];
|
|
9165
|
+
}
|
|
9166
|
+
function normalizeRecoveryCode(text) {
|
|
9167
|
+
if (typeof text !== "string") return null;
|
|
9168
|
+
const code = text.toLowerCase().replace(/[\s-]/g, "");
|
|
9169
|
+
return code.length === RECOVERY_CODE_LENGTH && [...code].every((char) => RECOVERY_ALPHABET.includes(char)) ? code : null;
|
|
9170
|
+
}
|
|
9171
|
+
function hashRecoveryCode(codeKey, code) {
|
|
9172
|
+
return hashCode(codeKey, `recovery:${code}`);
|
|
9173
|
+
}
|
|
9174
|
+
|
|
9175
|
+
// src/routes/totp.ts
|
|
9176
|
+
var CHALLENGE_COOKIE = "madauth_challenge";
|
|
9177
|
+
var CHALLENGE_TYP = "madauth-challenge+jwt";
|
|
9178
|
+
var CHALLENGE_TTL = 10 * 60;
|
|
9179
|
+
var SETUP_COOKIE = "madauth_totp_setup";
|
|
9180
|
+
var SETUP_TYP = "madauth-totp-setup+jwt";
|
|
9181
|
+
var SETUP_TTL = 10 * 60;
|
|
9182
|
+
var COOKIE_PATH = "/auth/totp";
|
|
9183
|
+
var cookieOptions = (secure) => ({ path: COOKIE_PATH, httpOnly: true, secure, sameSite: "Strict" });
|
|
9184
|
+
async function setChallenge(c, ctx, challenge) {
|
|
9185
|
+
const token = await signToken(await ctx.keys, ctx.config.issuer, CHALLENGE_TYP, { ...challenge }, CHALLENGE_TTL);
|
|
9186
|
+
setCookie(c, CHALLENGE_COOKIE, token, { ...cookieOptions(ctx.secure), maxAge: CHALLENGE_TTL });
|
|
9187
|
+
}
|
|
9188
|
+
function clearChallenge(c, secure) {
|
|
9189
|
+
deleteCookie(c, CHALLENGE_COOKIE, cookieOptions(secure));
|
|
9190
|
+
}
|
|
9191
|
+
async function readChallenge(c, ctx, next) {
|
|
9192
|
+
const claims = await readToken(await ctx.keys, ctx.config.issuer, CHALLENGE_TYP, getCookie(c, CHALLENGE_COOKIE));
|
|
9193
|
+
return claims && claims.next === next && typeof claims.sub === "string" && Array.isArray(claims.amr) ? claims : null;
|
|
9194
|
+
}
|
|
9195
|
+
async function checkCode(c, ctx, keys, user, account, data, wrong = () => error(c, 401, "code_invalid", "The code is wrong or was already used.")) {
|
|
9196
|
+
const { users } = ctx;
|
|
9197
|
+
const refused = await countSignInAttempt(c, users, account);
|
|
9198
|
+
if (refused) return refused;
|
|
9199
|
+
if (typeof data.recoveryCode === "string") {
|
|
9200
|
+
const code = normalizeRecoveryCode(data.recoveryCode);
|
|
9201
|
+
if (!code || !await users.consumeRecoveryCode(account, hashRecoveryCode(keys.codeKey, code))) return wrong();
|
|
9202
|
+
await users.updateAccount(account.id, { failedAttempts: 0, lockedUntil: 0 });
|
|
9203
|
+
const remaining = await users.countRecoveryCodes(account);
|
|
9204
|
+
await ctx.emit("totp.recovery_code_used", { user: toMadauthUser(user), remaining });
|
|
9205
|
+
return "recovery_code";
|
|
9206
|
+
}
|
|
9207
|
+
if (typeof data.code !== "string" || !account.secret) return wrong();
|
|
9208
|
+
const secret = decryptSecret(keys.totpKey, account.secret);
|
|
9209
|
+
if (!secret) {
|
|
9210
|
+
console.error(`[madauth] The authenticator secret of user ${user.id} can't be read: was MADAUTH_SIGNING_KEY changed?`);
|
|
9211
|
+
return wrong();
|
|
8593
9212
|
}
|
|
8594
|
-
|
|
8595
|
-
|
|
8596
|
-
|
|
8597
|
-
|
|
8598
|
-
|
|
8599
|
-
|
|
8600
|
-
|
|
8601
|
-
|
|
8602
|
-
|
|
8603
|
-
|
|
8604
|
-
|
|
8605
|
-
|
|
8606
|
-
const written = json === void 0 ? await this.store.create("setting", { id, ...patch }) : await this.store.update("setting", { id, value: json }, patch) === 1;
|
|
8607
|
-
if (written) return next;
|
|
9213
|
+
const step = verifyTotp(secret, data.code, Date.now(), TOTP_WINDOW);
|
|
9214
|
+
if (step === null || step <= (account.lastUsedStep ?? 0) || !await users.useTotpStep(account, step)) return wrong();
|
|
9215
|
+
return "totp";
|
|
9216
|
+
}
|
|
9217
|
+
function totpRoutes(app2, ctx, keys) {
|
|
9218
|
+
const { config, secure, users } = ctx;
|
|
9219
|
+
const { issuer } = config;
|
|
9220
|
+
const cookie = cookieOptions(secure);
|
|
9221
|
+
const methodsNow = (c) => ctx.methods(c);
|
|
9222
|
+
app2.use(`${COOKIE_PATH}/*`, async (c, next) => {
|
|
9223
|
+
if (!authenticatorOn(ctx.configured, await methodsNow(c))) {
|
|
9224
|
+
return error(c, 403, "method_disabled", "The authenticator app is switched off.");
|
|
8608
9225
|
}
|
|
8609
|
-
|
|
8610
|
-
}
|
|
8611
|
-
|
|
8612
|
-
|
|
8613
|
-
|
|
8614
|
-
|
|
8615
|
-
|
|
8616
|
-
|
|
8617
|
-
return
|
|
8618
|
-
}
|
|
8619
|
-
|
|
8620
|
-
|
|
8621
|
-
|
|
9226
|
+
await next();
|
|
9227
|
+
});
|
|
9228
|
+
const standalone = async (c) => isOn("totp", ctx.configured, await methodsNow(c));
|
|
9229
|
+
const notStandalone = (c) => error(c, 403, "method_disabled", "Signing in with the authenticator app alone is switched off.");
|
|
9230
|
+
const methodStillOn = async (c, challenge) => isOn(challenge.method, ctx.configured, await methodsNow(c));
|
|
9231
|
+
const methodOff = (c) => error(c, 403, "method_disabled", "This way of signing in is switched off.");
|
|
9232
|
+
const signedIn = async (c) => {
|
|
9233
|
+
const user = await ctx.currentUser(c);
|
|
9234
|
+
return user ? users.findById(user.id) : null;
|
|
9235
|
+
};
|
|
9236
|
+
const whoSetsUp = async (c) => {
|
|
9237
|
+
const challenge = await readChallenge(c, ctx, "totp-setup");
|
|
9238
|
+
const waiting = challenge ? await users.findById(challenge.sub) : null;
|
|
9239
|
+
if (challenge && waiting && waiting.sessionVersion === challenge.sv) return { user: waiting, challenge };
|
|
9240
|
+
const user = await signedIn(c);
|
|
9241
|
+
return user ? { user, challenge: null } : null;
|
|
9242
|
+
};
|
|
9243
|
+
const newRecoveryCodes = () => {
|
|
9244
|
+
const recoveryCodes = generateRecoveryCodes();
|
|
9245
|
+
return { recoveryCodes, hashes: recoveryCodes.map((code) => hashRecoveryCode(keys.codeKey, normalizeRecoveryCode(code))) };
|
|
9246
|
+
};
|
|
9247
|
+
const issuerLabel = (c) => {
|
|
9248
|
+
if (config.totpIssuer) return config.totpIssuer;
|
|
9249
|
+
const origin = c.req.header("origin");
|
|
9250
|
+
return URL.canParse(origin ?? "") ? new URL(origin).hostname : new URL(issuer).hostname;
|
|
9251
|
+
};
|
|
9252
|
+
const finishSignIn = async (c, user, challenge, via) => {
|
|
9253
|
+
const result = await ctx.startSession(c, toMadauthUser(user, challenge.profile), [...challenge.amr, "otp"], { sv: user.sessionVersion });
|
|
9254
|
+
await ctx.emit("user.signed_in", { user: result, method: challenge.method, ...challenge.amr.length ? { secondFactor: via } : {} });
|
|
9255
|
+
return result;
|
|
9256
|
+
};
|
|
9257
|
+
app2.post(`${COOKIE_PATH}/setup`, async (c) => {
|
|
9258
|
+
const who = await whoSetsUp(c);
|
|
9259
|
+
if (!who) return noSession(c);
|
|
9260
|
+
if (who.challenge && !await methodStillOn(c, who.challenge)) return methodOff(c);
|
|
9261
|
+
const existing = await users.totpAccount(who.user.id);
|
|
9262
|
+
let approved;
|
|
9263
|
+
if (existing) {
|
|
9264
|
+
const previous = await readToken(await ctx.keys, issuer, SETUP_TYP, getCookie(c, SETUP_COOKIE));
|
|
9265
|
+
const holds = previous?.sub === who.user.id && typeof previous.approved === "number" && Date.now() - previous.approved < SETUP_TTL * 1e3;
|
|
9266
|
+
if (holds) {
|
|
9267
|
+
approved = previous.approved;
|
|
9268
|
+
} else {
|
|
9269
|
+
const data = await body(c);
|
|
9270
|
+
if (data.code === void 0 && data.recoveryCode === void 0) {
|
|
9271
|
+
return error(c, 401, "code_required", "Enter a code from your current authenticator app (or a recovery code) to replace it.");
|
|
9272
|
+
}
|
|
9273
|
+
const via = await checkCode(c, ctx, keys, who.user, existing, data);
|
|
9274
|
+
if (via instanceof Response) return via;
|
|
9275
|
+
approved = Date.now();
|
|
9276
|
+
}
|
|
9277
|
+
}
|
|
9278
|
+
const secret = generateTotpSecret();
|
|
9279
|
+
const claims = {
|
|
9280
|
+
sub: who.user.id,
|
|
9281
|
+
setup: newId("tsu"),
|
|
9282
|
+
secret: encryptSecret(keys.totpKey, secret),
|
|
9283
|
+
...existing ? { replaces: existing.setupId } : {},
|
|
9284
|
+
...approved ? { approved } : {}
|
|
9285
|
+
};
|
|
9286
|
+
const token = await signToken(await ctx.keys, issuer, SETUP_TYP, claims, SETUP_TTL);
|
|
9287
|
+
setCookie(c, SETUP_COOKIE, token, { ...cookie, maxAge: SETUP_TTL });
|
|
9288
|
+
return c.json({ secret: base32Encode(secret), uri: otpauthUri({ issuer: issuerLabel(c), account: who.user.email, secret }) });
|
|
9289
|
+
});
|
|
9290
|
+
app2.post(`${COOKIE_PATH}/confirm`, async (c) => {
|
|
9291
|
+
const who = await whoSetsUp(c);
|
|
9292
|
+
if (!who) return noSession(c);
|
|
9293
|
+
const { user, challenge } = who;
|
|
9294
|
+
if (challenge && !await methodStillOn(c, challenge)) return methodOff(c);
|
|
9295
|
+
const data = await body(c);
|
|
9296
|
+
const setup = await readToken(await ctx.keys, issuer, SETUP_TYP, getCookie(c, SETUP_COOKIE));
|
|
9297
|
+
const secret = setup && setup.sub === user.id && typeof setup.secret === "string" && typeof setup.setup === "string" ? decryptSecret(keys.totpKey, setup.secret) : null;
|
|
9298
|
+
if (!setup || !secret) return error(c, 400, "setup_expired", "The setup has expired or was started in another browser. Please start again.");
|
|
9299
|
+
const step = typeof data.code === "string" ? verifyTotp(secret, data.code, Date.now(), TOTP_WINDOW) : null;
|
|
9300
|
+
if (step === null) return error(c, 400, "code_invalid", "The code is wrong. Enter the current code from the app.");
|
|
9301
|
+
const { recoveryCodes, hashes } = newRecoveryCodes();
|
|
9302
|
+
await users.addRecoveryCodes(user.id, setup.setup, hashes);
|
|
9303
|
+
const patch = { secret: setup.secret, setupId: setup.setup, failedAttempts: 0, lockedUntil: 0, lastUsedStep: step };
|
|
9304
|
+
const current = setup.replaces === void 0 ? null : await users.totpAccount(user.id);
|
|
9305
|
+
const written = setup.replaces === void 0 ? !!await users.linkAccount(user.id, { key: totpAccountKey(user.id), ...patch }) : !!current && await users.confirmTotpSetup(current.id, setup.replaces, patch);
|
|
9306
|
+
if (!written) {
|
|
9307
|
+
await users.clearRecoveryCodes(user.id, { only: setup.setup });
|
|
9308
|
+
deleteCookie(c, SETUP_COOKIE, cookie);
|
|
9309
|
+
return error(c, 400, "setup_expired", "Your authenticator app was set up or removed elsewhere meanwhile. Please start again.");
|
|
9310
|
+
}
|
|
9311
|
+
await users.clearRecoveryCodes(user.id, { except: setup.setup });
|
|
9312
|
+
deleteCookie(c, SETUP_COOKIE, cookie);
|
|
9313
|
+
await ctx.emit("totp.enabled", { user: toMadauthUser(user) });
|
|
9314
|
+
if (!challenge) return c.json({ recoveryCodes });
|
|
9315
|
+
const result = await finishSignIn(c, user, challenge, "totp");
|
|
9316
|
+
return c.json({ user: result, recoveryCodes });
|
|
9317
|
+
});
|
|
9318
|
+
app2.post(`${COOKIE_PATH}/verify`, async (c) => {
|
|
9319
|
+
const expired = () => error(c, 401, "challenge_expired", "The sign-in has expired. Please sign in again.");
|
|
9320
|
+
const challenge = await readChallenge(c, ctx, "totp");
|
|
9321
|
+
if (!challenge) return expired();
|
|
9322
|
+
if (!await methodStillOn(c, challenge)) return methodOff(c);
|
|
9323
|
+
const user = await users.findById(challenge.sub);
|
|
9324
|
+
const account = user && user.sessionVersion === challenge.sv ? await users.totpAccount(user.id) : null;
|
|
9325
|
+
if (!user || !account) return expired();
|
|
9326
|
+
const via = await checkCode(c, ctx, keys, user, account, await body(c));
|
|
9327
|
+
if (via instanceof Response) return via;
|
|
9328
|
+
return c.json({ user: await finishSignIn(c, user, challenge, via) });
|
|
9329
|
+
});
|
|
9330
|
+
app2.post(`${COOKIE_PATH}/signin`, async (c) => {
|
|
9331
|
+
if (!await standalone(c)) return notStandalone(c);
|
|
9332
|
+
const data = await body(c);
|
|
9333
|
+
const wrong = () => error(c, 401, "invalid_credentials", "E-mail or code is wrong.");
|
|
9334
|
+
if (!isValidEmail(data.email)) return wrong();
|
|
9335
|
+
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
9336
|
+
const account = user ? await users.totpAccount(user.id) : null;
|
|
9337
|
+
if (!user || !user.emailVerified || !account?.secret) return wrong();
|
|
9338
|
+
const via = await checkCode(c, ctx, keys, user, account, data, wrong);
|
|
9339
|
+
if (via instanceof Response) return via;
|
|
9340
|
+
const result = await ctx.startSession(c, toMadauthUser(user), ["otp"], { sv: user.sessionVersion });
|
|
9341
|
+
await ctx.emit("user.signed_in", { user: result, method: "totp" });
|
|
9342
|
+
return c.json({ user: result });
|
|
9343
|
+
});
|
|
9344
|
+
app2.post(`${COOKIE_PATH}/remove`, async (c) => {
|
|
9345
|
+
const user = await signedIn(c);
|
|
9346
|
+
if (!user) return noSession(c);
|
|
9347
|
+
const methods = await methodsNow(c);
|
|
9348
|
+
const required = METHODS_WITH_SECOND_FACTOR.filter((method) => isOn(method, ctx.configured, methods) && policyOf(method, methods) === "required");
|
|
9349
|
+
if (required.length) {
|
|
9350
|
+
const names = required.map((method) => method === "google" ? "Google" : "e-mail & password");
|
|
9351
|
+
return error(c, 403, "required_by_policy", `Signing in with ${names.join(" and ")} requires the authenticator app, so it can't be removed.`);
|
|
9352
|
+
}
|
|
9353
|
+
const account = await users.totpAccount(user.id);
|
|
9354
|
+
if (!account) return c.json({});
|
|
9355
|
+
if (!(await users.signInMethods(user.id, methods)).some((method) => method !== "totp" && isOn(method, ctx.configured, methods))) {
|
|
9356
|
+
return error(c, 403, "only_method", "The authenticator app is the only way you sign in, so it can't be removed. Delete your account instead.");
|
|
9357
|
+
}
|
|
9358
|
+
const via = await checkCode(c, ctx, keys, user, account, await body(c));
|
|
9359
|
+
if (via instanceof Response) return via;
|
|
9360
|
+
await users.removeTotp(user.id);
|
|
9361
|
+
await ctx.emit("totp.disabled", { user: toMadauthUser(user) });
|
|
9362
|
+
return c.json({});
|
|
9363
|
+
});
|
|
9364
|
+
app2.post(`${COOKIE_PATH}/recovery-codes`, async (c) => {
|
|
9365
|
+
const user = await signedIn(c);
|
|
9366
|
+
if (!user) return noSession(c);
|
|
9367
|
+
const account = await users.totpAccount(user.id);
|
|
9368
|
+
if (!account) return error(c, 400, "no_authenticator", "No authenticator app is set up.");
|
|
9369
|
+
const via = await checkCode(c, ctx, keys, user, account, await body(c));
|
|
9370
|
+
if (via instanceof Response) return via;
|
|
9371
|
+
const { recoveryCodes, hashes } = newRecoveryCodes();
|
|
9372
|
+
await users.replaceRecoveryCodes(account, hashes);
|
|
9373
|
+
return c.json({ recoveryCodes });
|
|
9374
|
+
});
|
|
9375
|
+
app2.get(`${COOKIE_PATH}/status`, async (c) => {
|
|
9376
|
+
const user = await signedIn(c);
|
|
9377
|
+
if (!user) return noSession(c);
|
|
9378
|
+
const account = await users.totpAccount(user.id);
|
|
9379
|
+
return c.json({ enabled: !!account, recoveryCodesLeft: account ? await users.countRecoveryCodes(account) : 0 });
|
|
9380
|
+
});
|
|
9381
|
+
if (ctx.webhook && ctx.emailVerification) {
|
|
9382
|
+
const emails = createEmails(ctx, users, ctx.webhook);
|
|
9383
|
+
app2.post(`${COOKIE_PATH}/signup`, async (c) => {
|
|
9384
|
+
if (!await standalone(c)) return notStandalone(c);
|
|
9385
|
+
const data = await body(c);
|
|
9386
|
+
const target = redirectTarget(ctx, c, data.redirectTo);
|
|
9387
|
+
if (target instanceof Response) return target;
|
|
9388
|
+
if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
|
|
9389
|
+
const name = nameOf(data.name);
|
|
9390
|
+
const email = data.email.trim();
|
|
9391
|
+
const locale = localeOf(data.locale);
|
|
9392
|
+
const check2 = await ctx.checkSignup({ email, name: name ?? void 0, locale, method: "totp" });
|
|
9393
|
+
if (!check2.ok) return check2.reason === "rejected" ? error(c, 403, "signup_rejected", check2.message) : unavailable(c);
|
|
9394
|
+
const existing = await users.findByEmail(normalizeEmail(email));
|
|
9395
|
+
if (existing?.emailVerified && await users.hasAccounts(existing.id)) {
|
|
9396
|
+
return await emails.sendAccountEmail(existing, "email.already_registered", target, locale) ? accepted(c) : unavailable(c);
|
|
9397
|
+
}
|
|
9398
|
+
if (existing) {
|
|
9399
|
+
if (Date.now() - existing.lastMailAt < MAIL_INTERVAL_MS) return accepted(c);
|
|
9400
|
+
if (!existing.emailVerified) await users.unlinkAccount(passwordAccountKey(existing.id));
|
|
9401
|
+
await users.updateUser(existing.id, { name });
|
|
9402
|
+
return await emails.sendLinkEmail({ ...existing, name }, "verify", target, locale) ? accepted(c) : unavailable(c);
|
|
9403
|
+
}
|
|
9404
|
+
const user = await users.createUser({ email, emailNormalized: normalizeEmail(email), name, emailVerified: false });
|
|
9405
|
+
if (!user) return accepted(c);
|
|
9406
|
+
await ctx.emit("user.created", { user: toMadauthUser(user), method: "totp" });
|
|
9407
|
+
return await emails.sendLinkEmail(user, "verify", target, locale) ? accepted(c) : unavailable(c);
|
|
9408
|
+
});
|
|
8622
9409
|
}
|
|
8623
|
-
};
|
|
8624
|
-
function isOn(method, available, methods) {
|
|
8625
|
-
return available(method) && methods[method] !== false;
|
|
8626
|
-
}
|
|
8627
|
-
function anyOn(available, methods) {
|
|
8628
|
-
return SIGN_IN_METHODS.some((method) => isOn(method, available, methods));
|
|
8629
9410
|
}
|
|
8630
9411
|
|
|
8631
9412
|
// src/app.ts
|
|
@@ -8635,9 +9416,14 @@ function createApp(config) {
|
|
|
8635
9416
|
const keys = importSigningKeys(config.signingKey);
|
|
8636
9417
|
const secure = issuer.startsWith("https:");
|
|
8637
9418
|
const isAllowedOrigin = (origin) => !!origin && allowedOrigins.includes(origin);
|
|
8638
|
-
const
|
|
8639
|
-
const
|
|
8640
|
-
const
|
|
9419
|
+
const codeKey = deriveCodeKey(config.signingKey.d);
|
|
9420
|
+
const users = new Users(config.store, codeKey);
|
|
9421
|
+
const methods = methodConfig(config);
|
|
9422
|
+
const settings = new Settings(config.store, initialMethods(methods));
|
|
9423
|
+
const configured = (method) => isConfigured(methods, method);
|
|
9424
|
+
const signsUp = (method) => canSignUp(methods, method);
|
|
9425
|
+
const emailVerification = sendsVerificationEmails(config);
|
|
9426
|
+
const totpKeys = { totpKey: deriveTotpKey(config.signingKey.d), codeKey };
|
|
8641
9427
|
const issueSession = async (c, user, amr, extra) => {
|
|
8642
9428
|
const signingKeys = await keys;
|
|
8643
9429
|
const [sessionToken, renewalToken] = await Promise.all([
|
|
@@ -8672,6 +9458,29 @@ function createApp(config) {
|
|
|
8672
9458
|
deleteCookie(c, RENEWAL_COOKIE, { path: "/auth", secure });
|
|
8673
9459
|
deleteCookie(c, EXPIRY_COOKIE, { path: "/", domain: cookieDomain, secure });
|
|
8674
9460
|
};
|
|
9461
|
+
const tokenClaims = async (c, cookie, typ) => readToken(await keys, issuer, typ, getCookie(c, cookie));
|
|
9462
|
+
const checked = async (claims) => {
|
|
9463
|
+
const stored = await users.findById(claims.sub);
|
|
9464
|
+
if (!stored || stored.sessionVersion !== claims.sv) return null;
|
|
9465
|
+
const { claims: _, ...user } = userFromClaims(claims);
|
|
9466
|
+
const current = claimsFromJson(stored.claims);
|
|
9467
|
+
return Object.keys(current).length ? { ...user, claims: current } : user;
|
|
9468
|
+
};
|
|
9469
|
+
const currentSession = async (c) => {
|
|
9470
|
+
const session = await tokenClaims(c, SESSION_COOKIE, SESSION_TYP);
|
|
9471
|
+
const user = session ? await checked(session) : null;
|
|
9472
|
+
const upToDate = session && user && sameClaims(user.claims, session.claims) && Date.now() / 1e3 - session.iat <= sessionTtlSeconds / 2;
|
|
9473
|
+
if (upToDate) return user;
|
|
9474
|
+
const renewal = await tokenClaims(c, RENEWAL_COOKIE, RENEWAL_TYP);
|
|
9475
|
+
const sameSignIn = session && user && renewal && renewal.sub === session.sub && renewal.sv === session.sv && renewal.email === session.email;
|
|
9476
|
+
const renewed = sameSignIn ? user : renewal ? await checked(renewal) : null;
|
|
9477
|
+
if (renewal && renewed) {
|
|
9478
|
+
await issueSession(c, renewed, renewal.amr, { sv: renewal.sv });
|
|
9479
|
+
return renewed;
|
|
9480
|
+
}
|
|
9481
|
+
return user;
|
|
9482
|
+
};
|
|
9483
|
+
const methodsOf = /* @__PURE__ */ new WeakMap();
|
|
8675
9484
|
const ctx = {
|
|
8676
9485
|
config,
|
|
8677
9486
|
keys,
|
|
@@ -8689,14 +9498,42 @@ function createApp(config) {
|
|
|
8689
9498
|
return url;
|
|
8690
9499
|
},
|
|
8691
9500
|
async startSession(c, user, amr, extra) {
|
|
9501
|
+
clearChallenge(c, secure);
|
|
8692
9502
|
await issueSession(c, user, amr, extra);
|
|
8693
|
-
return user;
|
|
9503
|
+
return { ...user, amr };
|
|
9504
|
+
},
|
|
9505
|
+
async secondStep(c, user, amr, method, profile) {
|
|
9506
|
+
const methods2 = await this.methods(c);
|
|
9507
|
+
if (!isOn(method, configured, methods2)) return { disabled: true };
|
|
9508
|
+
const enrolled = await users.totpAccount(user.id);
|
|
9509
|
+
const policy = method === "totp" ? "required" : policyOf(method, methods2);
|
|
9510
|
+
const next = policy === "none" ? null : enrolled ? "totp" : policy === "required" ? "totp-setup" : null;
|
|
9511
|
+
if (next) {
|
|
9512
|
+
clearSession(c);
|
|
9513
|
+
const known = { ...profile?.name ? { name: profile.name } : {}, ...profile?.picture ? { picture: profile.picture } : {} };
|
|
9514
|
+
await setChallenge(c, this, { sub: user.id, sv: user.sessionVersion, amr, method, next, ...Object.keys(known).length ? { profile: known } : {} });
|
|
9515
|
+
return { next, method };
|
|
9516
|
+
}
|
|
9517
|
+
const result = await this.startSession(c, toMadauthUser(user, profile), amr, { sv: user.sessionVersion });
|
|
9518
|
+
await this.emit("user.signed_in", { user: result, method });
|
|
9519
|
+
return { user: result };
|
|
8694
9520
|
},
|
|
9521
|
+
currentUser: currentSession,
|
|
8695
9522
|
users,
|
|
8696
9523
|
settings,
|
|
8697
9524
|
webhook: config.webhook ? createWebhookClient(config.webhook, config.webhookFetch) : void 0,
|
|
8698
|
-
|
|
8699
|
-
|
|
9525
|
+
configured,
|
|
9526
|
+
emailVerification,
|
|
9527
|
+
methods(c) {
|
|
9528
|
+
let reading = methodsOf.get(c.req.raw);
|
|
9529
|
+
if (!reading) {
|
|
9530
|
+
reading = settings.methods();
|
|
9531
|
+
methodsOf.set(c.req.raw, reading);
|
|
9532
|
+
}
|
|
9533
|
+
return reading;
|
|
9534
|
+
},
|
|
9535
|
+
async enabled(c, method) {
|
|
9536
|
+
return isOn(method, configured, await this.methods(c));
|
|
8700
9537
|
},
|
|
8701
9538
|
async checkSignup(data) {
|
|
8702
9539
|
if (!this.webhook?.wants("signup.before")) return { ok: true };
|
|
@@ -8714,7 +9551,9 @@ function createApp(config) {
|
|
|
8714
9551
|
},
|
|
8715
9552
|
async emit(type, data) {
|
|
8716
9553
|
if (!this.webhook?.wants(type)) return;
|
|
8717
|
-
const
|
|
9554
|
+
const user = data.user;
|
|
9555
|
+
const payload = user && typeof user === "object" && "amr" in user ? { ...data, user: { ...user, amr: void 0 } } : data;
|
|
9556
|
+
const result = await this.webhook.call(type, payload, EVENT_TIMEOUT_MS);
|
|
8718
9557
|
if (!result.ok) console.error(`[madauth] Webhook "${type}" failed: ${result.reason}`);
|
|
8719
9558
|
}
|
|
8720
9559
|
};
|
|
@@ -8742,37 +9581,20 @@ function createApp(config) {
|
|
|
8742
9581
|
return c.json({ keys: [publicJwk] });
|
|
8743
9582
|
});
|
|
8744
9583
|
app2.get("/auth/config", async (c) => {
|
|
8745
|
-
const
|
|
8746
|
-
const on = (method) => isOn(method,
|
|
9584
|
+
const current = await ctx.methods(c);
|
|
9585
|
+
const on = (method) => isOn(method, configured, current);
|
|
8747
9586
|
return c.json({
|
|
8748
|
-
google: on("google") ? { clientId: config.google.clientId, codeFlow: !!config.google.clientSecret } : null,
|
|
8749
|
-
password: on("password") ? { minLength: config.password.minLength } : null
|
|
9587
|
+
google: on("google") ? { clientId: config.google.clientId, codeFlow: !!config.google.clientSecret, secondFactor: policyOf("google", current) } : null,
|
|
9588
|
+
password: on("password") ? { minLength: config.password.minLength, secondFactor: policyOf("password", current) } : null,
|
|
9589
|
+
// The app can be set up when it is in use at all; `signIn` says whether it signs in on its own.
|
|
9590
|
+
totp: authenticatorOn(configured, current) ? { signIn: on("totp"), signUp: on("totp") && emailVerification } : null,
|
|
9591
|
+
email: { verification: emailVerification }
|
|
8750
9592
|
});
|
|
8751
9593
|
});
|
|
9594
|
+
if (emailVerification && ctx.webhook) emailRoutes(app2, ctx, users, ctx.webhook);
|
|
8752
9595
|
if (config.google) googleRoutes(app2, ctx, config.google);
|
|
8753
9596
|
if (config.password && ctx.webhook) passwordRoutes(app2, ctx, config.password, users, ctx.webhook);
|
|
8754
|
-
|
|
8755
|
-
const checked = async (claims) => {
|
|
8756
|
-
const stored = await users.findById(claims.sub);
|
|
8757
|
-
if (!stored || stored.sessionVersion !== claims.sv) return null;
|
|
8758
|
-
const { claims: _, ...user } = userFromClaims(claims);
|
|
8759
|
-
const current = claimsFromJson(stored.claims);
|
|
8760
|
-
return Object.keys(current).length ? { ...user, claims: current } : user;
|
|
8761
|
-
};
|
|
8762
|
-
const currentSession = async (c) => {
|
|
8763
|
-
const session = await tokenClaims(c, SESSION_COOKIE, SESSION_TYP);
|
|
8764
|
-
const user = session ? await checked(session) : null;
|
|
8765
|
-
const upToDate = session && user && sameClaims(user.claims, session.claims) && Date.now() / 1e3 - session.iat <= sessionTtlSeconds / 2;
|
|
8766
|
-
if (upToDate) return user;
|
|
8767
|
-
const renewal = await tokenClaims(c, RENEWAL_COOKIE, RENEWAL_TYP);
|
|
8768
|
-
const sameSignIn = session && user && renewal && renewal.sub === session.sub && renewal.sv === session.sv && renewal.email === session.email;
|
|
8769
|
-
const renewed = sameSignIn ? user : renewal ? await checked(renewal) : null;
|
|
8770
|
-
if (renewal && renewed) {
|
|
8771
|
-
await issueSession(c, renewed, renewal.amr, { sv: renewal.sv });
|
|
8772
|
-
return renewed;
|
|
8773
|
-
}
|
|
8774
|
-
return user;
|
|
8775
|
-
};
|
|
9597
|
+
totpRoutes(app2, ctx, totpKeys);
|
|
8776
9598
|
app2.get("/auth/session", async (c) => {
|
|
8777
9599
|
const user = await currentSession(c);
|
|
8778
9600
|
if (!user) {
|
|
@@ -8783,11 +9605,23 @@ function createApp(config) {
|
|
|
8783
9605
|
});
|
|
8784
9606
|
app2.post("/auth/logout", (c) => {
|
|
8785
9607
|
clearSession(c);
|
|
9608
|
+
clearChallenge(c, secure);
|
|
8786
9609
|
return c.body(null, 204);
|
|
8787
9610
|
});
|
|
8788
9611
|
app2.post("/auth/account/delete", async (c) => {
|
|
8789
9612
|
const user = await currentSession(c);
|
|
8790
9613
|
if (!user) return c.json({ error: "no_session" }, 401);
|
|
9614
|
+
const stored = await users.findById(user.id);
|
|
9615
|
+
const protects = stored && authenticatorOn(configured, await ctx.methods(c));
|
|
9616
|
+
const authenticator = protects ? await users.totpAccount(user.id) : null;
|
|
9617
|
+
if (stored && authenticator) {
|
|
9618
|
+
const data = await body(c);
|
|
9619
|
+
if (data.code === void 0 && data.recoveryCode === void 0) {
|
|
9620
|
+
return error(c, 401, "code_required", "Enter a code from your authenticator app (or a recovery code) to delete your account.");
|
|
9621
|
+
}
|
|
9622
|
+
const via = await checkCode(c, ctx, totpKeys, stored, authenticator, data);
|
|
9623
|
+
if (via instanceof Response) return via;
|
|
9624
|
+
}
|
|
8791
9625
|
await users.deleteById(user.id);
|
|
8792
9626
|
clearSession(c);
|
|
8793
9627
|
await ctx.emit("user.deleted", { user });
|
|
@@ -8803,10 +9637,6 @@ function createApp(config) {
|
|
|
8803
9637
|
}
|
|
8804
9638
|
return { email };
|
|
8805
9639
|
};
|
|
8806
|
-
const body = async (c) => {
|
|
8807
|
-
const data = await c.req.json().catch(() => null);
|
|
8808
|
-
return data && typeof data === "object" ? data : {};
|
|
8809
|
-
};
|
|
8810
9640
|
const invalidEmail = (c) => c.json({ error: "invalid_email", message: "This is not a valid e-mail address." }, 400);
|
|
8811
9641
|
const noUser = (c) => c.json({ error: "user_not_found", message: "No user has this e-mail address." }, 404);
|
|
8812
9642
|
app2.post("/auth/admin/claims/get", async (c) => {
|
|
@@ -8840,9 +9670,16 @@ function createApp(config) {
|
|
|
8840
9670
|
await ctx.emit("user.claims_changed", { userId: user.id, email, claims, by: caller.email });
|
|
8841
9671
|
return c.json({ email, userId: user.id, claims });
|
|
8842
9672
|
});
|
|
8843
|
-
const settingsAnswer = (
|
|
9673
|
+
const settingsAnswer = (methods2) => ({
|
|
8844
9674
|
methods: Object.fromEntries(
|
|
8845
|
-
SIGN_IN_METHODS.map((method) => [
|
|
9675
|
+
SIGN_IN_METHODS.map((method) => [
|
|
9676
|
+
method,
|
|
9677
|
+
{
|
|
9678
|
+
configured: configured(method),
|
|
9679
|
+
enabled: isOn(method, configured, methods2),
|
|
9680
|
+
...method === "totp" ? {} : { secondFactor: policyOf(method, methods2) }
|
|
9681
|
+
}
|
|
9682
|
+
])
|
|
8846
9683
|
)
|
|
8847
9684
|
});
|
|
8848
9685
|
app2.post("/auth/admin/settings/get", async (c) => {
|
|
@@ -8855,212 +9692,30 @@ function createApp(config) {
|
|
|
8855
9692
|
if ("answer" in caller) return caller.answer;
|
|
8856
9693
|
const data = await body(c);
|
|
8857
9694
|
const invalid2 = (message2) => c.json({ error: "invalid_settings", message: message2 }, 400);
|
|
8858
|
-
const
|
|
8859
|
-
if (!
|
|
8860
|
-
|
|
8861
|
-
|
|
8862
|
-
|
|
8863
|
-
}
|
|
8864
|
-
|
|
8865
|
-
return
|
|
9695
|
+
const next = parseMethodSettings(data.methods, { booleans: true });
|
|
9696
|
+
if (!next) {
|
|
9697
|
+
return invalid2(
|
|
9698
|
+
`methods must list the sign-in methods that are on (${SIGN_IN_METHODS.join(", ")}), each as true, {} or { "secondFactor": "none" | "optional" | "required" } (google and password), e.g. { "google": {}, "password": { "secondFactor": "optional" } }. A method that is not listed is off.`
|
|
9699
|
+
);
|
|
9700
|
+
}
|
|
9701
|
+
const methods2 = await settings.changeMethods(() => anyOn(signsUp, next) ? next : null, caller.email);
|
|
9702
|
+
if (!methods2) return invalid2("At least one sign-in method that creates users must stay on: Google, e-mail & password, or the authenticator app with sign-up by e-mail.");
|
|
9703
|
+
return c.json(settingsAnswer(methods2));
|
|
9704
|
+
});
|
|
9705
|
+
app2.post("/auth/admin/totp/remove", async (c) => {
|
|
9706
|
+
const caller = await admin(c);
|
|
9707
|
+
if ("answer" in caller) return caller.answer;
|
|
9708
|
+
const data = await body(c);
|
|
9709
|
+
if (!isValidEmail(data.email)) return invalidEmail(c);
|
|
9710
|
+
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
9711
|
+
if (!user) return noUser(c);
|
|
9712
|
+
const removed = await users.removeTotp(user.id);
|
|
9713
|
+
if (removed) await ctx.emit("totp.disabled", { user: toMadauthUser(user), by: caller.email });
|
|
9714
|
+
return c.json({ email: user.emailNormalized, userId: user.id, enabled: false });
|
|
8866
9715
|
});
|
|
8867
9716
|
return app2;
|
|
8868
9717
|
}
|
|
8869
9718
|
|
|
8870
|
-
// src/config.ts
|
|
8871
|
-
async function resolveEnv(source = process.env) {
|
|
8872
|
-
return typeof source === "function" ? source() : source;
|
|
8873
|
-
}
|
|
8874
|
-
var DEFAULT_SESSION_TTL = 8 * 60 * 60;
|
|
8875
|
-
var DEFAULT_RENEWAL_TTL = 30 * 24 * 60 * 60;
|
|
8876
|
-
var DEFAULT_PASSWORD_MIN_LENGTH = 8;
|
|
8877
|
-
var ConfigError = class extends Error {
|
|
8878
|
-
constructor() {
|
|
8879
|
-
super(...arguments);
|
|
8880
|
-
this.name = "ConfigError";
|
|
8881
|
-
}
|
|
8882
|
-
};
|
|
8883
|
-
function parseOrigin(name, value) {
|
|
8884
|
-
let url;
|
|
8885
|
-
try {
|
|
8886
|
-
url = new URL(value);
|
|
8887
|
-
} catch {
|
|
8888
|
-
throw new ConfigError(`${name}: "${value}" is not a URL.`);
|
|
8889
|
-
}
|
|
8890
|
-
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
8891
|
-
throw new ConfigError(`${name}: "${value}" must be an http(s) URL.`);
|
|
8892
|
-
}
|
|
8893
|
-
return url.origin;
|
|
8894
|
-
}
|
|
8895
|
-
function readEnv(env, name) {
|
|
8896
|
-
return env[name]?.trim().replace(/^(['"])(.*)\1$/s, "$2").trim() || void 0;
|
|
8897
|
-
}
|
|
8898
|
-
async function loadConfig(env, overrides = {}) {
|
|
8899
|
-
const read = (name) => readEnv(env, name);
|
|
8900
|
-
const require2 = (name) => {
|
|
8901
|
-
const value = read(name);
|
|
8902
|
-
if (!value) throw new ConfigError(`${name} is not set. See docs/server.md.`);
|
|
8903
|
-
return value;
|
|
8904
|
-
};
|
|
8905
|
-
const issuerValue = require2("MADAUTH_ISSUER");
|
|
8906
|
-
parseOrigin("MADAUTH_ISSUER", issuerValue);
|
|
8907
|
-
const issuer = issuerValue.replace(/\/+$/, "");
|
|
8908
|
-
const keyValue = read("MADAUTH_SIGNING_KEY");
|
|
8909
|
-
if (!keyValue) {
|
|
8910
|
-
const key = await generateSigningKey();
|
|
8911
|
-
throw new ConfigError(
|
|
8912
|
-
`MADAUTH_SIGNING_KEY is not set. Here is a newly generated key you can use (keep it secret and use the same key on every instance):
|
|
8913
|
-
|
|
8914
|
-
MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
|
|
8915
|
-
`
|
|
8916
|
-
);
|
|
8917
|
-
}
|
|
8918
|
-
let signingKey;
|
|
8919
|
-
try {
|
|
8920
|
-
signingKey = JSON.parse(keyValue);
|
|
8921
|
-
assertPrivateSigningJwk(signingKey);
|
|
8922
|
-
} catch (e) {
|
|
8923
|
-
const reason = e instanceof SyntaxError ? "is not valid JSON" : e.message;
|
|
8924
|
-
throw new ConfigError(`MADAUTH_SIGNING_KEY ${reason}. Generate one with: npx @madauth/server generate-key`);
|
|
8925
|
-
}
|
|
8926
|
-
const allowedOrigins = require2("ALLOWED_ORIGINS").split(",").map((o) => o.trim()).filter(Boolean).map((o) => parseOrigin("ALLOWED_ORIGINS", o));
|
|
8927
|
-
const clientId = read("GOOGLE_CLIENT_ID");
|
|
8928
|
-
if (clientId && !clientId.endsWith(".apps.googleusercontent.com")) {
|
|
8929
|
-
throw new ConfigError(`GOOGLE_CLIENT_ID must end with ".apps.googleusercontent.com" but is "${clientId}".`);
|
|
8930
|
-
}
|
|
8931
|
-
const webhook = webhookFromEnv(read("WEBHOOK_URL"), read("WEBHOOK_SECRET"), read("WEBHOOK_EVENTS"));
|
|
8932
|
-
const sendsEmails = !!webhook && REQUIRED_EMAIL_TYPES.every((type) => webhook.events.has(type));
|
|
8933
|
-
const password = sendsEmails ? { minLength: passwordMinLength(read("PASSWORD_MIN_LENGTH")) } : void 0;
|
|
8934
|
-
if (!clientId && !password) {
|
|
8935
|
-
throw new ConfigError(
|
|
8936
|
-
`No sign-in method is configured. Set GOOGLE_CLIENT_ID for Google sign-in, and/or a webhook that sends the e-mails for e-mail & password sign-in: for development, run the receiver at ${DEV_WEBHOOK_RECEIVER_URL} and set WEBHOOK_URL=http://localhost:8790/webhook and WEBHOOK_EVENTS=${REQUIRED_EMAIL_TYPES.join(",")},email.already_registered. See docs/server.md.`
|
|
8937
|
-
);
|
|
8938
|
-
}
|
|
8939
|
-
const store = overrides.store ?? await storeFromDatabaseUrl(read("DATABASE_URL"));
|
|
8940
|
-
if (!store) {
|
|
8941
|
-
throw new ConfigError(
|
|
8942
|
-
"DATABASE_URL is not set. madAuth stores every user, whichever way they sign in: set e.g. DATABASE_URL=sqlite:./madauth.db or DATABASE_URL=dynamodb:<table>. See docs/server.md."
|
|
8943
|
-
);
|
|
8944
|
-
}
|
|
8945
|
-
const ttlValue = read("SESSION_TTL");
|
|
8946
|
-
const sessionTtlSeconds = ttlValue ? Number(ttlValue) : DEFAULT_SESSION_TTL;
|
|
8947
|
-
if (!Number.isInteger(sessionTtlSeconds) || sessionTtlSeconds < 60) {
|
|
8948
|
-
throw new ConfigError(`SESSION_TTL must be a whole number of seconds (at least 60) but is "${ttlValue}".`);
|
|
8949
|
-
}
|
|
8950
|
-
const renewalValue = read("SESSION_RENEWAL_TTL");
|
|
8951
|
-
const renewalTtlSeconds = renewalValue ? Number(renewalValue) : Math.max(DEFAULT_RENEWAL_TTL, sessionTtlSeconds);
|
|
8952
|
-
if (!Number.isInteger(renewalTtlSeconds) || renewalTtlSeconds < sessionTtlSeconds) {
|
|
8953
|
-
throw new ConfigError(
|
|
8954
|
-
`SESSION_RENEWAL_TTL must be a whole number of seconds, not less than SESSION_TTL (${sessionTtlSeconds}), but is "${renewalValue}".`
|
|
8955
|
-
);
|
|
8956
|
-
}
|
|
8957
|
-
return {
|
|
8958
|
-
issuer,
|
|
8959
|
-
signingKey,
|
|
8960
|
-
allowedOrigins,
|
|
8961
|
-
sessionTtlSeconds,
|
|
8962
|
-
renewalTtlSeconds,
|
|
8963
|
-
cookieDomain: read("COOKIE_DOMAIN"),
|
|
8964
|
-
google: clientId ? { clientId, clientSecret: read("GOOGLE_CLIENT_SECRET") } : void 0,
|
|
8965
|
-
store,
|
|
8966
|
-
password,
|
|
8967
|
-
webhook
|
|
8968
|
-
};
|
|
8969
|
-
}
|
|
8970
|
-
function passwordMinLength(value) {
|
|
8971
|
-
const minLength = value ? Number(value) : DEFAULT_PASSWORD_MIN_LENGTH;
|
|
8972
|
-
if (!Number.isInteger(minLength) || minLength < 1 || minLength > 128) {
|
|
8973
|
-
throw new ConfigError(`PASSWORD_MIN_LENGTH must be a whole number from 1 to 128 but is "${value}".`);
|
|
8974
|
-
}
|
|
8975
|
-
return minLength;
|
|
8976
|
-
}
|
|
8977
|
-
async function storeFromDatabaseUrl(url) {
|
|
8978
|
-
if (!url) return void 0;
|
|
8979
|
-
if (url.startsWith("dynamodb:")) return dynamoDbStore(url.slice("dynamodb:".length));
|
|
8980
|
-
if (!url.startsWith("sqlite:")) {
|
|
8981
|
-
throw new ConfigError(
|
|
8982
|
-
`DATABASE_URL must start with "sqlite:" (e.g. sqlite:/data/madauth.db) or "dynamodb:" (e.g. dynamodb:madauth) but is "${url}". For other databases, pass your own store adapter (see "Custom store adapter" in docs/server.md).`
|
|
8983
|
-
);
|
|
8984
|
-
}
|
|
8985
|
-
const path = url.slice("sqlite:".length);
|
|
8986
|
-
if (!path) throw new ConfigError("DATABASE_URL needs a file path, e.g. sqlite:/data/madauth.db");
|
|
8987
|
-
const { createSqliteAdapter: createSqliteAdapter2 } = await Promise.resolve().then(() => (init_sqlite(), sqlite_exports));
|
|
8988
|
-
try {
|
|
8989
|
-
return createSqliteAdapter2(path);
|
|
8990
|
-
} catch (e) {
|
|
8991
|
-
throw new ConfigError(`DATABASE_URL: could not open "${path}": ${e.message}`);
|
|
8992
|
-
}
|
|
8993
|
-
}
|
|
8994
|
-
async function dynamoDbStore(tableName2) {
|
|
8995
|
-
if (!tableName2) throw new ConfigError("DATABASE_URL needs a table name, e.g. dynamodb:madauth");
|
|
8996
|
-
if (!/^[\w.-]{3,255}$/.test(tableName2)) {
|
|
8997
|
-
throw new ConfigError(
|
|
8998
|
-
`DATABASE_URL=dynamodb: needs a DynamoDB table name (3 to 255 letters, digits, _ . -) but has "${tableName2}"`
|
|
8999
|
-
);
|
|
9000
|
-
}
|
|
9001
|
-
try {
|
|
9002
|
-
await import("@aws-sdk/client-dynamodb");
|
|
9003
|
-
} catch (e) {
|
|
9004
|
-
throw new ConfigError(
|
|
9005
|
-
`DATABASE_URL=dynamodb: needs the AWS SDK. Install it next to madAuth: npm install @aws-sdk/client-dynamodb (${e.message})`
|
|
9006
|
-
);
|
|
9007
|
-
}
|
|
9008
|
-
const { createDynamoDbAdapter: createDynamoDbAdapter2 } = await Promise.resolve().then(() => (init_dynamodb(), dynamodb_exports));
|
|
9009
|
-
return createDynamoDbAdapter2({ tableName: tableName2 });
|
|
9010
|
-
}
|
|
9011
|
-
var RENAMED_TYPES = { "password.reset": "email.password_reset", "roles.changed": "user.claims_changed" };
|
|
9012
|
-
var LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]", "host.docker.internal"];
|
|
9013
|
-
function webhookFromEnv(url, secret, events) {
|
|
9014
|
-
if (!url) {
|
|
9015
|
-
if (secret || events) throw new ConfigError("WEBHOOK_SECRET and WEBHOOK_EVENTS need WEBHOOK_URL.");
|
|
9016
|
-
return void 0;
|
|
9017
|
-
}
|
|
9018
|
-
let parsed;
|
|
9019
|
-
try {
|
|
9020
|
-
parsed = new URL(url);
|
|
9021
|
-
} catch {
|
|
9022
|
-
throw new ConfigError(`WEBHOOK_URL: "${url}" is not a URL.`);
|
|
9023
|
-
}
|
|
9024
|
-
if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && LOCAL_HOSTS.includes(parsed.hostname))) {
|
|
9025
|
-
throw new ConfigError(`WEBHOOK_URL must be an https URL (http only for localhost) but is "${url}".`);
|
|
9026
|
-
}
|
|
9027
|
-
if (!secret) {
|
|
9028
|
-
throw new ConfigError(
|
|
9029
|
-
`WEBHOOK_SECRET is not set. Here is a newly generated secret you can use (keep it secret; your webhook receiver needs the same one):
|
|
9030
|
-
|
|
9031
|
-
WEBHOOK_SECRET=${generateWebhookSecret()}
|
|
9032
|
-
`
|
|
9033
|
-
);
|
|
9034
|
-
}
|
|
9035
|
-
const problem = checkWebhookSecret(secret);
|
|
9036
|
-
if (problem) {
|
|
9037
|
-
throw new ConfigError(`WEBHOOK_SECRET ${problem}. Generate one with: npx @madauth/server generate-webhook-secret`);
|
|
9038
|
-
}
|
|
9039
|
-
const selected = new Set((events ?? "").split(",").map((e) => e.trim()).filter(Boolean));
|
|
9040
|
-
const allTypes = `All types: ${WEBHOOK_TYPES.join(", ")}. See "Webhooks" in docs/server.md.`;
|
|
9041
|
-
if (!selected.size) {
|
|
9042
|
-
throw new ConfigError(
|
|
9043
|
-
`WEBHOOK_EVENTS is not set. List the types your webhook receiver handles; only these are sent. E-mail & password sign-in is on when they include ${REQUIRED_EMAIL_TYPES.join(" and ")}. For a receiver that sends the e-mails:
|
|
9044
|
-
|
|
9045
|
-
WEBHOOK_EVENTS=email.verify,email.reset,email.already_registered
|
|
9046
|
-
|
|
9047
|
-
For one that only wants to know who signed in:
|
|
9048
|
-
|
|
9049
|
-
WEBHOOK_EVENTS=user.signed_in
|
|
9050
|
-
|
|
9051
|
-
` + allTypes
|
|
9052
|
-
);
|
|
9053
|
-
}
|
|
9054
|
-
const unknown = [...selected].filter((e) => !WEBHOOK_TYPES.includes(e));
|
|
9055
|
-
if (unknown.length) {
|
|
9056
|
-
const renamed = unknown.filter((e) => e in RENAMED_TYPES).map((e) => `${e} is now ${RENAMED_TYPES[e]}`);
|
|
9057
|
-
throw new ConfigError(
|
|
9058
|
-
`WEBHOOK_EVENTS: unknown type ${unknown.join(", ")}. ${renamed.length ? `${renamed.join("; ")}. ` : ""}Known types: ${WEBHOOK_TYPES.join(", ")}.`
|
|
9059
|
-
);
|
|
9060
|
-
}
|
|
9061
|
-
return { url: parsed.href, secret, events: selected };
|
|
9062
|
-
}
|
|
9063
|
-
|
|
9064
9719
|
// src/entry/azure-handler.ts
|
|
9065
9720
|
var sameSiteValues = { strict: "Strict", lax: "Lax", none: "None" };
|
|
9066
9721
|
function parseSetCookie(header) {
|