@madauth/server 0.3.0-beta.2 → 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-TI35AZ2U.js → chunk-ISJGOCF3.js} +250 -83
- package/dist/{chunk-56MOVFO4.js → chunk-OMIX3ERC.js} +11 -2
- package/dist/{chunk-3QRKWLR7.js → chunk-Y7ZSUROX.js} +4 -1
- package/dist/{config-Dg2fZuWR.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 +1316 -676
- package/dist/standalone/lambda.mjs +1311 -671
- package/dist/standalone/main.mjs +1142 -502
- 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-j5u9Kv9l.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-DVVBIFIV.js +0 -718
|
@@ -15,7 +15,7 @@ var init_schema = __esm({
|
|
|
15
15
|
"src/store/schema.ts"() {
|
|
16
16
|
"use strict";
|
|
17
17
|
madauthSchema = {
|
|
18
|
-
version:
|
|
18
|
+
version: 5,
|
|
19
19
|
models: {
|
|
20
20
|
/** Someone who signed in, whichever way: the e-mail address is the identity shared by the sign-in methods. */
|
|
21
21
|
user: {
|
|
@@ -34,7 +34,10 @@ var init_schema = __esm({
|
|
|
34
34
|
createdAt: { type: "number" }
|
|
35
35
|
}
|
|
36
36
|
},
|
|
37
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* How a user signs in. `key` is `password:<userId>`, `google:<sub>` or `totp:<userId>` (the authenticator
|
|
39
|
+
* app, whose `secret` is encrypted).
|
|
40
|
+
*/
|
|
38
41
|
account: {
|
|
39
42
|
fields: {
|
|
40
43
|
id: { type: "string", primaryKey: true },
|
|
@@ -45,6 +48,25 @@ var init_schema = __esm({
|
|
|
45
48
|
lockedUntil: { type: "number" },
|
|
46
49
|
/** The address the provider reported at the last sign-in, e.g. Google's current primary address (since version 4). */
|
|
47
50
|
email: { type: "string", nullable: true },
|
|
51
|
+
/** The time step of the last authenticator code that was accepted, so a code works once; 0 before (since version 5). */
|
|
52
|
+
lastUsedStep: { type: "number" },
|
|
53
|
+
/**
|
|
54
|
+
* The setup an authenticator secret came from; its recovery codes carry the same id, so the app and
|
|
55
|
+
* its codes always belong together. Empty for other accounts (since version 5).
|
|
56
|
+
*/
|
|
57
|
+
setupId: { type: "string", nullable: true },
|
|
58
|
+
createdAt: { type: "number" }
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
/**
|
|
62
|
+
* A recovery code of a user's authenticator app (since version 5). `id` is the code's keyed hash; the
|
|
63
|
+
* record goes when the code is used. It works only while the account's `setupId` is its own.
|
|
64
|
+
*/
|
|
65
|
+
recoveryCode: {
|
|
66
|
+
fields: {
|
|
67
|
+
id: { type: "string", primaryKey: true },
|
|
68
|
+
userId: { type: "string", index: true },
|
|
69
|
+
setupId: { type: "string" },
|
|
48
70
|
createdAt: { type: "number" }
|
|
49
71
|
}
|
|
50
72
|
},
|
|
@@ -76,7 +98,7 @@ var init_schema = __esm({
|
|
|
76
98
|
|
|
77
99
|
// src/store/sql.ts
|
|
78
100
|
function tableName(model) {
|
|
79
|
-
return `madauth_${model}`;
|
|
101
|
+
return `madauth_${columnName(model)}`;
|
|
80
102
|
}
|
|
81
103
|
function columnName(field) {
|
|
82
104
|
return field.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
|
@@ -144,6 +166,15 @@ var init_sql = __esm({
|
|
|
144
166
|
`DROP TABLE ${tableName("role")};`,
|
|
145
167
|
createTablesSql(dialect, { version: 4, models: { setting: madauthSchema.models.setting } })
|
|
146
168
|
].join("\n")
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
// The authenticator app: which code was used last, and the recovery codes.
|
|
172
|
+
version: 5,
|
|
173
|
+
sql: (dialect) => [
|
|
174
|
+
`ALTER TABLE ${tableName("account")} ADD COLUMN ${columnName("lastUsedStep")} ${columnTypes[dialect].number} NOT NULL DEFAULT 0;`,
|
|
175
|
+
`ALTER TABLE ${tableName("account")} ADD COLUMN ${columnName("setupId")} ${columnTypes[dialect].string};`,
|
|
176
|
+
createTablesSql(dialect, { version: 5, models: { recoveryCode: madauthSchema.models.recoveryCode } })
|
|
177
|
+
].join("\n")
|
|
147
178
|
}
|
|
148
179
|
];
|
|
149
180
|
}
|
|
@@ -296,16 +327,16 @@ function stillMatches(where) {
|
|
|
296
327
|
});
|
|
297
328
|
return { text: parts.join(" AND "), names, values };
|
|
298
329
|
}
|
|
299
|
-
function failedWrite(
|
|
300
|
-
const { name, CancellationReasons } =
|
|
330
|
+
function failedWrite(error2) {
|
|
331
|
+
const { name, CancellationReasons } = error2;
|
|
301
332
|
if (name === "ConditionalCheckFailedException") return "refused";
|
|
302
|
-
if (name && RETRYABLE_ERRORS.includes(name)) return { retry:
|
|
333
|
+
if (name && RETRYABLE_ERRORS.includes(name)) return { retry: error2 };
|
|
303
334
|
if (name === "TransactionCanceledException") {
|
|
304
335
|
const reasons = (CancellationReasons ?? []).map((r) => r.Code ?? "None").filter((code) => code !== "None");
|
|
305
336
|
if (reasons.includes("ConditionalCheckFailed")) return "refused";
|
|
306
|
-
if (reasons.length && reasons.every((code) => RETRYABLE_REASONS.includes(code))) return { retry:
|
|
337
|
+
if (reasons.length && reasons.every((code) => RETRYABLE_REASONS.includes(code))) return { retry: error2 };
|
|
307
338
|
}
|
|
308
|
-
throw
|
|
339
|
+
throw error2;
|
|
309
340
|
}
|
|
310
341
|
function createDynamoDbAdapter(options) {
|
|
311
342
|
const { tableName: tableName2 } = options;
|
|
@@ -534,9 +565,9 @@ var handle = (app, { isContentTypeBinary } = { isContentTypeBinary: void 0 }) =>
|
|
|
534
565
|
try {
|
|
535
566
|
req = processor.createRequest(event);
|
|
536
567
|
requestContext = getRequestContext(event);
|
|
537
|
-
} catch (
|
|
538
|
-
console.error("Error processing request:",
|
|
539
|
-
const errorResponse =
|
|
568
|
+
} catch (error2) {
|
|
569
|
+
console.error("Error processing request:", error2);
|
|
570
|
+
const errorResponse = error2 instanceof TypeError ? new Response("Invalid request", { status: 400 }) : new Response("Internal Server Error", { status: 500 });
|
|
540
571
|
return processor.createResult(event, errorResponse, { isContentTypeBinary });
|
|
541
572
|
}
|
|
542
573
|
const res = await app.fetch(req, {
|
|
@@ -568,9 +599,9 @@ var EventProcessor = class {
|
|
|
568
599
|
method: this.getMethod(event)
|
|
569
600
|
};
|
|
570
601
|
if (event.body) {
|
|
571
|
-
const
|
|
572
|
-
requestInit.body =
|
|
573
|
-
headers.set("content-length",
|
|
602
|
+
const body2 = event.isBase64Encoded ? decodeBase64(event.body) : new TextEncoder().encode(event.body);
|
|
603
|
+
requestInit.body = body2;
|
|
604
|
+
headers.set("content-length", body2.length.toString());
|
|
574
605
|
}
|
|
575
606
|
return new Request(url, requestInit);
|
|
576
607
|
}
|
|
@@ -1070,10 +1101,10 @@ var HonoRequest = class {
|
|
|
1070
1101
|
const { bodyCache, raw: raw2 } = this;
|
|
1071
1102
|
const cachedBody = bodyCache[key];
|
|
1072
1103
|
if (cachedBody) return cachedBody;
|
|
1073
|
-
for (const anyCachedKey in bodyCache) return bodyCache[anyCachedKey].then((
|
|
1074
|
-
if (anyCachedKey === "json")
|
|
1104
|
+
for (const anyCachedKey in bodyCache) return bodyCache[anyCachedKey].then((body2) => {
|
|
1105
|
+
if (anyCachedKey === "json") body2 = JSON.stringify(body2);
|
|
1075
1106
|
const contentType = anyCachedKey === "formData" ? void 0 : raw2.headers.get("content-type");
|
|
1076
|
-
return new Response(
|
|
1107
|
+
return new Response(body2, { headers: contentType ? { "Content-Type": contentType } : void 0 })[key]();
|
|
1077
1108
|
});
|
|
1078
1109
|
return bodyCache[key] = raw2[key]();
|
|
1079
1110
|
};
|
|
@@ -1298,7 +1329,7 @@ var setDefaultContentType = (contentType, headers) => {
|
|
|
1298
1329
|
...headers
|
|
1299
1330
|
};
|
|
1300
1331
|
};
|
|
1301
|
-
var createResponseInstance = (
|
|
1332
|
+
var createResponseInstance = (body2, init) => new Response(body2, init);
|
|
1302
1333
|
var Context = class {
|
|
1303
1334
|
#rawRequest;
|
|
1304
1335
|
#req;
|
|
@@ -3674,8 +3705,8 @@ async function importWithAlgCache(cache2, jwk, entry) {
|
|
|
3674
3705
|
if (key.type !== "public")
|
|
3675
3706
|
throw new JWKSInvalid("JSON Web Key Set members must be public keys");
|
|
3676
3707
|
return cached[alg] = key, key;
|
|
3677
|
-
}).catch((
|
|
3678
|
-
throw cached[alg] === pending && delete cached[alg],
|
|
3708
|
+
}).catch((error2) => {
|
|
3709
|
+
throw cached[alg] === pending && delete cached[alg], error2;
|
|
3679
3710
|
});
|
|
3680
3711
|
cached[alg] = pending;
|
|
3681
3712
|
}
|
|
@@ -3701,14 +3732,14 @@ function createLocalJWKSet(jwks) {
|
|
|
3701
3732
|
if (!length)
|
|
3702
3733
|
throw new JWKSNoMatchingKey();
|
|
3703
3734
|
if (length !== 1) {
|
|
3704
|
-
const
|
|
3705
|
-
throw
|
|
3735
|
+
const error2 = new JWKSMultipleMatchingKeys();
|
|
3736
|
+
throw error2[Symbol.asyncIterator] = async function* () {
|
|
3706
3737
|
for (const jwk2 of candidates)
|
|
3707
3738
|
try {
|
|
3708
3739
|
yield await importWithAlgCache(cached, jwk2, entry);
|
|
3709
3740
|
} catch {
|
|
3710
3741
|
}
|
|
3711
|
-
},
|
|
3742
|
+
}, error2;
|
|
3712
3743
|
}
|
|
3713
3744
|
return importWithAlgCache(cached, jwk, entry);
|
|
3714
3745
|
}, "jwks", {
|
|
@@ -3895,190 +3926,417 @@ async function importSigningKeys(jwk) {
|
|
|
3895
3926
|
};
|
|
3896
3927
|
}
|
|
3897
3928
|
|
|
3898
|
-
// src/
|
|
3899
|
-
import {
|
|
3900
|
-
var
|
|
3901
|
-
|
|
3902
|
-
|
|
3903
|
-
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
|
|
3918
|
-
|
|
3919
|
-
|
|
3920
|
-
return ["scrypt", params.logN, params.r, params.p, salt.toString("base64url"), key.toString("base64url")].join("$");
|
|
3921
|
-
}
|
|
3922
|
-
async function verifyPassword(password, stored) {
|
|
3923
|
-
const [scheme, logN, r, p, salt, hash] = stored.split("$");
|
|
3924
|
-
const params = { logN: Number(logN), r: Number(r), p: Number(p) };
|
|
3925
|
-
if (scheme !== "scrypt" || !salt || !hash || !Object.values(params).every((n) => Number.isInteger(n) && n > 0)) {
|
|
3926
|
-
return { ok: false, needsRehash: false };
|
|
3927
|
-
}
|
|
3928
|
-
const expected = Buffer.from(hash, "base64url");
|
|
3929
|
-
const key = await derive2(password, Buffer.from(salt, "base64url"), params);
|
|
3930
|
-
const ok = key.length === expected.length && timingSafeEqual(key, expected);
|
|
3931
|
-
const needsRehash = params.logN !== HASH_PARAMS.logN || params.r !== HASH_PARAMS.r || params.p !== HASH_PARAMS.p;
|
|
3932
|
-
return { ok, needsRehash };
|
|
3929
|
+
// src/webhooks.ts
|
|
3930
|
+
import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "crypto";
|
|
3931
|
+
var WEBHOOK_TYPES = [
|
|
3932
|
+
"email.verify",
|
|
3933
|
+
"email.reset",
|
|
3934
|
+
"email.already_registered",
|
|
3935
|
+
"email.no_password",
|
|
3936
|
+
"signup.before",
|
|
3937
|
+
"user.created",
|
|
3938
|
+
"email.verified",
|
|
3939
|
+
"email.password_reset",
|
|
3940
|
+
"user.signed_in",
|
|
3941
|
+
"user.deleted",
|
|
3942
|
+
"user.claims_changed",
|
|
3943
|
+
"totp.enabled",
|
|
3944
|
+
"totp.disabled",
|
|
3945
|
+
"totp.recovery_code_used"
|
|
3946
|
+
];
|
|
3947
|
+
var WEBHOOK_TIMEOUT_MS = 1e4;
|
|
3948
|
+
var MAX_LOCALE_LENGTH = 35;
|
|
3949
|
+
function localeOf(value) {
|
|
3950
|
+
return typeof value === "string" && value.length <= MAX_LOCALE_LENGTH ? value : void 0;
|
|
3933
3951
|
}
|
|
3934
|
-
var
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3952
|
+
var REQUIRED_EMAIL_TYPES = ["email.verify", "email.reset"];
|
|
3953
|
+
var SECRET_PREFIX = "whsec_";
|
|
3954
|
+
var DEV_WEBHOOK_RECEIVER_URL = "https://github.com/inouiw/madAuth-webhooks/tree/main/dev-receiver";
|
|
3955
|
+
var WEBHOOK_TOLERANCE_SECONDS = 5 * 60;
|
|
3956
|
+
function generateWebhookSecret() {
|
|
3957
|
+
return `${SECRET_PREFIX}${randomBytes(32).toString("base64")}`;
|
|
3938
3958
|
}
|
|
3939
|
-
function
|
|
3940
|
-
if (
|
|
3941
|
-
const
|
|
3942
|
-
if (length <
|
|
3943
|
-
if (length > MAX_PASSWORD_LENGTH) return `The password must have at most ${MAX_PASSWORD_LENGTH} characters.`;
|
|
3959
|
+
function checkWebhookSecret(secret) {
|
|
3960
|
+
if (!secret.startsWith(SECRET_PREFIX)) return `must start with "${SECRET_PREFIX}"`;
|
|
3961
|
+
const key = Buffer.from(secret.slice(SECRET_PREFIX.length), "base64");
|
|
3962
|
+
if (key.length < 24 || key.length > 64) return "must contain 24 to 64 bytes in base64";
|
|
3944
3963
|
return null;
|
|
3945
3964
|
}
|
|
3946
|
-
function
|
|
3947
|
-
|
|
3965
|
+
function hmac2(secret, content) {
|
|
3966
|
+
const key = Buffer.from(secret.slice(SECRET_PREFIX.length), "base64");
|
|
3967
|
+
return createHmac("sha256", key).update(content).digest("base64");
|
|
3948
3968
|
}
|
|
3949
|
-
function
|
|
3950
|
-
return
|
|
3969
|
+
function signWebhook(secret, id, timestamp, body2) {
|
|
3970
|
+
return `v1,${hmac2(secret, `${id}.${timestamp}.${body2}`)}`;
|
|
3951
3971
|
}
|
|
3952
|
-
function
|
|
3953
|
-
|
|
3972
|
+
function createWebhookClient(settings, fetchImpl = fetch) {
|
|
3973
|
+
const wants = (type) => settings.events.has(type);
|
|
3974
|
+
return {
|
|
3975
|
+
wants,
|
|
3976
|
+
async call(type, data, timeoutMs) {
|
|
3977
|
+
if (!wants(type)) return { ok: true, body: void 0 };
|
|
3978
|
+
const id = `msg_${randomUUID()}`;
|
|
3979
|
+
const timestamp = Math.floor(Date.now() / 1e3);
|
|
3980
|
+
const body2 = JSON.stringify({ type, data });
|
|
3981
|
+
let response;
|
|
3982
|
+
try {
|
|
3983
|
+
response = await fetchImpl(settings.url, {
|
|
3984
|
+
method: "POST",
|
|
3985
|
+
headers: {
|
|
3986
|
+
"Content-Type": "application/json",
|
|
3987
|
+
"webhook-id": id,
|
|
3988
|
+
"webhook-timestamp": String(timestamp),
|
|
3989
|
+
"webhook-signature": signWebhook(settings.secret, id, timestamp, body2)
|
|
3990
|
+
},
|
|
3991
|
+
body: body2,
|
|
3992
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
3993
|
+
});
|
|
3994
|
+
} catch (e) {
|
|
3995
|
+
const reason = e.name === "TimeoutError" ? `no answer within ${timeoutMs} ms` : e.message;
|
|
3996
|
+
return { ok: false, reason };
|
|
3997
|
+
}
|
|
3998
|
+
const text = await response.text().catch(() => "");
|
|
3999
|
+
if (!response.ok) return { ok: false, reason: `HTTP ${response.status}` };
|
|
4000
|
+
let parsed;
|
|
4001
|
+
try {
|
|
4002
|
+
parsed = text ? JSON.parse(text) : void 0;
|
|
4003
|
+
} catch {
|
|
4004
|
+
parsed = void 0;
|
|
4005
|
+
}
|
|
4006
|
+
return { ok: true, body: parsed };
|
|
4007
|
+
}
|
|
4008
|
+
};
|
|
3954
4009
|
}
|
|
3955
|
-
|
|
3956
|
-
|
|
4010
|
+
|
|
4011
|
+
// src/config.ts
|
|
4012
|
+
function sendsVerificationEmails(config) {
|
|
4013
|
+
return !!config.webhook?.events.has("email.verify");
|
|
3957
4014
|
}
|
|
3958
|
-
function
|
|
3959
|
-
return
|
|
4015
|
+
function methodConfig(config) {
|
|
4016
|
+
return { google: config.google, password: config.password, emailVerification: sendsVerificationEmails(config) };
|
|
3960
4017
|
}
|
|
3961
|
-
function
|
|
3962
|
-
return
|
|
4018
|
+
async function resolveEnv(source = process.env) {
|
|
4019
|
+
return typeof source === "function" ? source() : source;
|
|
3963
4020
|
}
|
|
3964
|
-
|
|
3965
|
-
|
|
4021
|
+
var DEFAULT_SESSION_TTL = 8 * 60 * 60;
|
|
4022
|
+
var DEFAULT_RENEWAL_TTL = 30 * 24 * 60 * 60;
|
|
4023
|
+
var DEFAULT_PASSWORD_MIN_LENGTH = 8;
|
|
4024
|
+
var ConfigError = class extends Error {
|
|
4025
|
+
constructor() {
|
|
4026
|
+
super(...arguments);
|
|
4027
|
+
this.name = "ConfigError";
|
|
4028
|
+
}
|
|
4029
|
+
};
|
|
4030
|
+
function parseOrigin(name, value) {
|
|
4031
|
+
let url;
|
|
4032
|
+
try {
|
|
4033
|
+
url = new URL(value);
|
|
4034
|
+
} catch {
|
|
4035
|
+
throw new ConfigError(`${name}: "${value}" is not a URL.`);
|
|
4036
|
+
}
|
|
4037
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
4038
|
+
throw new ConfigError(`${name}: "${value}" must be an http(s) URL.`);
|
|
4039
|
+
}
|
|
4040
|
+
return url.origin;
|
|
3966
4041
|
}
|
|
3967
|
-
function
|
|
3968
|
-
|
|
3969
|
-
const y = Buffer.from(b);
|
|
3970
|
-
return x.length === y.length && timingSafeEqual(x, y);
|
|
4042
|
+
function readEnv(env, name) {
|
|
4043
|
+
return env[name]?.trim().replace(/^(['"])(.*)\1$/s, "$2").trim() || void 0;
|
|
3971
4044
|
}
|
|
4045
|
+
async function loadConfig(env, overrides = {}) {
|
|
4046
|
+
const read = (name) => readEnv(env, name);
|
|
4047
|
+
const require2 = (name) => {
|
|
4048
|
+
const value = read(name);
|
|
4049
|
+
if (!value) throw new ConfigError(`${name} is not set. See docs/server.md.`);
|
|
4050
|
+
return value;
|
|
4051
|
+
};
|
|
4052
|
+
const issuerValue = require2("MADAUTH_ISSUER");
|
|
4053
|
+
parseOrigin("MADAUTH_ISSUER", issuerValue);
|
|
4054
|
+
const issuer = issuerValue.replace(/\/+$/, "");
|
|
4055
|
+
const keyValue = read("MADAUTH_SIGNING_KEY");
|
|
4056
|
+
if (!keyValue) {
|
|
4057
|
+
const key = await generateSigningKey();
|
|
4058
|
+
throw new ConfigError(
|
|
4059
|
+
`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):
|
|
3972
4060
|
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
3978
|
-
return googleJwks;
|
|
3979
|
-
}
|
|
3980
|
-
async function verifyGoogleIdToken(idToken, opts) {
|
|
3981
|
-
let payload;
|
|
4061
|
+
MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
|
|
4062
|
+
`
|
|
4063
|
+
);
|
|
4064
|
+
}
|
|
4065
|
+
let signingKey;
|
|
3982
4066
|
try {
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
audience: opts.clientId,
|
|
3986
|
-
algorithms: ["RS256", "ES256"]
|
|
3987
|
-
}));
|
|
4067
|
+
signingKey = JSON.parse(keyValue);
|
|
4068
|
+
assertPrivateSigningJwk(signingKey);
|
|
3988
4069
|
} catch (e) {
|
|
3989
|
-
|
|
4070
|
+
const reason = e instanceof SyntaxError ? "is not valid JSON" : e.message;
|
|
4071
|
+
throw new ConfigError(`MADAUTH_SIGNING_KEY ${reason}. Generate one with: npx @madauth/server generate-key`);
|
|
3990
4072
|
}
|
|
3991
|
-
|
|
3992
|
-
|
|
4073
|
+
const allowedOrigins = require2("ALLOWED_ORIGINS").split(",").map((o) => o.trim()).filter(Boolean).map((o) => parseOrigin("ALLOWED_ORIGINS", o));
|
|
4074
|
+
const clientId = read("GOOGLE_CLIENT_ID");
|
|
4075
|
+
if (clientId && !clientId.endsWith(".apps.googleusercontent.com")) {
|
|
4076
|
+
throw new ConfigError(`GOOGLE_CLIENT_ID must end with ".apps.googleusercontent.com" but is "${clientId}".`);
|
|
3993
4077
|
}
|
|
3994
|
-
|
|
3995
|
-
|
|
4078
|
+
const webhook = webhookFromEnv(read("WEBHOOK_URL"), read("WEBHOOK_SECRET"), read("WEBHOOK_EVENTS"));
|
|
4079
|
+
const sendsEmails = !!webhook && REQUIRED_EMAIL_TYPES.every((type) => webhook.events.has(type));
|
|
4080
|
+
const password = sendsEmails ? { minLength: passwordMinLength(read("PASSWORD_MIN_LENGTH")) } : void 0;
|
|
4081
|
+
const emailVerification = !!webhook && webhook.events.has("email.verify");
|
|
4082
|
+
if (!clientId && !password && !emailVerification) {
|
|
4083
|
+
throw new ConfigError(
|
|
4084
|
+
`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.`
|
|
4085
|
+
);
|
|
3996
4086
|
}
|
|
3997
|
-
|
|
3998
|
-
|
|
4087
|
+
const store = overrides.store ?? await storeFromDatabaseUrl(read("DATABASE_URL"));
|
|
4088
|
+
if (!store) {
|
|
4089
|
+
throw new ConfigError(
|
|
4090
|
+
"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."
|
|
4091
|
+
);
|
|
3999
4092
|
}
|
|
4000
|
-
const
|
|
4001
|
-
|
|
4002
|
-
if (
|
|
4003
|
-
|
|
4004
|
-
}
|
|
4005
|
-
|
|
4006
|
-
// src/user.ts
|
|
4007
|
-
var SESSION_COOKIE = "madauth_session";
|
|
4008
|
-
var SESSION_TYP = "madauth-session+jwt";
|
|
4009
|
-
var RENEWAL_COOKIE = "madauth_renewal";
|
|
4010
|
-
var RENEWAL_TYP = "madauth-renewal+jwt";
|
|
4011
|
-
var EXPIRY_COOKIE = "madauth_session_expires";
|
|
4012
|
-
|
|
4013
|
-
// src/tokens.ts
|
|
4014
|
-
function randomString(bytes = 32) {
|
|
4015
|
-
return base64url_exports.encode(crypto.getRandomValues(new Uint8Array(bytes)));
|
|
4016
|
-
}
|
|
4017
|
-
async function signToken(keys, issuer, typ, claims, ttlSeconds) {
|
|
4018
|
-
return new SignJWT(claims).setProtectedHeader({ alg: SIGNING_ALG, kid: keys.kid, typ }).setIssuer(issuer).setIssuedAt().setExpirationTime(`${ttlSeconds}s`).sign(keys.privateKey);
|
|
4019
|
-
}
|
|
4020
|
-
async function readToken(keys, issuer, typ, token) {
|
|
4021
|
-
if (!token) return null;
|
|
4022
|
-
try {
|
|
4023
|
-
const { payload } = await jwtVerify(token, keys.publicKey, { issuer, typ, algorithms: [SIGNING_ALG] });
|
|
4024
|
-
return payload;
|
|
4025
|
-
} catch {
|
|
4026
|
-
return null;
|
|
4093
|
+
const ttlValue = read("SESSION_TTL");
|
|
4094
|
+
const sessionTtlSeconds = ttlValue ? Number(ttlValue) : DEFAULT_SESSION_TTL;
|
|
4095
|
+
if (!Number.isInteger(sessionTtlSeconds) || sessionTtlSeconds < 60) {
|
|
4096
|
+
throw new ConfigError(`SESSION_TTL must be a whole number of seconds (at least 60) but is "${ttlValue}".`);
|
|
4027
4097
|
}
|
|
4028
|
-
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
|
|
4032
|
-
|
|
4033
|
-
|
|
4034
|
-
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4039
|
-
|
|
4040
|
-
|
|
4041
|
-
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
email_verified: user.email ? true : void 0,
|
|
4048
|
-
name: user.name,
|
|
4049
|
-
picture: user.picture,
|
|
4050
|
-
amr
|
|
4098
|
+
const renewalValue = read("SESSION_RENEWAL_TTL");
|
|
4099
|
+
const renewalTtlSeconds = renewalValue ? Number(renewalValue) : Math.max(DEFAULT_RENEWAL_TTL, sessionTtlSeconds);
|
|
4100
|
+
if (!Number.isInteger(renewalTtlSeconds) || renewalTtlSeconds < sessionTtlSeconds) {
|
|
4101
|
+
throw new ConfigError(
|
|
4102
|
+
`SESSION_RENEWAL_TTL must be a whole number of seconds, not less than SESSION_TTL (${sessionTtlSeconds}), but is "${renewalValue}".`
|
|
4103
|
+
);
|
|
4104
|
+
}
|
|
4105
|
+
return {
|
|
4106
|
+
issuer,
|
|
4107
|
+
signingKey,
|
|
4108
|
+
allowedOrigins,
|
|
4109
|
+
sessionTtlSeconds,
|
|
4110
|
+
renewalTtlSeconds,
|
|
4111
|
+
cookieDomain: read("COOKIE_DOMAIN"),
|
|
4112
|
+
totpIssuer: read("TOTP_ISSUER"),
|
|
4113
|
+
google: clientId ? { clientId, clientSecret: read("GOOGLE_CLIENT_SECRET") } : void 0,
|
|
4114
|
+
store,
|
|
4115
|
+
password,
|
|
4116
|
+
webhook
|
|
4051
4117
|
};
|
|
4052
|
-
return signToken(keys, issuer, RENEWAL_TYP, claims, ttlSeconds);
|
|
4053
4118
|
}
|
|
4054
|
-
function
|
|
4055
|
-
const
|
|
4056
|
-
if (
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4119
|
+
function passwordMinLength(value) {
|
|
4120
|
+
const minLength = value ? Number(value) : DEFAULT_PASSWORD_MIN_LENGTH;
|
|
4121
|
+
if (!Number.isInteger(minLength) || minLength < 1 || minLength > 128) {
|
|
4122
|
+
throw new ConfigError(`PASSWORD_MIN_LENGTH must be a whole number from 1 to 128 but is "${value}".`);
|
|
4123
|
+
}
|
|
4124
|
+
return minLength;
|
|
4125
|
+
}
|
|
4126
|
+
async function storeFromDatabaseUrl(url) {
|
|
4127
|
+
if (!url) return void 0;
|
|
4128
|
+
if (url.startsWith("dynamodb:")) return dynamoDbStore(url.slice("dynamodb:".length));
|
|
4129
|
+
if (!url.startsWith("sqlite:")) {
|
|
4130
|
+
throw new ConfigError(
|
|
4131
|
+
`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).`
|
|
4132
|
+
);
|
|
4133
|
+
}
|
|
4134
|
+
const path = url.slice("sqlite:".length);
|
|
4135
|
+
if (!path) throw new ConfigError("DATABASE_URL needs a file path, e.g. sqlite:/data/madauth.db");
|
|
4136
|
+
const { createSqliteAdapter: createSqliteAdapter2 } = await Promise.resolve().then(() => (init_sqlite(), sqlite_exports));
|
|
4137
|
+
try {
|
|
4138
|
+
return createSqliteAdapter2(path);
|
|
4139
|
+
} catch (e) {
|
|
4140
|
+
throw new ConfigError(`DATABASE_URL: could not open "${path}": ${e.message}`);
|
|
4141
|
+
}
|
|
4142
|
+
}
|
|
4143
|
+
async function dynamoDbStore(tableName2) {
|
|
4144
|
+
if (!tableName2) throw new ConfigError("DATABASE_URL needs a table name, e.g. dynamodb:madauth");
|
|
4145
|
+
if (!/^[\w.-]{3,255}$/.test(tableName2)) {
|
|
4146
|
+
throw new ConfigError(
|
|
4147
|
+
`DATABASE_URL=dynamodb: needs a DynamoDB table name (3 to 255 letters, digits, _ . -) but has "${tableName2}"`
|
|
4148
|
+
);
|
|
4149
|
+
}
|
|
4150
|
+
try {
|
|
4151
|
+
await import("@aws-sdk/client-dynamodb");
|
|
4152
|
+
} catch (e) {
|
|
4153
|
+
throw new ConfigError(
|
|
4154
|
+
`DATABASE_URL=dynamodb: needs the AWS SDK. Install it next to madAuth: npm install @aws-sdk/client-dynamodb (${e.message})`
|
|
4155
|
+
);
|
|
4156
|
+
}
|
|
4157
|
+
const { createDynamoDbAdapter: createDynamoDbAdapter2 } = await Promise.resolve().then(() => (init_dynamodb(), dynamodb_exports));
|
|
4158
|
+
return createDynamoDbAdapter2({ tableName: tableName2 });
|
|
4159
|
+
}
|
|
4160
|
+
var RENAMED_TYPES = { "password.reset": "email.password_reset", "roles.changed": "user.claims_changed" };
|
|
4161
|
+
var LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]", "host.docker.internal"];
|
|
4162
|
+
function webhookFromEnv(url, secret, events) {
|
|
4163
|
+
if (!url) {
|
|
4164
|
+
if (secret || events) throw new ConfigError("WEBHOOK_SECRET and WEBHOOK_EVENTS need WEBHOOK_URL.");
|
|
4165
|
+
return void 0;
|
|
4166
|
+
}
|
|
4167
|
+
let parsed;
|
|
4168
|
+
try {
|
|
4169
|
+
parsed = new URL(url);
|
|
4170
|
+
} catch {
|
|
4171
|
+
throw new ConfigError(`WEBHOOK_URL: "${url}" is not a URL.`);
|
|
4172
|
+
}
|
|
4173
|
+
if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && LOCAL_HOSTS.includes(parsed.hostname))) {
|
|
4174
|
+
throw new ConfigError(`WEBHOOK_URL must be an https URL (http only for localhost) but is "${url}".`);
|
|
4175
|
+
}
|
|
4176
|
+
if (!secret) {
|
|
4177
|
+
throw new ConfigError(
|
|
4178
|
+
`WEBHOOK_SECRET is not set. Here is a newly generated secret you can use (keep it secret; your webhook receiver needs the same one):
|
|
4179
|
+
|
|
4180
|
+
WEBHOOK_SECRET=${generateWebhookSecret()}
|
|
4181
|
+
`
|
|
4182
|
+
);
|
|
4183
|
+
}
|
|
4184
|
+
const problem = checkWebhookSecret(secret);
|
|
4185
|
+
if (problem) {
|
|
4186
|
+
throw new ConfigError(`WEBHOOK_SECRET ${problem}. Generate one with: npx @madauth/server generate-webhook-secret`);
|
|
4187
|
+
}
|
|
4188
|
+
const selected = new Set((events ?? "").split(",").map((e) => e.trim()).filter(Boolean));
|
|
4189
|
+
const allTypes = `All types: ${WEBHOOK_TYPES.join(", ")}. See "Webhooks" in docs/server.md.`;
|
|
4190
|
+
if (!selected.size) {
|
|
4191
|
+
throw new ConfigError(
|
|
4192
|
+
`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:
|
|
4193
|
+
|
|
4194
|
+
WEBHOOK_EVENTS=email.verify,email.reset,email.already_registered,email.no_password
|
|
4195
|
+
|
|
4196
|
+
For one that only wants to know who signed in:
|
|
4197
|
+
|
|
4198
|
+
WEBHOOK_EVENTS=user.signed_in
|
|
4199
|
+
|
|
4200
|
+
` + allTypes
|
|
4201
|
+
);
|
|
4202
|
+
}
|
|
4203
|
+
const unknown = [...selected].filter((e) => !WEBHOOK_TYPES.includes(e));
|
|
4204
|
+
if (unknown.length) {
|
|
4205
|
+
const renamed = unknown.filter((e) => e in RENAMED_TYPES).map((e) => `${e} is now ${RENAMED_TYPES[e]}`);
|
|
4206
|
+
throw new ConfigError(
|
|
4207
|
+
`WEBHOOK_EVENTS: unknown type ${unknown.join(", ")}. ${renamed.length ? `${renamed.join("; ")}. ` : ""}Known types: ${WEBHOOK_TYPES.join(", ")}.`
|
|
4208
|
+
);
|
|
4209
|
+
}
|
|
4210
|
+
return { url: parsed.href, secret, events: selected };
|
|
4211
|
+
}
|
|
4212
|
+
|
|
4213
|
+
// src/password.ts
|
|
4214
|
+
import { createHash, createHmac as createHmac2, hkdfSync, randomBytes as randomBytes2, randomInt, scrypt, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
4215
|
+
var HASH_PARAMS = { logN: 15, r: 8, p: 3 };
|
|
4216
|
+
var KEY_LENGTH = 64;
|
|
4217
|
+
var SALT_LENGTH = 16;
|
|
4218
|
+
var MAX_PASSWORD_LENGTH = 256;
|
|
4219
|
+
var MAX_EMAIL_LENGTH = 254;
|
|
4220
|
+
function derive2(password, salt, { logN, r, p }) {
|
|
4221
|
+
const N = 2 ** logN;
|
|
4222
|
+
return new Promise(
|
|
4223
|
+
(resolve, reject) => scrypt(
|
|
4224
|
+
password.normalize("NFC"),
|
|
4225
|
+
salt,
|
|
4226
|
+
KEY_LENGTH,
|
|
4227
|
+
{ N, r, p, maxmem: 256 * N * r + 1024 * 1024 },
|
|
4228
|
+
(err, key) => err ? reject(err) : resolve(key)
|
|
4229
|
+
)
|
|
4230
|
+
);
|
|
4231
|
+
}
|
|
4232
|
+
async function hashPassword(password, params = HASH_PARAMS) {
|
|
4233
|
+
const salt = randomBytes2(SALT_LENGTH);
|
|
4234
|
+
const key = await derive2(password, salt, params);
|
|
4235
|
+
return ["scrypt", params.logN, params.r, params.p, salt.toString("base64url"), key.toString("base64url")].join("$");
|
|
4236
|
+
}
|
|
4237
|
+
async function verifyPassword(password, stored) {
|
|
4238
|
+
const [scheme, logN, r, p, salt, hash] = stored.split("$");
|
|
4239
|
+
const params = { logN: Number(logN), r: Number(r), p: Number(p) };
|
|
4240
|
+
if (scheme !== "scrypt" || !salt || !hash || !Object.values(params).every((n) => Number.isInteger(n) && n > 0)) {
|
|
4241
|
+
return { ok: false, needsRehash: false };
|
|
4242
|
+
}
|
|
4243
|
+
const expected = Buffer.from(hash, "base64url");
|
|
4244
|
+
const key = await derive2(password, Buffer.from(salt, "base64url"), params);
|
|
4245
|
+
const ok = key.length === expected.length && timingSafeEqual2(key, expected);
|
|
4246
|
+
const needsRehash = params.logN !== HASH_PARAMS.logN || params.r !== HASH_PARAMS.r || params.p !== HASH_PARAMS.p;
|
|
4247
|
+
return { ok, needsRehash };
|
|
4248
|
+
}
|
|
4249
|
+
var dummyHash;
|
|
4250
|
+
async function verifyAgainstDummy(password) {
|
|
4251
|
+
dummyHash ??= hashPassword(randomBytes2(16).toString("hex"));
|
|
4252
|
+
await verifyPassword(password, await dummyHash);
|
|
4253
|
+
}
|
|
4254
|
+
function checkPasswordPolicy(password, minLength) {
|
|
4255
|
+
if (typeof password !== "string") return "password is missing";
|
|
4256
|
+
const length = [...password].length;
|
|
4257
|
+
if (length < minLength) return `The password must have at least ${minLength} characters.`;
|
|
4258
|
+
if (length > MAX_PASSWORD_LENGTH) return `The password must have at most ${MAX_PASSWORD_LENGTH} characters.`;
|
|
4259
|
+
return null;
|
|
4260
|
+
}
|
|
4261
|
+
function normalizeEmail(email) {
|
|
4262
|
+
return email.trim().toLowerCase();
|
|
4263
|
+
}
|
|
4264
|
+
function isValidEmail(email) {
|
|
4265
|
+
return typeof email === "string" && email.trim().length <= MAX_EMAIL_LENGTH && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim());
|
|
4266
|
+
}
|
|
4267
|
+
function generateLinkToken() {
|
|
4268
|
+
return randomBytes2(32).toString("base64url");
|
|
4269
|
+
}
|
|
4270
|
+
function hashLinkToken(token) {
|
|
4271
|
+
return createHash("sha256").update(token).digest("base64url");
|
|
4272
|
+
}
|
|
4273
|
+
function generateCode() {
|
|
4274
|
+
return randomInt(0, 1e6).toString().padStart(6, "0");
|
|
4275
|
+
}
|
|
4276
|
+
function deriveCodeKey(signingKeyD) {
|
|
4277
|
+
return Buffer.from(hkdfSync("sha256", Buffer.from(signingKeyD, "base64url"), "", "madauth e-mail code", 32));
|
|
4278
|
+
}
|
|
4279
|
+
function hashCode(key, code) {
|
|
4280
|
+
return createHmac2("sha256", key).update(code).digest("base64url");
|
|
4281
|
+
}
|
|
4282
|
+
function safeEqual(a, b) {
|
|
4283
|
+
const x = Buffer.from(a);
|
|
4284
|
+
const y = Buffer.from(b);
|
|
4285
|
+
return x.length === y.length && timingSafeEqual2(x, y);
|
|
4061
4286
|
}
|
|
4062
4287
|
|
|
4063
4288
|
// src/users.ts
|
|
4064
|
-
import { randomBytes as
|
|
4289
|
+
import { randomBytes as randomBytes3 } from "crypto";
|
|
4065
4290
|
|
|
4066
4291
|
// src/settings.ts
|
|
4067
|
-
var SIGN_IN_METHODS = ["google", "password"];
|
|
4292
|
+
var SIGN_IN_METHODS = ["google", "password", "totp"];
|
|
4293
|
+
var METHODS_WITH_SECOND_FACTOR = ["google", "password"];
|
|
4294
|
+
var SECOND_FACTOR_POLICIES = ["none", "optional", "required"];
|
|
4068
4295
|
var METHODS_SETTING = "methods";
|
|
4069
|
-
|
|
4296
|
+
var isSignInMethod = (value) => SIGN_IN_METHODS.includes(value);
|
|
4297
|
+
var isPolicy = (value) => SECOND_FACTOR_POLICIES.includes(value);
|
|
4298
|
+
function parseMethodSettings(input, options = {}) {
|
|
4070
4299
|
if (!input || typeof input !== "object" || Array.isArray(input)) return null;
|
|
4071
4300
|
const settings = {};
|
|
4072
|
-
for (const [
|
|
4073
|
-
if (!
|
|
4074
|
-
|
|
4301
|
+
for (const [method, value] of Object.entries(input)) {
|
|
4302
|
+
if (!isSignInMethod(method)) return null;
|
|
4303
|
+
if (typeof value === "boolean") {
|
|
4304
|
+
if (!options.booleans) return null;
|
|
4305
|
+
if (value) settings[method] = {};
|
|
4306
|
+
continue;
|
|
4307
|
+
}
|
|
4308
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
4309
|
+
const setting = {};
|
|
4310
|
+
for (const [field, fieldValue] of Object.entries(value)) {
|
|
4311
|
+
if (field !== "secondFactor" || method === "totp" || !isPolicy(fieldValue)) return null;
|
|
4312
|
+
setting.secondFactor = fieldValue;
|
|
4313
|
+
}
|
|
4314
|
+
settings[method] = setting;
|
|
4075
4315
|
}
|
|
4076
4316
|
return settings;
|
|
4077
4317
|
}
|
|
4318
|
+
function isConfigured(config, method) {
|
|
4319
|
+
if (method === "google") return !!config.google;
|
|
4320
|
+
if (method === "password") return !!config.password;
|
|
4321
|
+
return true;
|
|
4322
|
+
}
|
|
4323
|
+
function canSignUp(config, method) {
|
|
4324
|
+
return method === "totp" ? !!config.emailVerification : isConfigured(config, method);
|
|
4325
|
+
}
|
|
4326
|
+
function initialMethods(config) {
|
|
4327
|
+
const methods = {};
|
|
4328
|
+
if (config.google) methods.google = { secondFactor: "none" };
|
|
4329
|
+
if (config.password) methods.password = { secondFactor: "none" };
|
|
4330
|
+
if (!config.google && !config.password && config.emailVerification) methods.totp = {};
|
|
4331
|
+
return methods;
|
|
4332
|
+
}
|
|
4078
4333
|
var MAX_TRIES = 3;
|
|
4079
4334
|
var Settings = class {
|
|
4080
|
-
|
|
4335
|
+
/** `initial` is what counts until the methods are set, see {@link initialMethods}. */
|
|
4336
|
+
constructor(store, initial = {}) {
|
|
4081
4337
|
this.store = store;
|
|
4338
|
+
this.initial = initial;
|
|
4339
|
+
this.warned = false;
|
|
4082
4340
|
}
|
|
4083
4341
|
/** The setting's value, or undefined if it was never set. */
|
|
4084
4342
|
async get(id) {
|
|
@@ -4115,32 +4373,53 @@ var Settings = class {
|
|
|
4115
4373
|
async set(id, value, updatedBy) {
|
|
4116
4374
|
await this.change(id, () => value, updatedBy);
|
|
4117
4375
|
}
|
|
4118
|
-
/** Which sign-in methods are on;
|
|
4376
|
+
/** Which sign-in methods are on, with their settings; the initial ones until an admin set them. */
|
|
4119
4377
|
async methods() {
|
|
4120
|
-
return
|
|
4378
|
+
return this.current(await this.get(METHODS_SETTING));
|
|
4121
4379
|
}
|
|
4122
4380
|
/** Changes which methods are on, see {@link change}; `apply` gets the methods as they are now. */
|
|
4123
4381
|
async changeMethods(apply, updatedBy) {
|
|
4124
|
-
return this.change(METHODS_SETTING, (current) => apply(
|
|
4382
|
+
return this.change(METHODS_SETTING, (current) => apply(this.current(current)), updatedBy);
|
|
4383
|
+
}
|
|
4384
|
+
/** The stored methods, or the initial ones if nothing (usable) is stored. */
|
|
4385
|
+
current(stored) {
|
|
4386
|
+
if (stored === void 0) return this.initial;
|
|
4387
|
+
const parsed = parseMethodSettings(stored);
|
|
4388
|
+
if (parsed) return parsed;
|
|
4389
|
+
if (!this.warned) {
|
|
4390
|
+
this.warned = true;
|
|
4391
|
+
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.");
|
|
4392
|
+
}
|
|
4393
|
+
return this.initial;
|
|
4125
4394
|
}
|
|
4126
4395
|
};
|
|
4127
|
-
function isOn(method,
|
|
4128
|
-
return
|
|
4396
|
+
function isOn(method, configured, methods) {
|
|
4397
|
+
return configured(method) && methods[method] !== void 0;
|
|
4398
|
+
}
|
|
4399
|
+
function policyOf(method, methods) {
|
|
4400
|
+
return methods[method]?.secondFactor ?? "none";
|
|
4129
4401
|
}
|
|
4130
|
-
function anyOn(
|
|
4131
|
-
return SIGN_IN_METHODS.some((method) =>
|
|
4402
|
+
function anyOn(signsUp, methods) {
|
|
4403
|
+
return SIGN_IN_METHODS.some((method) => signsUp(method) && methods[method] !== void 0);
|
|
4404
|
+
}
|
|
4405
|
+
function authenticatorOn(configured, methods) {
|
|
4406
|
+
if (isOn("totp", configured, methods)) return true;
|
|
4407
|
+
return METHODS_WITH_SECOND_FACTOR.some((method) => isOn(method, configured, methods) && policyOf(method, methods) !== "none");
|
|
4132
4408
|
}
|
|
4133
4409
|
|
|
4134
4410
|
// src/users.ts
|
|
4135
4411
|
var MAX_CODE_ATTEMPTS = 5;
|
|
4136
4412
|
var MAX_WRONG_CODES = 10;
|
|
4137
|
-
var newId = (prefix) => `${prefix}_${
|
|
4413
|
+
var newId = (prefix) => `${prefix}_${randomBytes3(16).toString("base64url")}`;
|
|
4138
4414
|
function passwordAccountKey(userId) {
|
|
4139
4415
|
return `password:${userId}`;
|
|
4140
4416
|
}
|
|
4141
4417
|
function googleAccountKey(sub) {
|
|
4142
4418
|
return `google:${sub}`;
|
|
4143
4419
|
}
|
|
4420
|
+
function totpAccountKey(userId) {
|
|
4421
|
+
return `totp:${userId}`;
|
|
4422
|
+
}
|
|
4144
4423
|
function toMadauthUser(user, profile = {}) {
|
|
4145
4424
|
const result = { id: user.id, email: user.email };
|
|
4146
4425
|
const name = user.name ?? profile.name;
|
|
@@ -4169,18 +4448,32 @@ var Users = class {
|
|
|
4169
4448
|
async passwordAccount(userId) {
|
|
4170
4449
|
return this.findAccountByKey(passwordAccountKey(userId));
|
|
4171
4450
|
}
|
|
4172
|
-
/**
|
|
4173
|
-
async
|
|
4451
|
+
/** The user's authenticator app, if they set one up. */
|
|
4452
|
+
async totpAccount(userId) {
|
|
4453
|
+
return this.findAccountByKey(totpAccountKey(userId));
|
|
4454
|
+
}
|
|
4455
|
+
/** Whether the user has any way to sign in; a sign-up with the authenticator app has none until it is set up. */
|
|
4456
|
+
async hasAccounts(userId) {
|
|
4457
|
+
return (await this.store.findMany("account", { userId }, { limit: 1 })).length > 0;
|
|
4458
|
+
}
|
|
4459
|
+
/**
|
|
4460
|
+
* The ways the user can sign in, from their accounts: e.g. `['google']` for a user without a password.
|
|
4461
|
+
* The authenticator app counts only when it signs in on its own (`methods` lists it); as a second
|
|
4462
|
+
* factor it is a step of another method, not a way in.
|
|
4463
|
+
*/
|
|
4464
|
+
async signInMethods(userId, methods) {
|
|
4174
4465
|
const accounts = await this.store.findMany("account", { userId });
|
|
4175
|
-
const
|
|
4176
|
-
return SIGN_IN_METHODS.filter((method) =>
|
|
4466
|
+
const keys = accounts.map((account) => String(account.key).split(":")[0]);
|
|
4467
|
+
return SIGN_IN_METHODS.filter((method) => keys.includes(method) && (method !== "totp" || methods.totp !== void 0));
|
|
4177
4468
|
}
|
|
4178
4469
|
async findAccountByKey(key) {
|
|
4179
4470
|
return await this.store.findOne("account", { key });
|
|
4180
4471
|
}
|
|
4181
4472
|
/**
|
|
4182
|
-
* Creates a user with their first account. Resolves to null if the e-mail address or the
|
|
4183
|
-
* taken. `account.key` is `password:<userId>` for a password (its hash as `secret`), or
|
|
4473
|
+
* Creates a user, with their first account if given. Resolves to null if the e-mail address or the
|
|
4474
|
+
* account key is taken. `account.key` is `password:<userId>` for a password (its hash as `secret`), or
|
|
4475
|
+
* e.g. `google:<sub>`. A sign-up with the authenticator app creates the user without an account: the app
|
|
4476
|
+
* is set up once the address is confirmed.
|
|
4184
4477
|
*/
|
|
4185
4478
|
async createUser(data) {
|
|
4186
4479
|
const user = {
|
|
@@ -4196,6 +4489,7 @@ var Users = class {
|
|
|
4196
4489
|
createdAt: Date.now()
|
|
4197
4490
|
};
|
|
4198
4491
|
if (!await this.store.create("user", user)) return null;
|
|
4492
|
+
if (!data.account) return user;
|
|
4199
4493
|
const account = typeof data.account === "function" ? data.account(user.id) : data.account;
|
|
4200
4494
|
if (!await this.linkAccount(user.id, account)) {
|
|
4201
4495
|
await this.store.delete("user", { id: user.id });
|
|
@@ -4217,6 +4511,16 @@ var Users = class {
|
|
|
4217
4511
|
async deleteAccount(id) {
|
|
4218
4512
|
await this.store.delete("account", { id });
|
|
4219
4513
|
}
|
|
4514
|
+
/** Removes a sign-in method by its key. Resolves to false if there was none. */
|
|
4515
|
+
async unlinkAccount(key) {
|
|
4516
|
+
return await this.store.delete("account", { key }) === 1;
|
|
4517
|
+
}
|
|
4518
|
+
/** Removes the user's authenticator app and recovery codes. Resolves to false if they had no app. */
|
|
4519
|
+
async removeTotp(userId) {
|
|
4520
|
+
const removed = await this.unlinkAccount(totpAccountKey(userId));
|
|
4521
|
+
await this.clearRecoveryCodes(userId);
|
|
4522
|
+
return removed;
|
|
4523
|
+
}
|
|
4220
4524
|
/** Adds a sign-in method to a user. Resolves to null if the account key is taken. */
|
|
4221
4525
|
async linkAccount(userId, account) {
|
|
4222
4526
|
const record = {
|
|
@@ -4227,10 +4531,70 @@ var Users = class {
|
|
|
4227
4531
|
failedAttempts: 0,
|
|
4228
4532
|
lockedUntil: 0,
|
|
4229
4533
|
email: account.email ?? null,
|
|
4534
|
+
lastUsedStep: account.lastUsedStep ?? 0,
|
|
4535
|
+
setupId: account.setupId ?? null,
|
|
4230
4536
|
createdAt: Date.now()
|
|
4231
4537
|
};
|
|
4232
4538
|
return await this.store.create("account", record) ? record : null;
|
|
4233
4539
|
}
|
|
4540
|
+
/**
|
|
4541
|
+
* Writes the secret of a confirmed setup over the authenticator account, only if the account still has the
|
|
4542
|
+
* setup it had when this setup started (`replaces`): the write count decides, so of two setups confirmed
|
|
4543
|
+
* at once exactly one wins, and the other is told to start again.
|
|
4544
|
+
*/
|
|
4545
|
+
async confirmTotpSetup(accountId, replaces, patch) {
|
|
4546
|
+
return await this.store.update("account", { id: accountId, setupId: replaces }, patch) === 1;
|
|
4547
|
+
}
|
|
4548
|
+
/**
|
|
4549
|
+
* Marks the time step of an accepted authenticator code as used and resets the failed attempts, only if
|
|
4550
|
+
* no other request accepted a code meanwhile: the write count decides, so the same code sent twice signs
|
|
4551
|
+
* in once.
|
|
4552
|
+
*/
|
|
4553
|
+
async useTotpStep(account, step) {
|
|
4554
|
+
const changed = await this.store.update(
|
|
4555
|
+
"account",
|
|
4556
|
+
{ id: account.id, lastUsedStep: account.lastUsedStep ?? null },
|
|
4557
|
+
{ lastUsedStep: step, failedAttempts: 0, lockedUntil: 0 }
|
|
4558
|
+
);
|
|
4559
|
+
return changed === 1;
|
|
4560
|
+
}
|
|
4561
|
+
/** Replaces the user's recovery codes with new ones, given as their hashes. */
|
|
4562
|
+
/** Stores the recovery codes of a setup (their hashes), next to whatever codes the user has. */
|
|
4563
|
+
async addRecoveryCodes(userId, setupId, hashes) {
|
|
4564
|
+
const createdAt = Date.now();
|
|
4565
|
+
for (const id of hashes) await this.store.create("recoveryCode", { id, userId, setupId, createdAt });
|
|
4566
|
+
}
|
|
4567
|
+
/** New recovery codes for the user's current app: the codes they had before stop working. */
|
|
4568
|
+
async replaceRecoveryCodes(account, hashes) {
|
|
4569
|
+
const before = await this.store.findMany("recoveryCode", { userId: account.userId });
|
|
4570
|
+
await this.addRecoveryCodes(account.userId, account.setupId ?? "", hashes);
|
|
4571
|
+
for (const old of before) await this.store.delete("recoveryCode", { id: old.id });
|
|
4572
|
+
}
|
|
4573
|
+
/**
|
|
4574
|
+
* Uses up the recovery code with this hash for the user's authenticator account. Resolves to false if it
|
|
4575
|
+
* is not theirs, belongs to another setup than the account's, or was used already.
|
|
4576
|
+
*/
|
|
4577
|
+
async consumeRecoveryCode(account, hash) {
|
|
4578
|
+
const record = await this.store.findOne("recoveryCode", { id: hash });
|
|
4579
|
+
if (!record || record.userId !== account.userId || record.setupId !== (account.setupId ?? "")) return false;
|
|
4580
|
+
return await this.store.delete("recoveryCode", { id: hash }) === 1;
|
|
4581
|
+
}
|
|
4582
|
+
/** How many recovery codes of the account's setup are left. */
|
|
4583
|
+
async countRecoveryCodes(account) {
|
|
4584
|
+
const codes = await this.store.findMany("recoveryCode", { userId: account.userId });
|
|
4585
|
+
return codes.filter((code) => code.setupId === (account.setupId ?? "")).length;
|
|
4586
|
+
}
|
|
4587
|
+
/** Deletes the user's recovery codes: all of them, or only those of one setup, or all but one setup's. */
|
|
4588
|
+
async clearRecoveryCodes(userId, which = {}) {
|
|
4589
|
+
if (which.only === void 0 && which.except === void 0) {
|
|
4590
|
+
await this.store.delete("recoveryCode", { userId });
|
|
4591
|
+
return;
|
|
4592
|
+
}
|
|
4593
|
+
for (const code of await this.store.findMany("recoveryCode", { userId })) {
|
|
4594
|
+
const match2 = which.only !== void 0 ? code.setupId === which.only : code.setupId !== which.except;
|
|
4595
|
+
if (match2) await this.store.delete("recoveryCode", { id: code.id });
|
|
4596
|
+
}
|
|
4597
|
+
}
|
|
4234
4598
|
/**
|
|
4235
4599
|
* Deletes the user and everything stored for them. Resolves to false if there is no such user (any more).
|
|
4236
4600
|
*/
|
|
@@ -4241,6 +4605,7 @@ var Users = class {
|
|
|
4241
4605
|
try {
|
|
4242
4606
|
await this.store.delete("account", { userId: user.id });
|
|
4243
4607
|
await this.store.delete("verification", { userId: user.id });
|
|
4608
|
+
await this.store.delete("recoveryCode", { userId: user.id });
|
|
4244
4609
|
} catch (e) {
|
|
4245
4610
|
console.error(`[madauth] Deleted user ${user.id}, but not all of their records: ${e.message}`);
|
|
4246
4611
|
}
|
|
@@ -4284,6 +4649,11 @@ var Users = class {
|
|
|
4284
4649
|
await this.store.create("verification", record);
|
|
4285
4650
|
return { token, code };
|
|
4286
4651
|
}
|
|
4652
|
+
/** The user ID a link token belongs to, without using the verification up; null if invalid, expired or used. */
|
|
4653
|
+
async peekLinkToken(token, purpose) {
|
|
4654
|
+
const record = await this.store.findOne("verification", { id: hashLinkToken(token), purpose });
|
|
4655
|
+
return record && record.expiresAt > Date.now() ? record.userId : null;
|
|
4656
|
+
}
|
|
4287
4657
|
/** Uses up the verification a link token belongs to. Resolves to its user ID, or null if invalid, expired or used. */
|
|
4288
4658
|
async consumeLinkToken(token, purpose) {
|
|
4289
4659
|
const id = hashLinkToken(token);
|
|
@@ -4330,83 +4700,213 @@ var Users = class {
|
|
|
4330
4700
|
}
|
|
4331
4701
|
};
|
|
4332
4702
|
|
|
4333
|
-
// src/
|
|
4334
|
-
|
|
4335
|
-
|
|
4336
|
-
"
|
|
4337
|
-
"email.reset",
|
|
4338
|
-
"email.already_registered",
|
|
4339
|
-
"email.no_password",
|
|
4340
|
-
"signup.before",
|
|
4341
|
-
"user.created",
|
|
4342
|
-
"email.verified",
|
|
4343
|
-
"email.password_reset",
|
|
4344
|
-
"user.signed_in",
|
|
4345
|
-
"user.deleted",
|
|
4346
|
-
"user.claims_changed"
|
|
4347
|
-
];
|
|
4348
|
-
var WEBHOOK_TIMEOUT_MS = 1e4;
|
|
4349
|
-
var MAX_LOCALE_LENGTH = 35;
|
|
4350
|
-
function localeOf(value) {
|
|
4351
|
-
return typeof value === "string" && value.length <= MAX_LOCALE_LENGTH ? value : void 0;
|
|
4703
|
+
// src/routes/helpers.ts
|
|
4704
|
+
async function body(c) {
|
|
4705
|
+
const data = await c.req.json().catch(() => null);
|
|
4706
|
+
return data && typeof data === "object" ? data : {};
|
|
4352
4707
|
}
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
var DEV_WEBHOOK_RECEIVER_URL = "https://github.com/inouiw/madAuth-webhooks/tree/main/dev-receiver";
|
|
4356
|
-
var WEBHOOK_TOLERANCE_SECONDS = 5 * 60;
|
|
4357
|
-
function generateWebhookSecret() {
|
|
4358
|
-
return `${SECRET_PREFIX}${randomBytes3(32).toString("base64")}`;
|
|
4708
|
+
function error(c, status, code, message2) {
|
|
4709
|
+
return c.json({ error: code, message: message2 }, status);
|
|
4359
4710
|
}
|
|
4360
|
-
|
|
4361
|
-
|
|
4362
|
-
|
|
4363
|
-
|
|
4364
|
-
|
|
4711
|
+
var unavailable = (c) => error(c, 503, "temporarily_unavailable", "E-mails can not be sent right now. Please try again later.");
|
|
4712
|
+
var accepted = (c) => c.json({}, 202);
|
|
4713
|
+
var noSession = (c) => error(c, 401, "no_session", "Please sign in first.");
|
|
4714
|
+
function redirectTarget(ctx, c, value) {
|
|
4715
|
+
const url = ctx.allowedUrl(value);
|
|
4716
|
+
return url ?? error(c, 400, "invalid_options", "redirectTo must be an absolute URL whose origin is in ALLOWED_ORIGINS.");
|
|
4365
4717
|
}
|
|
4366
|
-
|
|
4367
|
-
|
|
4368
|
-
return
|
|
4718
|
+
var MAX_NAME_LENGTH = 100;
|
|
4719
|
+
function nameOf(value) {
|
|
4720
|
+
return typeof value === "string" && value.trim() ? value.trim().slice(0, MAX_NAME_LENGTH) : null;
|
|
4369
4721
|
}
|
|
4370
|
-
function
|
|
4371
|
-
return
|
|
4722
|
+
function signInAnswer(c, step) {
|
|
4723
|
+
if ("disabled" in step) return error(c, 403, "method_disabled", "This way of signing in is switched off.");
|
|
4724
|
+
if ("next" in step) {
|
|
4725
|
+
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);
|
|
4726
|
+
}
|
|
4727
|
+
return c.json({ user: step.user });
|
|
4372
4728
|
}
|
|
4373
|
-
|
|
4374
|
-
|
|
4729
|
+
|
|
4730
|
+
// src/routes/emails.ts
|
|
4731
|
+
var MAIL_INTERVAL_MS = 60 * 1e3;
|
|
4732
|
+
var VERIFY_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
4733
|
+
var RESET_TTL_MS = 30 * 60 * 1e3;
|
|
4734
|
+
var VERIFY_LINK_PARAM = "madauth_verify";
|
|
4735
|
+
var RESET_LINK_PARAM = "madauth_reset";
|
|
4736
|
+
function createEmails(ctx, users, webhook) {
|
|
4737
|
+
const sendEmail = async (user, type, data) => {
|
|
4738
|
+
if (Date.now() - user.lastMailAt < MAIL_INTERVAL_MS) return true;
|
|
4739
|
+
const result = await webhook.call(type, { to: user.email, user: { id: user.id, name: user.name ?? void 0 }, ...data }, WEBHOOK_TIMEOUT_MS);
|
|
4740
|
+
if (!result.ok) {
|
|
4741
|
+
console.error(`[madauth] Webhook "${type}" for ${user.email} failed: ${result.reason}`);
|
|
4742
|
+
return false;
|
|
4743
|
+
}
|
|
4744
|
+
await users.updateUser(user.id, { lastMailAt: Date.now() });
|
|
4745
|
+
return true;
|
|
4746
|
+
};
|
|
4375
4747
|
return {
|
|
4376
|
-
|
|
4377
|
-
async
|
|
4378
|
-
if (
|
|
4379
|
-
const
|
|
4380
|
-
const
|
|
4381
|
-
const
|
|
4382
|
-
|
|
4383
|
-
|
|
4384
|
-
|
|
4385
|
-
|
|
4386
|
-
|
|
4387
|
-
|
|
4388
|
-
|
|
4389
|
-
|
|
4390
|
-
|
|
4391
|
-
|
|
4392
|
-
|
|
4393
|
-
|
|
4394
|
-
|
|
4395
|
-
|
|
4396
|
-
|
|
4397
|
-
|
|
4748
|
+
sendEmail,
|
|
4749
|
+
async sendLinkEmail(user, purpose, target, locale) {
|
|
4750
|
+
if (Date.now() - user.lastMailAt < MAIL_INTERVAL_MS) return true;
|
|
4751
|
+
const ttl = purpose === "verify" ? VERIFY_TTL_MS : RESET_TTL_MS;
|
|
4752
|
+
const { token, code } = await users.issueVerification(user.id, purpose, ttl);
|
|
4753
|
+
const link = new URL(target);
|
|
4754
|
+
link.hash = `${purpose === "verify" ? VERIFY_LINK_PARAM : RESET_LINK_PARAM}=${token}`;
|
|
4755
|
+
return sendEmail(user, purpose === "verify" ? "email.verify" : "email.reset", {
|
|
4756
|
+
link: link.href,
|
|
4757
|
+
code,
|
|
4758
|
+
expiresAt: Date.now() + ttl,
|
|
4759
|
+
site: target.host,
|
|
4760
|
+
locale
|
|
4761
|
+
});
|
|
4762
|
+
},
|
|
4763
|
+
async sendAccountEmail(user, type, target, locale) {
|
|
4764
|
+
if (!webhook.wants(type)) return true;
|
|
4765
|
+
const methods = await users.signInMethods(user.id, await ctx.settings.methods());
|
|
4766
|
+
return sendEmail(user, type, { link: target.href, site: target.host, locale, methods });
|
|
4767
|
+
},
|
|
4768
|
+
async consume(data, purpose) {
|
|
4769
|
+
if (typeof data.token === "string" && data.token) {
|
|
4770
|
+
return { userId: await users.consumeLinkToken(data.token, purpose), via: "link" };
|
|
4398
4771
|
}
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
|
|
4403
|
-
parsed = text ? JSON.parse(text) : void 0;
|
|
4404
|
-
} catch {
|
|
4405
|
-
parsed = void 0;
|
|
4772
|
+
if (isValidEmail(data.email) && typeof data.code === "string") {
|
|
4773
|
+
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
4774
|
+
if (!user) return { userId: null, via: "code" };
|
|
4775
|
+
return { ...await users.consumeCode(user, purpose, data.code.replace(/\s/g, "")), via: "code" };
|
|
4406
4776
|
}
|
|
4407
|
-
return {
|
|
4777
|
+
return { userId: null, via: "code" };
|
|
4778
|
+
},
|
|
4779
|
+
invalidVerification(c, { via, locked }) {
|
|
4780
|
+
if (locked) {
|
|
4781
|
+
return error(c, 429, "codes_locked", "Too many wrong codes. Please ask for a new e-mail and use the link in it.");
|
|
4782
|
+
}
|
|
4783
|
+
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.");
|
|
4784
|
+
}
|
|
4785
|
+
};
|
|
4786
|
+
}
|
|
4787
|
+
|
|
4788
|
+
// src/routes/email.ts
|
|
4789
|
+
function emailRoutes(app, ctx, users, webhook) {
|
|
4790
|
+
const emails = createEmails(ctx, users, webhook);
|
|
4791
|
+
const signUpMethod = async (userId) => await users.passwordAccount(userId) ? "password" : "totp";
|
|
4792
|
+
app.post("/auth/email/send-verification", async (c) => {
|
|
4793
|
+
const data = await body(c);
|
|
4794
|
+
const target = redirectTarget(ctx, c, data.redirectTo);
|
|
4795
|
+
if (target instanceof Response) return target;
|
|
4796
|
+
if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
|
|
4797
|
+
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
4798
|
+
if (user && !user.emailVerified && await ctx.enabled(c, await signUpMethod(user.id))) {
|
|
4799
|
+
if (!await emails.sendLinkEmail(user, "verify", target, localeOf(data.locale))) return unavailable(c);
|
|
4408
4800
|
}
|
|
4801
|
+
return accepted(c);
|
|
4802
|
+
});
|
|
4803
|
+
const disabled = (c) => error(c, 403, "method_disabled", "This way of signing in is switched off.");
|
|
4804
|
+
app.post("/auth/email/verify", async (c) => {
|
|
4805
|
+
const data = await body(c);
|
|
4806
|
+
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;
|
|
4807
|
+
if (waiting && !await ctx.enabled(c, await signUpMethod(waiting))) return disabled(c);
|
|
4808
|
+
const consumed = await emails.consume(data, "verify");
|
|
4809
|
+
const { userId, via } = consumed;
|
|
4810
|
+
const user = userId ? await users.findById(userId) : null;
|
|
4811
|
+
if (!user) return emails.invalidVerification(c, consumed);
|
|
4812
|
+
const method = await signUpMethod(user.id);
|
|
4813
|
+
if (!await ctx.enabled(c, method)) return disabled(c);
|
|
4814
|
+
await users.updateUser(user.id, { emailVerified: true, wrongCodes: 0 });
|
|
4815
|
+
const verified = { ...user, emailVerified: true, wrongCodes: 0 };
|
|
4816
|
+
await ctx.emit("email.verified", { user: toMadauthUser(verified), via });
|
|
4817
|
+
return signInAnswer(c, await ctx.secondStep(c, verified, method === "password" ? ["pwd"] : [], method));
|
|
4818
|
+
});
|
|
4819
|
+
}
|
|
4820
|
+
|
|
4821
|
+
// src/google.ts
|
|
4822
|
+
var GOOGLE_ISSUERS = ["https://accounts.google.com", "accounts.google.com"];
|
|
4823
|
+
var googleJwks;
|
|
4824
|
+
function googleKeys() {
|
|
4825
|
+
googleJwks ??= createRemoteJWKSet(new URL("https://www.googleapis.com/oauth2/v3/certs"));
|
|
4826
|
+
return googleJwks;
|
|
4827
|
+
}
|
|
4828
|
+
async function verifyGoogleIdToken(idToken, opts) {
|
|
4829
|
+
let payload;
|
|
4830
|
+
try {
|
|
4831
|
+
({ payload } = await jwtVerify(idToken, opts.keys ?? googleKeys(), {
|
|
4832
|
+
issuer: GOOGLE_ISSUERS,
|
|
4833
|
+
audience: opts.clientId,
|
|
4834
|
+
algorithms: ["RS256", "ES256"]
|
|
4835
|
+
}));
|
|
4836
|
+
} catch (e) {
|
|
4837
|
+
return { ok: false, error: "verification_failed", reason: e.message };
|
|
4838
|
+
}
|
|
4839
|
+
if (typeof payload.sub !== "string" || !payload.sub) {
|
|
4840
|
+
return { ok: false, error: "verification_failed", reason: "missing sub" };
|
|
4841
|
+
}
|
|
4842
|
+
if (payload.nonce !== opts.nonce) {
|
|
4843
|
+
return { ok: false, error: "verification_failed", reason: "nonce mismatch" };
|
|
4844
|
+
}
|
|
4845
|
+
if (typeof payload.email !== "string" || !payload.email || payload.email_verified !== true) {
|
|
4846
|
+
return { ok: false, error: "email_unverified", reason: "e-mail address not verified" };
|
|
4847
|
+
}
|
|
4848
|
+
const profile = { sub: payload.sub, email: payload.email };
|
|
4849
|
+
if (typeof payload.name === "string") profile.name = payload.name;
|
|
4850
|
+
if (typeof payload.picture === "string") profile.picture = payload.picture;
|
|
4851
|
+
return { ok: true, profile };
|
|
4852
|
+
}
|
|
4853
|
+
|
|
4854
|
+
// src/user.ts
|
|
4855
|
+
var SESSION_COOKIE = "madauth_session";
|
|
4856
|
+
var SESSION_TYP = "madauth-session+jwt";
|
|
4857
|
+
var RENEWAL_COOKIE = "madauth_renewal";
|
|
4858
|
+
var RENEWAL_TYP = "madauth-renewal+jwt";
|
|
4859
|
+
var EXPIRY_COOKIE = "madauth_session_expires";
|
|
4860
|
+
|
|
4861
|
+
// src/tokens.ts
|
|
4862
|
+
function randomString(bytes = 32) {
|
|
4863
|
+
return base64url_exports.encode(crypto.getRandomValues(new Uint8Array(bytes)));
|
|
4864
|
+
}
|
|
4865
|
+
async function signToken(keys, issuer, typ, claims, ttlSeconds) {
|
|
4866
|
+
return new SignJWT(claims).setProtectedHeader({ alg: SIGNING_ALG, kid: keys.kid, typ }).setIssuer(issuer).setIssuedAt().setExpirationTime(`${ttlSeconds}s`).sign(keys.privateKey);
|
|
4867
|
+
}
|
|
4868
|
+
async function readToken(keys, issuer, typ, token) {
|
|
4869
|
+
if (!token) return null;
|
|
4870
|
+
try {
|
|
4871
|
+
const { payload } = await jwtVerify(token, keys.publicKey, { issuer, typ, algorithms: [SIGNING_ALG] });
|
|
4872
|
+
return payload;
|
|
4873
|
+
} catch {
|
|
4874
|
+
return null;
|
|
4875
|
+
}
|
|
4876
|
+
}
|
|
4877
|
+
function signSession(keys, issuer, ttlSeconds, user, amr, extra = {}) {
|
|
4878
|
+
const claims = {
|
|
4879
|
+
...extra,
|
|
4880
|
+
sub: user.id,
|
|
4881
|
+
email: user.email,
|
|
4882
|
+
email_verified: user.email ? true : void 0,
|
|
4883
|
+
name: user.name,
|
|
4884
|
+
picture: user.picture,
|
|
4885
|
+
claims: user.claims && Object.keys(user.claims).length ? user.claims : void 0,
|
|
4886
|
+
amr
|
|
4887
|
+
};
|
|
4888
|
+
return signToken(keys, issuer, SESSION_TYP, claims, ttlSeconds);
|
|
4889
|
+
}
|
|
4890
|
+
function signRenewal(keys, issuer, ttlSeconds, user, amr, extra = {}) {
|
|
4891
|
+
const claims = {
|
|
4892
|
+
...extra,
|
|
4893
|
+
sub: user.id,
|
|
4894
|
+
email: user.email,
|
|
4895
|
+
email_verified: user.email ? true : void 0,
|
|
4896
|
+
name: user.name,
|
|
4897
|
+
picture: user.picture,
|
|
4898
|
+
amr
|
|
4409
4899
|
};
|
|
4900
|
+
return signToken(keys, issuer, RENEWAL_TYP, claims, ttlSeconds);
|
|
4901
|
+
}
|
|
4902
|
+
function userFromClaims(claims) {
|
|
4903
|
+
const user = { id: claims.sub };
|
|
4904
|
+
if (claims.email) user.email = claims.email;
|
|
4905
|
+
if (claims.name) user.name = claims.name;
|
|
4906
|
+
if (claims.picture) user.picture = claims.picture;
|
|
4907
|
+
if (claims.claims && typeof claims.claims === "object" && Object.keys(claims.claims).length) user.claims = claims.claims;
|
|
4908
|
+
if (Array.isArray(claims.amr)) user.amr = claims.amr.filter((value) => typeof value === "string");
|
|
4909
|
+
return user;
|
|
4410
4910
|
}
|
|
4411
4911
|
|
|
4412
4912
|
// src/routes/google.ts
|
|
@@ -4419,6 +4919,7 @@ var OAUTH_TTL = 10 * 60;
|
|
|
4419
4919
|
var GOOGLE_AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
4420
4920
|
var GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
|
|
4421
4921
|
var REDIRECT_ERROR_PARAM = "madauth_error";
|
|
4922
|
+
var REDIRECT_NEXT_PARAM = "madauth_next";
|
|
4422
4923
|
async function pkceChallenge(verifier) {
|
|
4423
4924
|
const digest2 = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
4424
4925
|
return base64url_exports.encode(new Uint8Array(digest2));
|
|
@@ -4430,7 +4931,7 @@ function googleRoutes(app, ctx, google) {
|
|
|
4430
4931
|
const resolveUser = async (profile, locale) => {
|
|
4431
4932
|
const key = googleAccountKey(profile.sub);
|
|
4432
4933
|
const emailNormalized = normalizeEmail(profile.email);
|
|
4433
|
-
const signedIn = (user) => ({ ok: true, user
|
|
4934
|
+
const signedIn = (user) => ({ ok: true, user });
|
|
4434
4935
|
const account = await users.findAccountByKey(key);
|
|
4435
4936
|
if (account) {
|
|
4436
4937
|
const user = await users.findById(account.userId);
|
|
@@ -4443,6 +4944,11 @@ function googleRoutes(app, ctx, google) {
|
|
|
4443
4944
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
4444
4945
|
const existing = await users.findByEmail(emailNormalized);
|
|
4445
4946
|
if (existing) {
|
|
4947
|
+
if (existing.emailVerified && await users.hasAccounts(existing.id)) {
|
|
4948
|
+
const methods = await users.signInMethods(existing.id, await ctx.settings.methods());
|
|
4949
|
+
const how = methods.map((method) => ({ password: "a password", totp: "an authenticator app", google: "Google" })[method]).join(" or ");
|
|
4950
|
+
return { ok: false, error: "other_method", message: `This e-mail address signs in with ${how || "another method"}. Use that instead of Google.`, methods };
|
|
4951
|
+
}
|
|
4446
4952
|
await users.verifyByProvider(existing);
|
|
4447
4953
|
if (!await users.linkAccount(existing.id, { key, email: profile.email })) {
|
|
4448
4954
|
const linked = await users.findAccountByKey(key);
|
|
@@ -4474,7 +4980,7 @@ function googleRoutes(app, ctx, google) {
|
|
|
4474
4980
|
};
|
|
4475
4981
|
const disabled = (c) => c.json({ error: "method_disabled", message: "Google sign-in is switched off." }, 403);
|
|
4476
4982
|
app.post("/auth/google/nonce", async (c) => {
|
|
4477
|
-
if (!await ctx.enabled("google")) return disabled(c);
|
|
4983
|
+
if (!await ctx.enabled(c, "google")) return disabled(c);
|
|
4478
4984
|
const nonce = randomString();
|
|
4479
4985
|
const token = await signToken(await keys, issuer, NONCE_TYP, { nonce }, NONCE_TTL);
|
|
4480
4986
|
setCookie(c, NONCE_COOKIE, token, {
|
|
@@ -4487,16 +4993,16 @@ function googleRoutes(app, ctx, google) {
|
|
|
4487
4993
|
return c.json({ nonce });
|
|
4488
4994
|
});
|
|
4489
4995
|
app.post("/auth/google/verify", async (c) => {
|
|
4490
|
-
if (!await ctx.enabled("google")) return disabled(c);
|
|
4491
|
-
const
|
|
4492
|
-
if (typeof
|
|
4996
|
+
if (!await ctx.enabled(c, "google")) return disabled(c);
|
|
4997
|
+
const body2 = await c.req.json().catch(() => ({}));
|
|
4998
|
+
if (typeof body2.credential !== "string" || !body2.credential) {
|
|
4493
4999
|
return c.json({ error: "verification_failed", message: "credential is missing" }, 400);
|
|
4494
5000
|
}
|
|
4495
5001
|
const nonceClaims = await readToken(await keys, issuer, NONCE_TYP, getCookie(c, NONCE_COOKIE));
|
|
4496
5002
|
if (!nonceClaims) {
|
|
4497
5003
|
return c.json({ error: "verification_failed", message: "nonce cookie missing or expired" }, 401);
|
|
4498
5004
|
}
|
|
4499
|
-
const result = await verifyGoogleIdToken(
|
|
5005
|
+
const result = await verifyGoogleIdToken(body2.credential, {
|
|
4500
5006
|
clientId: google.clientId,
|
|
4501
5007
|
nonce: nonceClaims.nonce,
|
|
4502
5008
|
keys: config.jwksResolver
|
|
@@ -4505,17 +5011,16 @@ function googleRoutes(app, ctx, google) {
|
|
|
4505
5011
|
return c.json({ error: result.error, message: result.reason }, result.error === "email_unverified" ? 403 : 401);
|
|
4506
5012
|
}
|
|
4507
5013
|
deleteCookie(c, NONCE_COOKIE, { path: "/auth/google", secure });
|
|
4508
|
-
const resolved = await resolveUser(result.profile, localeOf(
|
|
5014
|
+
const resolved = await resolveUser(result.profile, localeOf(body2.locale));
|
|
4509
5015
|
if (!resolved.ok) {
|
|
5016
|
+
if (resolved.error === "other_method") return c.json({ error: resolved.error, message: resolved.message, methods: resolved.methods }, 403);
|
|
4510
5017
|
return c.json({ error: resolved.error, message: resolved.message }, resolved.error === "signup_rejected" ? 403 : 503);
|
|
4511
5018
|
}
|
|
4512
|
-
|
|
4513
|
-
await ctx.emit("user.signed_in", { user, method: "google" });
|
|
4514
|
-
return c.json({ user });
|
|
5019
|
+
return signInAnswer(c, await ctx.secondStep(c, resolved.user, ["google"], "google", result.profile));
|
|
4515
5020
|
});
|
|
4516
5021
|
const requireCodeFlow = async (c, next) => {
|
|
4517
5022
|
if (!google.clientSecret) return c.json({ error: "not_found" }, 404);
|
|
4518
|
-
if (!await ctx.enabled("google")) return disabled(c);
|
|
5023
|
+
if (!await ctx.enabled(c, "google")) return disabled(c);
|
|
4519
5024
|
await next();
|
|
4520
5025
|
};
|
|
4521
5026
|
app.get("/auth/google/start", requireCodeFlow, async (c) => {
|
|
@@ -4553,9 +5058,10 @@ function googleRoutes(app, ctx, google) {
|
|
|
4553
5058
|
const oauth = await readToken(await keys, issuer, OAUTH_TYP, getCookie(c, OAUTH_COOKIE));
|
|
4554
5059
|
if (!oauth) return c.text("Sign-in expired or was started in another browser. Please try again.", 400);
|
|
4555
5060
|
deleteCookie(c, OAUTH_COOKIE, { path: "/auth/google", secure });
|
|
4556
|
-
const back = (
|
|
5061
|
+
const back = (error2, next) => {
|
|
4557
5062
|
const url = new URL(oauth.returnTo);
|
|
4558
|
-
if (
|
|
5063
|
+
if (error2) url.hash = `${REDIRECT_ERROR_PARAM}=${error2}`;
|
|
5064
|
+
if (next) url.hash = `${REDIRECT_NEXT_PARAM}=${next}`;
|
|
4559
5065
|
return c.redirect(url.href, 302);
|
|
4560
5066
|
};
|
|
4561
5067
|
if (c.req.query("error")) return back(c.req.query("error") === "access_denied" ? "cancelled" : "verification_failed");
|
|
@@ -4589,85 +5095,38 @@ function googleRoutes(app, ctx, google) {
|
|
|
4589
5095
|
if (!result.ok) return back(result.error);
|
|
4590
5096
|
const resolved = await resolveUser(result.profile, typeof oauth.locale === "string" ? oauth.locale : void 0);
|
|
4591
5097
|
if (!resolved.ok) return back(resolved.error);
|
|
4592
|
-
const
|
|
4593
|
-
|
|
5098
|
+
const step = await ctx.secondStep(c, resolved.user, ["google"], "google", result.profile);
|
|
5099
|
+
if ("disabled" in step) return back("method_disabled");
|
|
5100
|
+
if ("next" in step) return back(void 0, step.next);
|
|
4594
5101
|
return back();
|
|
4595
5102
|
});
|
|
4596
5103
|
}
|
|
4597
5104
|
|
|
4598
|
-
// src/routes/
|
|
5105
|
+
// src/routes/lockout.ts
|
|
4599
5106
|
var FREE_ATTEMPTS = 5;
|
|
4600
5107
|
var MAX_LOCK_MS = 15 * 60 * 1e3;
|
|
4601
|
-
|
|
4602
|
-
|
|
4603
|
-
|
|
4604
|
-
|
|
4605
|
-
|
|
4606
|
-
|
|
5108
|
+
async function countSignInAttempt(c, users, account, now = Date.now()) {
|
|
5109
|
+
const tooMany = (message2) => c.json({ error: "too_many_attempts", message: message2 }, 429);
|
|
5110
|
+
if (account.lockedUntil > now) {
|
|
5111
|
+
const seconds = Math.ceil((account.lockedUntil - now) / 1e3);
|
|
5112
|
+
return tooMany(`Too many failed attempts. Try again in ${seconds} seconds.`);
|
|
5113
|
+
}
|
|
5114
|
+
const failed = account.failedAttempts + 1;
|
|
5115
|
+
const lockedUntil = failed >= FREE_ATTEMPTS ? now + Math.min(2 ** (failed - FREE_ATTEMPTS) * 1e3, MAX_LOCK_MS) : 0;
|
|
5116
|
+
if (!await users.countAttempt(account, { failedAttempts: failed, lockedUntil })) {
|
|
5117
|
+
return tooMany("Too many attempts at once. Please try again.");
|
|
5118
|
+
}
|
|
5119
|
+
return null;
|
|
5120
|
+
}
|
|
5121
|
+
|
|
5122
|
+
// src/routes/password.ts
|
|
4607
5123
|
function passwordRoutes(app, ctx, password, users, webhook) {
|
|
4608
5124
|
const { minLength } = password;
|
|
4609
|
-
const
|
|
5125
|
+
const emails = createEmails(ctx, users, webhook);
|
|
4610
5126
|
app.use("/auth/password/*", async (c, next) => {
|
|
4611
|
-
if (!await ctx.enabled("password")) return
|
|
5127
|
+
if (!await ctx.enabled(c, "password")) return error(c, 403, "method_disabled", "E-mail & password sign-in is switched off.");
|
|
4612
5128
|
await next();
|
|
4613
5129
|
});
|
|
4614
|
-
const body = async (c) => {
|
|
4615
|
-
const data = await c.req.json().catch(() => null);
|
|
4616
|
-
return data && typeof data === "object" ? data : {};
|
|
4617
|
-
};
|
|
4618
|
-
const error = (c, status, code, message2) => c.json({ error: code, message: message2 }, status);
|
|
4619
|
-
const unavailable = (c) => error(c, 503, "temporarily_unavailable", "E-mails can not be sent right now. Please try again later.");
|
|
4620
|
-
const accepted = (c) => c.json({}, 202);
|
|
4621
|
-
const redirectTarget = (c, value) => {
|
|
4622
|
-
const url = ctx.allowedUrl(value);
|
|
4623
|
-
return url ?? error(c, 400, "invalid_options", "redirectTo must be an absolute URL whose origin is in ALLOWED_ORIGINS.");
|
|
4624
|
-
};
|
|
4625
|
-
const sendEmail = async (user, type, data) => {
|
|
4626
|
-
if (Date.now() - user.lastMailAt < MAIL_INTERVAL_MS) return true;
|
|
4627
|
-
const result = await webhook.call(type, { to: user.email, user: { id: user.id, name: user.name ?? void 0 }, ...data }, WEBHOOK_TIMEOUT_MS);
|
|
4628
|
-
if (!result.ok) {
|
|
4629
|
-
console.error(`[madauth] Webhook "${type}" for ${user.email} failed: ${result.reason}`);
|
|
4630
|
-
return false;
|
|
4631
|
-
}
|
|
4632
|
-
await users.updateUser(user.id, { lastMailAt: Date.now() });
|
|
4633
|
-
return true;
|
|
4634
|
-
};
|
|
4635
|
-
const sendLinkEmail = async (user, purpose, target, locale) => {
|
|
4636
|
-
if (Date.now() - user.lastMailAt < MAIL_INTERVAL_MS) return true;
|
|
4637
|
-
const ttl = purpose === "verify" ? VERIFY_TTL_MS : RESET_TTL_MS;
|
|
4638
|
-
const { token, code } = await users.issueVerification(user.id, purpose, ttl);
|
|
4639
|
-
const link = new URL(target);
|
|
4640
|
-
link.hash = `${purpose === "verify" ? VERIFY_LINK_PARAM : RESET_LINK_PARAM}=${token}`;
|
|
4641
|
-
return sendEmail(user, purpose === "verify" ? "email.verify" : "email.reset", {
|
|
4642
|
-
link: link.href,
|
|
4643
|
-
code,
|
|
4644
|
-
expiresAt: Date.now() + ttl,
|
|
4645
|
-
site: target.host,
|
|
4646
|
-
locale
|
|
4647
|
-
});
|
|
4648
|
-
};
|
|
4649
|
-
const sendAccountEmail = async (user, type, target, locale) => {
|
|
4650
|
-
if (!webhook.wants(type)) return true;
|
|
4651
|
-
const methods = await users.signInMethods(user.id);
|
|
4652
|
-
return sendEmail(user, type, { link: target.href, site: target.host, locale, methods });
|
|
4653
|
-
};
|
|
4654
|
-
const consume = async (data, purpose) => {
|
|
4655
|
-
if (typeof data.token === "string" && data.token) {
|
|
4656
|
-
return { userId: await users.consumeLinkToken(data.token, purpose), via: "link" };
|
|
4657
|
-
}
|
|
4658
|
-
if (isValidEmail(data.email) && typeof data.code === "string") {
|
|
4659
|
-
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
4660
|
-
if (!user) return { userId: null, via: "code" };
|
|
4661
|
-
return { ...await users.consumeCode(user, purpose, data.code.replace(/\s/g, "")), via: "code" };
|
|
4662
|
-
}
|
|
4663
|
-
return { userId: null, via: "code" };
|
|
4664
|
-
};
|
|
4665
|
-
const invalidVerification = (c, { via, locked }) => {
|
|
4666
|
-
if (locked) {
|
|
4667
|
-
return error(c, 429, "codes_locked", "Too many wrong codes. Please ask for a new e-mail and use the link in it.");
|
|
4668
|
-
}
|
|
4669
|
-
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.");
|
|
4670
|
-
};
|
|
4671
5130
|
app.post("/auth/password/signin", async (c) => {
|
|
4672
5131
|
const data = await body(c);
|
|
4673
5132
|
const wrong = () => error(c, 401, "invalid_credentials", "E-mail or password is wrong.");
|
|
@@ -4679,16 +5138,8 @@ function passwordRoutes(app, ctx, password, users, webhook) {
|
|
|
4679
5138
|
await verifyAgainstDummy(pw);
|
|
4680
5139
|
return wrong();
|
|
4681
5140
|
}
|
|
4682
|
-
const
|
|
4683
|
-
if (
|
|
4684
|
-
const seconds = Math.ceil((account.lockedUntil - now) / 1e3);
|
|
4685
|
-
return error(c, 429, "too_many_attempts", `Too many failed attempts. Try again in ${seconds} seconds.`);
|
|
4686
|
-
}
|
|
4687
|
-
const failed = account.failedAttempts + 1;
|
|
4688
|
-
const lockedUntil = failed >= FREE_ATTEMPTS ? now + Math.min(2 ** (failed - FREE_ATTEMPTS) * 1e3, MAX_LOCK_MS) : 0;
|
|
4689
|
-
if (!await users.countAttempt(account, { failedAttempts: failed, lockedUntil })) {
|
|
4690
|
-
return error(c, 429, "too_many_attempts", "Too many attempts at once. Please try again.");
|
|
4691
|
-
}
|
|
5141
|
+
const refused = await countSignInAttempt(c, users, account);
|
|
5142
|
+
if (refused) return refused;
|
|
4692
5143
|
const { ok, needsRehash } = await verifyPassword(pw, account.secret);
|
|
4693
5144
|
if (!ok) return wrong();
|
|
4694
5145
|
await users.updateAccount(account.id, {
|
|
@@ -4699,26 +5150,24 @@ function passwordRoutes(app, ctx, password, users, webhook) {
|
|
|
4699
5150
|
if (!user.emailVerified) {
|
|
4700
5151
|
return error(c, 403, "email_unverified", "Please confirm your e-mail address first. We can send the e-mail again.");
|
|
4701
5152
|
}
|
|
4702
|
-
|
|
4703
|
-
await ctx.emit("user.signed_in", { user: result, method: "password" });
|
|
4704
|
-
return c.json({ user: result });
|
|
5153
|
+
return signInAnswer(c, await ctx.secondStep(c, user, ["pwd"], "password"));
|
|
4705
5154
|
});
|
|
4706
5155
|
app.post("/auth/password/signup", async (c) => {
|
|
4707
5156
|
const data = await body(c);
|
|
4708
|
-
const target = redirectTarget(c, data.redirectTo);
|
|
5157
|
+
const target = redirectTarget(ctx, c, data.redirectTo);
|
|
4709
5158
|
if (target instanceof Response) return target;
|
|
4710
5159
|
if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
|
|
4711
5160
|
const policy = checkPasswordPolicy(data.password, minLength);
|
|
4712
5161
|
if (policy) return error(c, 400, "weak_password", policy);
|
|
4713
|
-
const name =
|
|
5162
|
+
const name = nameOf(data.name);
|
|
4714
5163
|
const email = data.email.trim();
|
|
4715
5164
|
const locale = localeOf(data.locale);
|
|
4716
5165
|
const check2 = await ctx.checkSignup({ email, name: name ?? void 0, locale, method: "password" });
|
|
4717
5166
|
if (!check2.ok) return check2.reason === "rejected" ? error(c, 403, "signup_rejected", check2.message) : unavailable(c);
|
|
4718
5167
|
const passwordHash = await hashPassword(data.password);
|
|
4719
5168
|
const existing = await users.findByEmail(normalizeEmail(email));
|
|
4720
|
-
if (existing?.emailVerified) {
|
|
4721
|
-
return await sendAccountEmail(existing, "email.already_registered", target, locale) ? accepted(c) : unavailable(c);
|
|
5169
|
+
if (existing?.emailVerified && await users.hasAccounts(existing.id)) {
|
|
5170
|
+
return await emails.sendAccountEmail(existing, "email.already_registered", target, locale) ? accepted(c) : unavailable(c);
|
|
4722
5171
|
}
|
|
4723
5172
|
if (existing) {
|
|
4724
5173
|
if (Date.now() - existing.lastMailAt < MAIL_INTERVAL_MS) return accepted(c);
|
|
@@ -4726,7 +5175,7 @@ function passwordRoutes(app, ctx, password, users, webhook) {
|
|
|
4726
5175
|
if (account) await users.updateAccount(account.id, { secret: passwordHash, failedAttempts: 0, lockedUntil: 0 });
|
|
4727
5176
|
else await users.linkAccount(existing.id, { key: passwordAccountKey(existing.id), secret: passwordHash });
|
|
4728
5177
|
await users.updateUser(existing.id, { name });
|
|
4729
|
-
return await sendLinkEmail({ ...existing, name }, "verify", target, locale) ? accepted(c) : unavailable(c);
|
|
5178
|
+
return await emails.sendLinkEmail({ ...existing, name }, "verify", target, locale) ? accepted(c) : unavailable(c);
|
|
4730
5179
|
}
|
|
4731
5180
|
const user = await users.createUser({
|
|
4732
5181
|
email,
|
|
@@ -4737,41 +5186,19 @@ function passwordRoutes(app, ctx, password, users, webhook) {
|
|
|
4737
5186
|
});
|
|
4738
5187
|
if (!user) return accepted(c);
|
|
4739
5188
|
await ctx.emit("user.created", { user: toMadauthUser(user), method: "password" });
|
|
4740
|
-
return await sendLinkEmail(user, "verify", target, locale) ? accepted(c) : unavailable(c);
|
|
4741
|
-
});
|
|
4742
|
-
app.post("/auth/password/send-verification", async (c) => {
|
|
4743
|
-
const data = await body(c);
|
|
4744
|
-
const target = redirectTarget(c, data.redirectTo);
|
|
4745
|
-
if (target instanceof Response) return target;
|
|
4746
|
-
if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
|
|
4747
|
-
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
4748
|
-
if (user && !user.emailVerified && await users.passwordAccount(user.id)) {
|
|
4749
|
-
if (!await sendLinkEmail(user, "verify", target, localeOf(data.locale))) return unavailable(c);
|
|
4750
|
-
}
|
|
4751
|
-
return accepted(c);
|
|
4752
|
-
});
|
|
4753
|
-
app.post("/auth/password/verify-email", async (c) => {
|
|
4754
|
-
const consumed = await consume(await body(c), "verify");
|
|
4755
|
-
const { userId, via } = consumed;
|
|
4756
|
-
const user = userId ? await users.findById(userId) : null;
|
|
4757
|
-
if (!user) return invalidVerification(c, consumed);
|
|
4758
|
-
await users.updateUser(user.id, { emailVerified: true, wrongCodes: 0 });
|
|
4759
|
-
const result = await ctx.startSession(c, toMadauthUser(user), ["pwd"], { sv: user.sessionVersion });
|
|
4760
|
-
await ctx.emit("email.verified", { user: result, via });
|
|
4761
|
-
await ctx.emit("user.signed_in", { user: result, method: "password" });
|
|
4762
|
-
return c.json({ user: result });
|
|
5189
|
+
return await emails.sendLinkEmail(user, "verify", target, locale) ? accepted(c) : unavailable(c);
|
|
4763
5190
|
});
|
|
4764
5191
|
app.post("/auth/password/send-reset", async (c) => {
|
|
4765
5192
|
const data = await body(c);
|
|
4766
|
-
const target = redirectTarget(c, data.redirectTo);
|
|
5193
|
+
const target = redirectTarget(ctx, c, data.redirectTo);
|
|
4767
5194
|
if (target instanceof Response) return target;
|
|
4768
5195
|
if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
|
|
4769
5196
|
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
4770
5197
|
const locale = localeOf(data.locale);
|
|
4771
5198
|
if (user && await users.passwordAccount(user.id)) {
|
|
4772
|
-
if (!await sendLinkEmail(user, "reset", target, locale)) return unavailable(c);
|
|
5199
|
+
if (!await emails.sendLinkEmail(user, "reset", target, locale)) return unavailable(c);
|
|
4773
5200
|
} else if (user) {
|
|
4774
|
-
if (!await sendAccountEmail(user, "email.no_password", target, locale)) return unavailable(c);
|
|
5201
|
+
if (!await emails.sendAccountEmail(user, "email.no_password", target, locale)) return unavailable(c);
|
|
4775
5202
|
}
|
|
4776
5203
|
return accepted(c);
|
|
4777
5204
|
});
|
|
@@ -4779,71 +5206,438 @@ function passwordRoutes(app, ctx, password, users, webhook) {
|
|
|
4779
5206
|
const data = await body(c);
|
|
4780
5207
|
const policy = checkPasswordPolicy(data.password, minLength);
|
|
4781
5208
|
if (policy) return error(c, 400, "weak_password", policy);
|
|
4782
|
-
const consumed = await consume(data, "reset");
|
|
5209
|
+
const consumed = await emails.consume(data, "reset");
|
|
4783
5210
|
const user = consumed.userId ? await users.findById(consumed.userId) : null;
|
|
4784
5211
|
const account = user ? await users.passwordAccount(user.id) : null;
|
|
4785
|
-
if (!user || !account) return invalidVerification(c, consumed);
|
|
5212
|
+
if (!user || !account) return emails.invalidVerification(c, consumed);
|
|
4786
5213
|
const secret = await hashPassword(data.password);
|
|
4787
|
-
if (!await users.updateAccount(account.id, { secret, failedAttempts: 0, lockedUntil: 0 })) return invalidVerification(c, consumed);
|
|
5214
|
+
if (!await users.updateAccount(account.id, { secret, failedAttempts: 0, lockedUntil: 0 })) return emails.invalidVerification(c, consumed);
|
|
4788
5215
|
const sessionVersion = user.sessionVersion + 1;
|
|
4789
5216
|
await users.updateUser(user.id, { emailVerified: true, sessionVersion, wrongCodes: 0 });
|
|
4790
5217
|
await users.clearVerifications(user.id);
|
|
4791
|
-
const
|
|
4792
|
-
await ctx.emit("email.password_reset", { user:
|
|
4793
|
-
await ctx.
|
|
4794
|
-
return c.json({ user: result });
|
|
5218
|
+
const reset = { ...user, emailVerified: true, sessionVersion, wrongCodes: 0 };
|
|
5219
|
+
await ctx.emit("email.password_reset", { user: toMadauthUser(reset) });
|
|
5220
|
+
return signInAnswer(c, await ctx.secondStep(c, reset, ["pwd"], "password"));
|
|
4795
5221
|
});
|
|
4796
5222
|
}
|
|
4797
5223
|
|
|
4798
|
-
// src/
|
|
4799
|
-
|
|
4800
|
-
|
|
4801
|
-
|
|
4802
|
-
|
|
4803
|
-
|
|
4804
|
-
|
|
4805
|
-
|
|
4806
|
-
|
|
4807
|
-
|
|
4808
|
-
|
|
4809
|
-
|
|
4810
|
-
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
|
|
4814
|
-
|
|
4815
|
-
|
|
4816
|
-
|
|
4817
|
-
|
|
4818
|
-
|
|
4819
|
-
|
|
4820
|
-
|
|
4821
|
-
}
|
|
4822
|
-
|
|
4823
|
-
|
|
4824
|
-
|
|
4825
|
-
|
|
4826
|
-
|
|
4827
|
-
|
|
4828
|
-
|
|
4829
|
-
|
|
4830
|
-
|
|
4831
|
-
|
|
4832
|
-
|
|
4833
|
-
|
|
4834
|
-
|
|
4835
|
-
|
|
4836
|
-
|
|
4837
|
-
|
|
4838
|
-
|
|
4839
|
-
|
|
4840
|
-
|
|
4841
|
-
|
|
4842
|
-
|
|
4843
|
-
|
|
4844
|
-
|
|
4845
|
-
|
|
4846
|
-
|
|
5224
|
+
// src/totp.ts
|
|
5225
|
+
import { createCipheriv, createDecipheriv, createHmac as createHmac3, hkdfSync as hkdfSync2, randomBytes as randomBytes4, randomInt as randomInt2 } from "crypto";
|
|
5226
|
+
var TOTP_STEP_SECONDS = 30;
|
|
5227
|
+
var TOTP_DIGITS = 6;
|
|
5228
|
+
var TOTP_WINDOW = 1;
|
|
5229
|
+
var RECOVERY_CODES = 10;
|
|
5230
|
+
var SECRET_BYTES = 20;
|
|
5231
|
+
var BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
5232
|
+
var RECOVERY_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
|
|
5233
|
+
var RECOVERY_CODE_LENGTH = 10;
|
|
5234
|
+
var ENCRYPTION_VERSION = "v1";
|
|
5235
|
+
var IV_BYTES = 12;
|
|
5236
|
+
var TAG_BYTES = 16;
|
|
5237
|
+
function base32Encode(bytes) {
|
|
5238
|
+
let bits = 0;
|
|
5239
|
+
let value = 0;
|
|
5240
|
+
let out = "";
|
|
5241
|
+
for (const byte of bytes) {
|
|
5242
|
+
value = (value << 8 | byte) & 65535;
|
|
5243
|
+
bits += 8;
|
|
5244
|
+
while (bits >= 5) {
|
|
5245
|
+
out += BASE32[value >>> bits - 5 & 31];
|
|
5246
|
+
bits -= 5;
|
|
5247
|
+
}
|
|
5248
|
+
}
|
|
5249
|
+
if (bits > 0) out += BASE32[value << 5 - bits & 31];
|
|
5250
|
+
return out;
|
|
5251
|
+
}
|
|
5252
|
+
function hotp(secret, counter, digits = TOTP_DIGITS) {
|
|
5253
|
+
const message2 = Buffer.alloc(8);
|
|
5254
|
+
message2.writeBigUInt64BE(BigInt(counter));
|
|
5255
|
+
const digest2 = createHmac3("sha1", secret).update(message2).digest();
|
|
5256
|
+
const offset = digest2[digest2.length - 1] & 15;
|
|
5257
|
+
const binary = (digest2[offset] & 127) << 24 | digest2[offset + 1] << 16 | digest2[offset + 2] << 8 | digest2[offset + 3];
|
|
5258
|
+
return (binary % 10 ** digits).toString().padStart(digits, "0");
|
|
5259
|
+
}
|
|
5260
|
+
function stepOf(nowMs) {
|
|
5261
|
+
return Math.floor(nowMs / 1e3 / TOTP_STEP_SECONDS);
|
|
5262
|
+
}
|
|
5263
|
+
function totp(secret, step, digits = TOTP_DIGITS) {
|
|
5264
|
+
return hotp(secret, step, digits);
|
|
5265
|
+
}
|
|
5266
|
+
function verifyTotp(secret, code, nowMs, window = TOTP_WINDOW) {
|
|
5267
|
+
const given = code.replace(/\s/g, "");
|
|
5268
|
+
if (!new RegExp(`^\\d{${TOTP_DIGITS}}$`).test(given)) return null;
|
|
5269
|
+
const now = stepOf(nowMs);
|
|
5270
|
+
let found = null;
|
|
5271
|
+
for (let step = now - window; step <= now + window; step++) {
|
|
5272
|
+
if (safeEqual(totp(secret, step), given) && found === null) found = step;
|
|
5273
|
+
}
|
|
5274
|
+
return found;
|
|
5275
|
+
}
|
|
5276
|
+
function generateTotpSecret() {
|
|
5277
|
+
return randomBytes4(SECRET_BYTES);
|
|
5278
|
+
}
|
|
5279
|
+
function otpauthUri(options) {
|
|
5280
|
+
const issuer = encodeURIComponent(options.issuer);
|
|
5281
|
+
const account = encodeURIComponent(options.account);
|
|
5282
|
+
const secret = base32Encode(options.secret);
|
|
5283
|
+
return `otpauth://totp/${issuer}:${account}?secret=${secret}&issuer=${issuer}&algorithm=SHA1&digits=${TOTP_DIGITS}&period=${TOTP_STEP_SECONDS}`;
|
|
5284
|
+
}
|
|
5285
|
+
function deriveTotpKey(signingKeyD) {
|
|
5286
|
+
return Buffer.from(hkdfSync2("sha256", Buffer.from(signingKeyD, "base64url"), "", "madauth totp secret", 32));
|
|
5287
|
+
}
|
|
5288
|
+
function encryptSecret(key, secret) {
|
|
5289
|
+
const iv = randomBytes4(IV_BYTES);
|
|
5290
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
5291
|
+
const encrypted = Buffer.concat([cipher.update(secret), cipher.final()]);
|
|
5292
|
+
const parts = [iv, encrypted, cipher.getAuthTag()].map((part) => part.toString("base64url"));
|
|
5293
|
+
return [ENCRYPTION_VERSION, ...parts].join(".");
|
|
5294
|
+
}
|
|
5295
|
+
function decryptSecret(key, stored) {
|
|
5296
|
+
const [version, iv, encrypted, tag2, ...rest] = stored.split(".");
|
|
5297
|
+
if (version !== ENCRYPTION_VERSION || !iv || !encrypted || !tag2 || rest.length) return null;
|
|
5298
|
+
const ivBytes = Buffer.from(iv, "base64url");
|
|
5299
|
+
const tagBytes = Buffer.from(tag2, "base64url");
|
|
5300
|
+
if (ivBytes.length !== IV_BYTES || tagBytes.length !== TAG_BYTES) return null;
|
|
5301
|
+
try {
|
|
5302
|
+
const decipher = createDecipheriv("aes-256-gcm", key, ivBytes);
|
|
5303
|
+
decipher.setAuthTag(tagBytes);
|
|
5304
|
+
return Buffer.concat([decipher.update(Buffer.from(encrypted, "base64url")), decipher.final()]);
|
|
5305
|
+
} catch {
|
|
5306
|
+
return null;
|
|
5307
|
+
}
|
|
5308
|
+
}
|
|
5309
|
+
function generateRecoveryCodes() {
|
|
5310
|
+
const codes = /* @__PURE__ */ new Set();
|
|
5311
|
+
while (codes.size < RECOVERY_CODES) {
|
|
5312
|
+
let code = "";
|
|
5313
|
+
for (let i = 0; i < RECOVERY_CODE_LENGTH; i++) code += RECOVERY_ALPHABET[randomInt2(RECOVERY_ALPHABET.length)];
|
|
5314
|
+
codes.add(`${code.slice(0, 5)}-${code.slice(5)}`);
|
|
5315
|
+
}
|
|
5316
|
+
return [...codes];
|
|
5317
|
+
}
|
|
5318
|
+
function normalizeRecoveryCode(text) {
|
|
5319
|
+
if (typeof text !== "string") return null;
|
|
5320
|
+
const code = text.toLowerCase().replace(/[\s-]/g, "");
|
|
5321
|
+
return code.length === RECOVERY_CODE_LENGTH && [...code].every((char) => RECOVERY_ALPHABET.includes(char)) ? code : null;
|
|
5322
|
+
}
|
|
5323
|
+
function hashRecoveryCode(codeKey, code) {
|
|
5324
|
+
return hashCode(codeKey, `recovery:${code}`);
|
|
5325
|
+
}
|
|
5326
|
+
|
|
5327
|
+
// src/routes/totp.ts
|
|
5328
|
+
var CHALLENGE_COOKIE = "madauth_challenge";
|
|
5329
|
+
var CHALLENGE_TYP = "madauth-challenge+jwt";
|
|
5330
|
+
var CHALLENGE_TTL = 10 * 60;
|
|
5331
|
+
var SETUP_COOKIE = "madauth_totp_setup";
|
|
5332
|
+
var SETUP_TYP = "madauth-totp-setup+jwt";
|
|
5333
|
+
var SETUP_TTL = 10 * 60;
|
|
5334
|
+
var COOKIE_PATH = "/auth/totp";
|
|
5335
|
+
var cookieOptions = (secure) => ({ path: COOKIE_PATH, httpOnly: true, secure, sameSite: "Strict" });
|
|
5336
|
+
async function setChallenge(c, ctx, challenge) {
|
|
5337
|
+
const token = await signToken(await ctx.keys, ctx.config.issuer, CHALLENGE_TYP, { ...challenge }, CHALLENGE_TTL);
|
|
5338
|
+
setCookie(c, CHALLENGE_COOKIE, token, { ...cookieOptions(ctx.secure), maxAge: CHALLENGE_TTL });
|
|
5339
|
+
}
|
|
5340
|
+
function clearChallenge(c, secure) {
|
|
5341
|
+
deleteCookie(c, CHALLENGE_COOKIE, cookieOptions(secure));
|
|
5342
|
+
}
|
|
5343
|
+
async function readChallenge(c, ctx, next) {
|
|
5344
|
+
const claims = await readToken(await ctx.keys, ctx.config.issuer, CHALLENGE_TYP, getCookie(c, CHALLENGE_COOKIE));
|
|
5345
|
+
return claims && claims.next === next && typeof claims.sub === "string" && Array.isArray(claims.amr) ? claims : null;
|
|
5346
|
+
}
|
|
5347
|
+
async function checkCode(c, ctx, keys, user, account, data, wrong = () => error(c, 401, "code_invalid", "The code is wrong or was already used.")) {
|
|
5348
|
+
const { users } = ctx;
|
|
5349
|
+
const refused = await countSignInAttempt(c, users, account);
|
|
5350
|
+
if (refused) return refused;
|
|
5351
|
+
if (typeof data.recoveryCode === "string") {
|
|
5352
|
+
const code = normalizeRecoveryCode(data.recoveryCode);
|
|
5353
|
+
if (!code || !await users.consumeRecoveryCode(account, hashRecoveryCode(keys.codeKey, code))) return wrong();
|
|
5354
|
+
await users.updateAccount(account.id, { failedAttempts: 0, lockedUntil: 0 });
|
|
5355
|
+
const remaining = await users.countRecoveryCodes(account);
|
|
5356
|
+
await ctx.emit("totp.recovery_code_used", { user: toMadauthUser(user), remaining });
|
|
5357
|
+
return "recovery_code";
|
|
5358
|
+
}
|
|
5359
|
+
if (typeof data.code !== "string" || !account.secret) return wrong();
|
|
5360
|
+
const secret = decryptSecret(keys.totpKey, account.secret);
|
|
5361
|
+
if (!secret) {
|
|
5362
|
+
console.error(`[madauth] The authenticator secret of user ${user.id} can't be read: was MADAUTH_SIGNING_KEY changed?`);
|
|
5363
|
+
return wrong();
|
|
5364
|
+
}
|
|
5365
|
+
const step = verifyTotp(secret, data.code, Date.now(), TOTP_WINDOW);
|
|
5366
|
+
if (step === null || step <= (account.lastUsedStep ?? 0) || !await users.useTotpStep(account, step)) return wrong();
|
|
5367
|
+
return "totp";
|
|
5368
|
+
}
|
|
5369
|
+
function totpRoutes(app, ctx, keys) {
|
|
5370
|
+
const { config, secure, users } = ctx;
|
|
5371
|
+
const { issuer } = config;
|
|
5372
|
+
const cookie = cookieOptions(secure);
|
|
5373
|
+
const methodsNow = (c) => ctx.methods(c);
|
|
5374
|
+
app.use(`${COOKIE_PATH}/*`, async (c, next) => {
|
|
5375
|
+
if (!authenticatorOn(ctx.configured, await methodsNow(c))) {
|
|
5376
|
+
return error(c, 403, "method_disabled", "The authenticator app is switched off.");
|
|
5377
|
+
}
|
|
5378
|
+
await next();
|
|
5379
|
+
});
|
|
5380
|
+
const standalone = async (c) => isOn("totp", ctx.configured, await methodsNow(c));
|
|
5381
|
+
const notStandalone = (c) => error(c, 403, "method_disabled", "Signing in with the authenticator app alone is switched off.");
|
|
5382
|
+
const methodStillOn = async (c, challenge) => isOn(challenge.method, ctx.configured, await methodsNow(c));
|
|
5383
|
+
const methodOff = (c) => error(c, 403, "method_disabled", "This way of signing in is switched off.");
|
|
5384
|
+
const signedIn = async (c) => {
|
|
5385
|
+
const user = await ctx.currentUser(c);
|
|
5386
|
+
return user ? users.findById(user.id) : null;
|
|
5387
|
+
};
|
|
5388
|
+
const whoSetsUp = async (c) => {
|
|
5389
|
+
const challenge = await readChallenge(c, ctx, "totp-setup");
|
|
5390
|
+
const waiting = challenge ? await users.findById(challenge.sub) : null;
|
|
5391
|
+
if (challenge && waiting && waiting.sessionVersion === challenge.sv) return { user: waiting, challenge };
|
|
5392
|
+
const user = await signedIn(c);
|
|
5393
|
+
return user ? { user, challenge: null } : null;
|
|
5394
|
+
};
|
|
5395
|
+
const newRecoveryCodes = () => {
|
|
5396
|
+
const recoveryCodes = generateRecoveryCodes();
|
|
5397
|
+
return { recoveryCodes, hashes: recoveryCodes.map((code) => hashRecoveryCode(keys.codeKey, normalizeRecoveryCode(code))) };
|
|
5398
|
+
};
|
|
5399
|
+
const issuerLabel = (c) => {
|
|
5400
|
+
if (config.totpIssuer) return config.totpIssuer;
|
|
5401
|
+
const origin = c.req.header("origin");
|
|
5402
|
+
return URL.canParse(origin ?? "") ? new URL(origin).hostname : new URL(issuer).hostname;
|
|
5403
|
+
};
|
|
5404
|
+
const finishSignIn = async (c, user, challenge, via) => {
|
|
5405
|
+
const result = await ctx.startSession(c, toMadauthUser(user, challenge.profile), [...challenge.amr, "otp"], { sv: user.sessionVersion });
|
|
5406
|
+
await ctx.emit("user.signed_in", { user: result, method: challenge.method, ...challenge.amr.length ? { secondFactor: via } : {} });
|
|
5407
|
+
return result;
|
|
5408
|
+
};
|
|
5409
|
+
app.post(`${COOKIE_PATH}/setup`, async (c) => {
|
|
5410
|
+
const who = await whoSetsUp(c);
|
|
5411
|
+
if (!who) return noSession(c);
|
|
5412
|
+
if (who.challenge && !await methodStillOn(c, who.challenge)) return methodOff(c);
|
|
5413
|
+
const existing = await users.totpAccount(who.user.id);
|
|
5414
|
+
let approved;
|
|
5415
|
+
if (existing) {
|
|
5416
|
+
const previous = await readToken(await ctx.keys, issuer, SETUP_TYP, getCookie(c, SETUP_COOKIE));
|
|
5417
|
+
const holds = previous?.sub === who.user.id && typeof previous.approved === "number" && Date.now() - previous.approved < SETUP_TTL * 1e3;
|
|
5418
|
+
if (holds) {
|
|
5419
|
+
approved = previous.approved;
|
|
5420
|
+
} else {
|
|
5421
|
+
const data = await body(c);
|
|
5422
|
+
if (data.code === void 0 && data.recoveryCode === void 0) {
|
|
5423
|
+
return error(c, 401, "code_required", "Enter a code from your current authenticator app (or a recovery code) to replace it.");
|
|
5424
|
+
}
|
|
5425
|
+
const via = await checkCode(c, ctx, keys, who.user, existing, data);
|
|
5426
|
+
if (via instanceof Response) return via;
|
|
5427
|
+
approved = Date.now();
|
|
5428
|
+
}
|
|
5429
|
+
}
|
|
5430
|
+
const secret = generateTotpSecret();
|
|
5431
|
+
const claims = {
|
|
5432
|
+
sub: who.user.id,
|
|
5433
|
+
setup: newId("tsu"),
|
|
5434
|
+
secret: encryptSecret(keys.totpKey, secret),
|
|
5435
|
+
...existing ? { replaces: existing.setupId } : {},
|
|
5436
|
+
...approved ? { approved } : {}
|
|
5437
|
+
};
|
|
5438
|
+
const token = await signToken(await ctx.keys, issuer, SETUP_TYP, claims, SETUP_TTL);
|
|
5439
|
+
setCookie(c, SETUP_COOKIE, token, { ...cookie, maxAge: SETUP_TTL });
|
|
5440
|
+
return c.json({ secret: base32Encode(secret), uri: otpauthUri({ issuer: issuerLabel(c), account: who.user.email, secret }) });
|
|
5441
|
+
});
|
|
5442
|
+
app.post(`${COOKIE_PATH}/confirm`, async (c) => {
|
|
5443
|
+
const who = await whoSetsUp(c);
|
|
5444
|
+
if (!who) return noSession(c);
|
|
5445
|
+
const { user, challenge } = who;
|
|
5446
|
+
if (challenge && !await methodStillOn(c, challenge)) return methodOff(c);
|
|
5447
|
+
const data = await body(c);
|
|
5448
|
+
const setup = await readToken(await ctx.keys, issuer, SETUP_TYP, getCookie(c, SETUP_COOKIE));
|
|
5449
|
+
const secret = setup && setup.sub === user.id && typeof setup.secret === "string" && typeof setup.setup === "string" ? decryptSecret(keys.totpKey, setup.secret) : null;
|
|
5450
|
+
if (!setup || !secret) return error(c, 400, "setup_expired", "The setup has expired or was started in another browser. Please start again.");
|
|
5451
|
+
const step = typeof data.code === "string" ? verifyTotp(secret, data.code, Date.now(), TOTP_WINDOW) : null;
|
|
5452
|
+
if (step === null) return error(c, 400, "code_invalid", "The code is wrong. Enter the current code from the app.");
|
|
5453
|
+
const { recoveryCodes, hashes } = newRecoveryCodes();
|
|
5454
|
+
await users.addRecoveryCodes(user.id, setup.setup, hashes);
|
|
5455
|
+
const patch = { secret: setup.secret, setupId: setup.setup, failedAttempts: 0, lockedUntil: 0, lastUsedStep: step };
|
|
5456
|
+
const current = setup.replaces === void 0 ? null : await users.totpAccount(user.id);
|
|
5457
|
+
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);
|
|
5458
|
+
if (!written) {
|
|
5459
|
+
await users.clearRecoveryCodes(user.id, { only: setup.setup });
|
|
5460
|
+
deleteCookie(c, SETUP_COOKIE, cookie);
|
|
5461
|
+
return error(c, 400, "setup_expired", "Your authenticator app was set up or removed elsewhere meanwhile. Please start again.");
|
|
5462
|
+
}
|
|
5463
|
+
await users.clearRecoveryCodes(user.id, { except: setup.setup });
|
|
5464
|
+
deleteCookie(c, SETUP_COOKIE, cookie);
|
|
5465
|
+
await ctx.emit("totp.enabled", { user: toMadauthUser(user) });
|
|
5466
|
+
if (!challenge) return c.json({ recoveryCodes });
|
|
5467
|
+
const result = await finishSignIn(c, user, challenge, "totp");
|
|
5468
|
+
return c.json({ user: result, recoveryCodes });
|
|
5469
|
+
});
|
|
5470
|
+
app.post(`${COOKIE_PATH}/verify`, async (c) => {
|
|
5471
|
+
const expired = () => error(c, 401, "challenge_expired", "The sign-in has expired. Please sign in again.");
|
|
5472
|
+
const challenge = await readChallenge(c, ctx, "totp");
|
|
5473
|
+
if (!challenge) return expired();
|
|
5474
|
+
if (!await methodStillOn(c, challenge)) return methodOff(c);
|
|
5475
|
+
const user = await users.findById(challenge.sub);
|
|
5476
|
+
const account = user && user.sessionVersion === challenge.sv ? await users.totpAccount(user.id) : null;
|
|
5477
|
+
if (!user || !account) return expired();
|
|
5478
|
+
const via = await checkCode(c, ctx, keys, user, account, await body(c));
|
|
5479
|
+
if (via instanceof Response) return via;
|
|
5480
|
+
return c.json({ user: await finishSignIn(c, user, challenge, via) });
|
|
5481
|
+
});
|
|
5482
|
+
app.post(`${COOKIE_PATH}/signin`, async (c) => {
|
|
5483
|
+
if (!await standalone(c)) return notStandalone(c);
|
|
5484
|
+
const data = await body(c);
|
|
5485
|
+
const wrong = () => error(c, 401, "invalid_credentials", "E-mail or code is wrong.");
|
|
5486
|
+
if (!isValidEmail(data.email)) return wrong();
|
|
5487
|
+
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
5488
|
+
const account = user ? await users.totpAccount(user.id) : null;
|
|
5489
|
+
if (!user || !user.emailVerified || !account?.secret) return wrong();
|
|
5490
|
+
const via = await checkCode(c, ctx, keys, user, account, data, wrong);
|
|
5491
|
+
if (via instanceof Response) return via;
|
|
5492
|
+
const result = await ctx.startSession(c, toMadauthUser(user), ["otp"], { sv: user.sessionVersion });
|
|
5493
|
+
await ctx.emit("user.signed_in", { user: result, method: "totp" });
|
|
5494
|
+
return c.json({ user: result });
|
|
5495
|
+
});
|
|
5496
|
+
app.post(`${COOKIE_PATH}/remove`, async (c) => {
|
|
5497
|
+
const user = await signedIn(c);
|
|
5498
|
+
if (!user) return noSession(c);
|
|
5499
|
+
const methods = await methodsNow(c);
|
|
5500
|
+
const required = METHODS_WITH_SECOND_FACTOR.filter((method) => isOn(method, ctx.configured, methods) && policyOf(method, methods) === "required");
|
|
5501
|
+
if (required.length) {
|
|
5502
|
+
const names = required.map((method) => method === "google" ? "Google" : "e-mail & password");
|
|
5503
|
+
return error(c, 403, "required_by_policy", `Signing in with ${names.join(" and ")} requires the authenticator app, so it can't be removed.`);
|
|
5504
|
+
}
|
|
5505
|
+
const account = await users.totpAccount(user.id);
|
|
5506
|
+
if (!account) return c.json({});
|
|
5507
|
+
if (!(await users.signInMethods(user.id, methods)).some((method) => method !== "totp" && isOn(method, ctx.configured, methods))) {
|
|
5508
|
+
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.");
|
|
5509
|
+
}
|
|
5510
|
+
const via = await checkCode(c, ctx, keys, user, account, await body(c));
|
|
5511
|
+
if (via instanceof Response) return via;
|
|
5512
|
+
await users.removeTotp(user.id);
|
|
5513
|
+
await ctx.emit("totp.disabled", { user: toMadauthUser(user) });
|
|
5514
|
+
return c.json({});
|
|
5515
|
+
});
|
|
5516
|
+
app.post(`${COOKIE_PATH}/recovery-codes`, async (c) => {
|
|
5517
|
+
const user = await signedIn(c);
|
|
5518
|
+
if (!user) return noSession(c);
|
|
5519
|
+
const account = await users.totpAccount(user.id);
|
|
5520
|
+
if (!account) return error(c, 400, "no_authenticator", "No authenticator app is set up.");
|
|
5521
|
+
const via = await checkCode(c, ctx, keys, user, account, await body(c));
|
|
5522
|
+
if (via instanceof Response) return via;
|
|
5523
|
+
const { recoveryCodes, hashes } = newRecoveryCodes();
|
|
5524
|
+
await users.replaceRecoveryCodes(account, hashes);
|
|
5525
|
+
return c.json({ recoveryCodes });
|
|
5526
|
+
});
|
|
5527
|
+
app.get(`${COOKIE_PATH}/status`, async (c) => {
|
|
5528
|
+
const user = await signedIn(c);
|
|
5529
|
+
if (!user) return noSession(c);
|
|
5530
|
+
const account = await users.totpAccount(user.id);
|
|
5531
|
+
return c.json({ enabled: !!account, recoveryCodesLeft: account ? await users.countRecoveryCodes(account) : 0 });
|
|
5532
|
+
});
|
|
5533
|
+
if (ctx.webhook && ctx.emailVerification) {
|
|
5534
|
+
const emails = createEmails(ctx, users, ctx.webhook);
|
|
5535
|
+
app.post(`${COOKIE_PATH}/signup`, async (c) => {
|
|
5536
|
+
if (!await standalone(c)) return notStandalone(c);
|
|
5537
|
+
const data = await body(c);
|
|
5538
|
+
const target = redirectTarget(ctx, c, data.redirectTo);
|
|
5539
|
+
if (target instanceof Response) return target;
|
|
5540
|
+
if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
|
|
5541
|
+
const name = nameOf(data.name);
|
|
5542
|
+
const email = data.email.trim();
|
|
5543
|
+
const locale = localeOf(data.locale);
|
|
5544
|
+
const check2 = await ctx.checkSignup({ email, name: name ?? void 0, locale, method: "totp" });
|
|
5545
|
+
if (!check2.ok) return check2.reason === "rejected" ? error(c, 403, "signup_rejected", check2.message) : unavailable(c);
|
|
5546
|
+
const existing = await users.findByEmail(normalizeEmail(email));
|
|
5547
|
+
if (existing?.emailVerified && await users.hasAccounts(existing.id)) {
|
|
5548
|
+
return await emails.sendAccountEmail(existing, "email.already_registered", target, locale) ? accepted(c) : unavailable(c);
|
|
5549
|
+
}
|
|
5550
|
+
if (existing) {
|
|
5551
|
+
if (Date.now() - existing.lastMailAt < MAIL_INTERVAL_MS) return accepted(c);
|
|
5552
|
+
if (!existing.emailVerified) await users.unlinkAccount(passwordAccountKey(existing.id));
|
|
5553
|
+
await users.updateUser(existing.id, { name });
|
|
5554
|
+
return await emails.sendLinkEmail({ ...existing, name }, "verify", target, locale) ? accepted(c) : unavailable(c);
|
|
5555
|
+
}
|
|
5556
|
+
const user = await users.createUser({ email, emailNormalized: normalizeEmail(email), name, emailVerified: false });
|
|
5557
|
+
if (!user) return accepted(c);
|
|
5558
|
+
await ctx.emit("user.created", { user: toMadauthUser(user), method: "totp" });
|
|
5559
|
+
return await emails.sendLinkEmail(user, "verify", target, locale) ? accepted(c) : unavailable(c);
|
|
5560
|
+
});
|
|
5561
|
+
}
|
|
5562
|
+
}
|
|
5563
|
+
|
|
5564
|
+
// src/app.ts
|
|
5565
|
+
var EVENT_TIMEOUT_MS = 5e3;
|
|
5566
|
+
function createApp(config) {
|
|
5567
|
+
const { issuer, allowedOrigins, sessionTtlSeconds, renewalTtlSeconds, cookieDomain } = config;
|
|
5568
|
+
const keys = importSigningKeys(config.signingKey);
|
|
5569
|
+
const secure = issuer.startsWith("https:");
|
|
5570
|
+
const isAllowedOrigin = (origin) => !!origin && allowedOrigins.includes(origin);
|
|
5571
|
+
const codeKey = deriveCodeKey(config.signingKey.d);
|
|
5572
|
+
const users = new Users(config.store, codeKey);
|
|
5573
|
+
const methods = methodConfig(config);
|
|
5574
|
+
const settings = new Settings(config.store, initialMethods(methods));
|
|
5575
|
+
const configured = (method) => isConfigured(methods, method);
|
|
5576
|
+
const signsUp = (method) => canSignUp(methods, method);
|
|
5577
|
+
const emailVerification = sendsVerificationEmails(config);
|
|
5578
|
+
const totpKeys = { totpKey: deriveTotpKey(config.signingKey.d), codeKey };
|
|
5579
|
+
const issueSession = async (c, user, amr, extra) => {
|
|
5580
|
+
const signingKeys = await keys;
|
|
5581
|
+
const [sessionToken, renewalToken] = await Promise.all([
|
|
5582
|
+
signSession(signingKeys, issuer, sessionTtlSeconds, user, amr, extra),
|
|
5583
|
+
signRenewal(signingKeys, issuer, renewalTtlSeconds, user, amr, extra)
|
|
5584
|
+
]);
|
|
5585
|
+
setCookie(c, SESSION_COOKIE, sessionToken, {
|
|
5586
|
+
path: "/",
|
|
5587
|
+
domain: cookieDomain,
|
|
5588
|
+
httpOnly: true,
|
|
5589
|
+
secure,
|
|
5590
|
+
sameSite: "Lax",
|
|
5591
|
+
maxAge: sessionTtlSeconds
|
|
5592
|
+
});
|
|
5593
|
+
setCookie(c, RENEWAL_COOKIE, renewalToken, {
|
|
5594
|
+
path: "/auth",
|
|
5595
|
+
httpOnly: true,
|
|
5596
|
+
secure,
|
|
5597
|
+
sameSite: "Lax",
|
|
5598
|
+
maxAge: renewalTtlSeconds
|
|
5599
|
+
});
|
|
5600
|
+
setCookie(c, EXPIRY_COOKIE, String(Math.floor(Date.now() / 1e3) + sessionTtlSeconds), {
|
|
5601
|
+
path: "/",
|
|
5602
|
+
domain: cookieDomain,
|
|
5603
|
+
secure,
|
|
5604
|
+
sameSite: "Lax",
|
|
5605
|
+
maxAge: renewalTtlSeconds
|
|
5606
|
+
});
|
|
5607
|
+
};
|
|
5608
|
+
const clearSession = (c) => {
|
|
5609
|
+
deleteCookie(c, SESSION_COOKIE, { path: "/", domain: cookieDomain, secure });
|
|
5610
|
+
deleteCookie(c, RENEWAL_COOKIE, { path: "/auth", secure });
|
|
5611
|
+
deleteCookie(c, EXPIRY_COOKIE, { path: "/", domain: cookieDomain, secure });
|
|
5612
|
+
};
|
|
5613
|
+
const tokenClaims = async (c, cookie, typ) => readToken(await keys, issuer, typ, getCookie(c, cookie));
|
|
5614
|
+
const checked = async (claims) => {
|
|
5615
|
+
const stored = await users.findById(claims.sub);
|
|
5616
|
+
if (!stored || stored.sessionVersion !== claims.sv) return null;
|
|
5617
|
+
const { claims: _, ...user } = userFromClaims(claims);
|
|
5618
|
+
const current = claimsFromJson(stored.claims);
|
|
5619
|
+
return Object.keys(current).length ? { ...user, claims: current } : user;
|
|
5620
|
+
};
|
|
5621
|
+
const currentSession = async (c) => {
|
|
5622
|
+
const session = await tokenClaims(c, SESSION_COOKIE, SESSION_TYP);
|
|
5623
|
+
const user = session ? await checked(session) : null;
|
|
5624
|
+
const upToDate = session && user && sameClaims(user.claims, session.claims) && Date.now() / 1e3 - session.iat <= sessionTtlSeconds / 2;
|
|
5625
|
+
if (upToDate) return user;
|
|
5626
|
+
const renewal = await tokenClaims(c, RENEWAL_COOKIE, RENEWAL_TYP);
|
|
5627
|
+
const sameSignIn = session && user && renewal && renewal.sub === session.sub && renewal.sv === session.sv && renewal.email === session.email;
|
|
5628
|
+
const renewed = sameSignIn ? user : renewal ? await checked(renewal) : null;
|
|
5629
|
+
if (renewal && renewed) {
|
|
5630
|
+
await issueSession(c, renewed, renewal.amr, { sv: renewal.sv });
|
|
5631
|
+
return renewed;
|
|
5632
|
+
}
|
|
5633
|
+
return user;
|
|
5634
|
+
};
|
|
5635
|
+
const methodsOf = /* @__PURE__ */ new WeakMap();
|
|
5636
|
+
const ctx = {
|
|
5637
|
+
config,
|
|
5638
|
+
keys,
|
|
5639
|
+
secure,
|
|
5640
|
+
isAllowedOrigin,
|
|
4847
5641
|
allowedUrl(value) {
|
|
4848
5642
|
let url;
|
|
4849
5643
|
try {
|
|
@@ -4856,14 +5650,42 @@ function createApp(config) {
|
|
|
4856
5650
|
return url;
|
|
4857
5651
|
},
|
|
4858
5652
|
async startSession(c, user, amr, extra) {
|
|
5653
|
+
clearChallenge(c, secure);
|
|
4859
5654
|
await issueSession(c, user, amr, extra);
|
|
4860
|
-
return user;
|
|
5655
|
+
return { ...user, amr };
|
|
4861
5656
|
},
|
|
5657
|
+
async secondStep(c, user, amr, method, profile) {
|
|
5658
|
+
const methods2 = await this.methods(c);
|
|
5659
|
+
if (!isOn(method, configured, methods2)) return { disabled: true };
|
|
5660
|
+
const enrolled = await users.totpAccount(user.id);
|
|
5661
|
+
const policy = method === "totp" ? "required" : policyOf(method, methods2);
|
|
5662
|
+
const next = policy === "none" ? null : enrolled ? "totp" : policy === "required" ? "totp-setup" : null;
|
|
5663
|
+
if (next) {
|
|
5664
|
+
clearSession(c);
|
|
5665
|
+
const known = { ...profile?.name ? { name: profile.name } : {}, ...profile?.picture ? { picture: profile.picture } : {} };
|
|
5666
|
+
await setChallenge(c, this, { sub: user.id, sv: user.sessionVersion, amr, method, next, ...Object.keys(known).length ? { profile: known } : {} });
|
|
5667
|
+
return { next, method };
|
|
5668
|
+
}
|
|
5669
|
+
const result = await this.startSession(c, toMadauthUser(user, profile), amr, { sv: user.sessionVersion });
|
|
5670
|
+
await this.emit("user.signed_in", { user: result, method });
|
|
5671
|
+
return { user: result };
|
|
5672
|
+
},
|
|
5673
|
+
currentUser: currentSession,
|
|
4862
5674
|
users,
|
|
4863
5675
|
settings,
|
|
4864
5676
|
webhook: config.webhook ? createWebhookClient(config.webhook, config.webhookFetch) : void 0,
|
|
4865
|
-
|
|
4866
|
-
|
|
5677
|
+
configured,
|
|
5678
|
+
emailVerification,
|
|
5679
|
+
methods(c) {
|
|
5680
|
+
let reading = methodsOf.get(c.req.raw);
|
|
5681
|
+
if (!reading) {
|
|
5682
|
+
reading = settings.methods();
|
|
5683
|
+
methodsOf.set(c.req.raw, reading);
|
|
5684
|
+
}
|
|
5685
|
+
return reading;
|
|
5686
|
+
},
|
|
5687
|
+
async enabled(c, method) {
|
|
5688
|
+
return isOn(method, configured, await this.methods(c));
|
|
4867
5689
|
},
|
|
4868
5690
|
async checkSignup(data) {
|
|
4869
5691
|
if (!this.webhook?.wants("signup.before")) return { ok: true };
|
|
@@ -4881,7 +5703,9 @@ function createApp(config) {
|
|
|
4881
5703
|
},
|
|
4882
5704
|
async emit(type, data) {
|
|
4883
5705
|
if (!this.webhook?.wants(type)) return;
|
|
4884
|
-
const
|
|
5706
|
+
const user = data.user;
|
|
5707
|
+
const payload = user && typeof user === "object" && "amr" in user ? { ...data, user: { ...user, amr: void 0 } } : data;
|
|
5708
|
+
const result = await this.webhook.call(type, payload, EVENT_TIMEOUT_MS);
|
|
4885
5709
|
if (!result.ok) console.error(`[madauth] Webhook "${type}" failed: ${result.reason}`);
|
|
4886
5710
|
}
|
|
4887
5711
|
};
|
|
@@ -4909,37 +5733,20 @@ function createApp(config) {
|
|
|
4909
5733
|
return c.json({ keys: [publicJwk] });
|
|
4910
5734
|
});
|
|
4911
5735
|
app.get("/auth/config", async (c) => {
|
|
4912
|
-
const
|
|
4913
|
-
const on = (method) => isOn(method,
|
|
5736
|
+
const current = await ctx.methods(c);
|
|
5737
|
+
const on = (method) => isOn(method, configured, current);
|
|
4914
5738
|
return c.json({
|
|
4915
|
-
google: on("google") ? { clientId: config.google.clientId, codeFlow: !!config.google.clientSecret } : null,
|
|
4916
|
-
password: on("password") ? { minLength: config.password.minLength } : null
|
|
5739
|
+
google: on("google") ? { clientId: config.google.clientId, codeFlow: !!config.google.clientSecret, secondFactor: policyOf("google", current) } : null,
|
|
5740
|
+
password: on("password") ? { minLength: config.password.minLength, secondFactor: policyOf("password", current) } : null,
|
|
5741
|
+
// The app can be set up when it is in use at all; `signIn` says whether it signs in on its own.
|
|
5742
|
+
totp: authenticatorOn(configured, current) ? { signIn: on("totp"), signUp: on("totp") && emailVerification } : null,
|
|
5743
|
+
email: { verification: emailVerification }
|
|
4917
5744
|
});
|
|
4918
5745
|
});
|
|
5746
|
+
if (emailVerification && ctx.webhook) emailRoutes(app, ctx, users, ctx.webhook);
|
|
4919
5747
|
if (config.google) googleRoutes(app, ctx, config.google);
|
|
4920
5748
|
if (config.password && ctx.webhook) passwordRoutes(app, ctx, config.password, users, ctx.webhook);
|
|
4921
|
-
|
|
4922
|
-
const checked = async (claims) => {
|
|
4923
|
-
const stored = await users.findById(claims.sub);
|
|
4924
|
-
if (!stored || stored.sessionVersion !== claims.sv) return null;
|
|
4925
|
-
const { claims: _, ...user } = userFromClaims(claims);
|
|
4926
|
-
const current = claimsFromJson(stored.claims);
|
|
4927
|
-
return Object.keys(current).length ? { ...user, claims: current } : user;
|
|
4928
|
-
};
|
|
4929
|
-
const currentSession = async (c) => {
|
|
4930
|
-
const session = await tokenClaims(c, SESSION_COOKIE, SESSION_TYP);
|
|
4931
|
-
const user = session ? await checked(session) : null;
|
|
4932
|
-
const upToDate = session && user && sameClaims(user.claims, session.claims) && Date.now() / 1e3 - session.iat <= sessionTtlSeconds / 2;
|
|
4933
|
-
if (upToDate) return user;
|
|
4934
|
-
const renewal = await tokenClaims(c, RENEWAL_COOKIE, RENEWAL_TYP);
|
|
4935
|
-
const sameSignIn = session && user && renewal && renewal.sub === session.sub && renewal.sv === session.sv && renewal.email === session.email;
|
|
4936
|
-
const renewed = sameSignIn ? user : renewal ? await checked(renewal) : null;
|
|
4937
|
-
if (renewal && renewed) {
|
|
4938
|
-
await issueSession(c, renewed, renewal.amr, { sv: renewal.sv });
|
|
4939
|
-
return renewed;
|
|
4940
|
-
}
|
|
4941
|
-
return user;
|
|
4942
|
-
};
|
|
5749
|
+
totpRoutes(app, ctx, totpKeys);
|
|
4943
5750
|
app.get("/auth/session", async (c) => {
|
|
4944
5751
|
const user = await currentSession(c);
|
|
4945
5752
|
if (!user) {
|
|
@@ -4950,11 +5757,23 @@ function createApp(config) {
|
|
|
4950
5757
|
});
|
|
4951
5758
|
app.post("/auth/logout", (c) => {
|
|
4952
5759
|
clearSession(c);
|
|
5760
|
+
clearChallenge(c, secure);
|
|
4953
5761
|
return c.body(null, 204);
|
|
4954
5762
|
});
|
|
4955
5763
|
app.post("/auth/account/delete", async (c) => {
|
|
4956
5764
|
const user = await currentSession(c);
|
|
4957
5765
|
if (!user) return c.json({ error: "no_session" }, 401);
|
|
5766
|
+
const stored = await users.findById(user.id);
|
|
5767
|
+
const protects = stored && authenticatorOn(configured, await ctx.methods(c));
|
|
5768
|
+
const authenticator = protects ? await users.totpAccount(user.id) : null;
|
|
5769
|
+
if (stored && authenticator) {
|
|
5770
|
+
const data = await body(c);
|
|
5771
|
+
if (data.code === void 0 && data.recoveryCode === void 0) {
|
|
5772
|
+
return error(c, 401, "code_required", "Enter a code from your authenticator app (or a recovery code) to delete your account.");
|
|
5773
|
+
}
|
|
5774
|
+
const via = await checkCode(c, ctx, totpKeys, stored, authenticator, data);
|
|
5775
|
+
if (via instanceof Response) return via;
|
|
5776
|
+
}
|
|
4958
5777
|
await users.deleteById(user.id);
|
|
4959
5778
|
clearSession(c);
|
|
4960
5779
|
await ctx.emit("user.deleted", { user });
|
|
@@ -4970,10 +5789,6 @@ function createApp(config) {
|
|
|
4970
5789
|
}
|
|
4971
5790
|
return { email };
|
|
4972
5791
|
};
|
|
4973
|
-
const body = async (c) => {
|
|
4974
|
-
const data = await c.req.json().catch(() => null);
|
|
4975
|
-
return data && typeof data === "object" ? data : {};
|
|
4976
|
-
};
|
|
4977
5792
|
const invalidEmail = (c) => c.json({ error: "invalid_email", message: "This is not a valid e-mail address." }, 400);
|
|
4978
5793
|
const noUser = (c) => c.json({ error: "user_not_found", message: "No user has this e-mail address." }, 404);
|
|
4979
5794
|
app.post("/auth/admin/claims/get", async (c) => {
|
|
@@ -5007,9 +5822,16 @@ function createApp(config) {
|
|
|
5007
5822
|
await ctx.emit("user.claims_changed", { userId: user.id, email, claims, by: caller.email });
|
|
5008
5823
|
return c.json({ email, userId: user.id, claims });
|
|
5009
5824
|
});
|
|
5010
|
-
const settingsAnswer = (
|
|
5825
|
+
const settingsAnswer = (methods2) => ({
|
|
5011
5826
|
methods: Object.fromEntries(
|
|
5012
|
-
SIGN_IN_METHODS.map((method) => [
|
|
5827
|
+
SIGN_IN_METHODS.map((method) => [
|
|
5828
|
+
method,
|
|
5829
|
+
{
|
|
5830
|
+
configured: configured(method),
|
|
5831
|
+
enabled: isOn(method, configured, methods2),
|
|
5832
|
+
...method === "totp" ? {} : { secondFactor: policyOf(method, methods2) }
|
|
5833
|
+
}
|
|
5834
|
+
])
|
|
5013
5835
|
)
|
|
5014
5836
|
});
|
|
5015
5837
|
app.post("/auth/admin/settings/get", async (c) => {
|
|
@@ -5022,212 +5844,30 @@ function createApp(config) {
|
|
|
5022
5844
|
if ("answer" in caller) return caller.answer;
|
|
5023
5845
|
const data = await body(c);
|
|
5024
5846
|
const invalid2 = (message2) => c.json({ error: "invalid_settings", message: message2 }, 400);
|
|
5025
|
-
const
|
|
5026
|
-
if (!
|
|
5027
|
-
|
|
5028
|
-
|
|
5029
|
-
|
|
5030
|
-
}
|
|
5031
|
-
|
|
5032
|
-
return
|
|
5847
|
+
const next = parseMethodSettings(data.methods, { booleans: true });
|
|
5848
|
+
if (!next) {
|
|
5849
|
+
return invalid2(
|
|
5850
|
+
`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.`
|
|
5851
|
+
);
|
|
5852
|
+
}
|
|
5853
|
+
const methods2 = await settings.changeMethods(() => anyOn(signsUp, next) ? next : null, caller.email);
|
|
5854
|
+
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.");
|
|
5855
|
+
return c.json(settingsAnswer(methods2));
|
|
5856
|
+
});
|
|
5857
|
+
app.post("/auth/admin/totp/remove", async (c) => {
|
|
5858
|
+
const caller = await admin(c);
|
|
5859
|
+
if ("answer" in caller) return caller.answer;
|
|
5860
|
+
const data = await body(c);
|
|
5861
|
+
if (!isValidEmail(data.email)) return invalidEmail(c);
|
|
5862
|
+
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
5863
|
+
if (!user) return noUser(c);
|
|
5864
|
+
const removed = await users.removeTotp(user.id);
|
|
5865
|
+
if (removed) await ctx.emit("totp.disabled", { user: toMadauthUser(user), by: caller.email });
|
|
5866
|
+
return c.json({ email: user.emailNormalized, userId: user.id, enabled: false });
|
|
5033
5867
|
});
|
|
5034
5868
|
return app;
|
|
5035
5869
|
}
|
|
5036
5870
|
|
|
5037
|
-
// src/config.ts
|
|
5038
|
-
async function resolveEnv(source = process.env) {
|
|
5039
|
-
return typeof source === "function" ? source() : source;
|
|
5040
|
-
}
|
|
5041
|
-
var DEFAULT_SESSION_TTL = 8 * 60 * 60;
|
|
5042
|
-
var DEFAULT_RENEWAL_TTL = 30 * 24 * 60 * 60;
|
|
5043
|
-
var DEFAULT_PASSWORD_MIN_LENGTH = 8;
|
|
5044
|
-
var ConfigError = class extends Error {
|
|
5045
|
-
constructor() {
|
|
5046
|
-
super(...arguments);
|
|
5047
|
-
this.name = "ConfigError";
|
|
5048
|
-
}
|
|
5049
|
-
};
|
|
5050
|
-
function parseOrigin(name, value) {
|
|
5051
|
-
let url;
|
|
5052
|
-
try {
|
|
5053
|
-
url = new URL(value);
|
|
5054
|
-
} catch {
|
|
5055
|
-
throw new ConfigError(`${name}: "${value}" is not a URL.`);
|
|
5056
|
-
}
|
|
5057
|
-
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
5058
|
-
throw new ConfigError(`${name}: "${value}" must be an http(s) URL.`);
|
|
5059
|
-
}
|
|
5060
|
-
return url.origin;
|
|
5061
|
-
}
|
|
5062
|
-
function readEnv(env, name) {
|
|
5063
|
-
return env[name]?.trim().replace(/^(['"])(.*)\1$/s, "$2").trim() || void 0;
|
|
5064
|
-
}
|
|
5065
|
-
async function loadConfig(env, overrides = {}) {
|
|
5066
|
-
const read = (name) => readEnv(env, name);
|
|
5067
|
-
const require2 = (name) => {
|
|
5068
|
-
const value = read(name);
|
|
5069
|
-
if (!value) throw new ConfigError(`${name} is not set. See docs/server.md.`);
|
|
5070
|
-
return value;
|
|
5071
|
-
};
|
|
5072
|
-
const issuerValue = require2("MADAUTH_ISSUER");
|
|
5073
|
-
parseOrigin("MADAUTH_ISSUER", issuerValue);
|
|
5074
|
-
const issuer = issuerValue.replace(/\/+$/, "");
|
|
5075
|
-
const keyValue = read("MADAUTH_SIGNING_KEY");
|
|
5076
|
-
if (!keyValue) {
|
|
5077
|
-
const key = await generateSigningKey();
|
|
5078
|
-
throw new ConfigError(
|
|
5079
|
-
`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):
|
|
5080
|
-
|
|
5081
|
-
MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
|
|
5082
|
-
`
|
|
5083
|
-
);
|
|
5084
|
-
}
|
|
5085
|
-
let signingKey;
|
|
5086
|
-
try {
|
|
5087
|
-
signingKey = JSON.parse(keyValue);
|
|
5088
|
-
assertPrivateSigningJwk(signingKey);
|
|
5089
|
-
} catch (e) {
|
|
5090
|
-
const reason = e instanceof SyntaxError ? "is not valid JSON" : e.message;
|
|
5091
|
-
throw new ConfigError(`MADAUTH_SIGNING_KEY ${reason}. Generate one with: npx @madauth/server generate-key`);
|
|
5092
|
-
}
|
|
5093
|
-
const allowedOrigins = require2("ALLOWED_ORIGINS").split(",").map((o) => o.trim()).filter(Boolean).map((o) => parseOrigin("ALLOWED_ORIGINS", o));
|
|
5094
|
-
const clientId = read("GOOGLE_CLIENT_ID");
|
|
5095
|
-
if (clientId && !clientId.endsWith(".apps.googleusercontent.com")) {
|
|
5096
|
-
throw new ConfigError(`GOOGLE_CLIENT_ID must end with ".apps.googleusercontent.com" but is "${clientId}".`);
|
|
5097
|
-
}
|
|
5098
|
-
const webhook = webhookFromEnv(read("WEBHOOK_URL"), read("WEBHOOK_SECRET"), read("WEBHOOK_EVENTS"));
|
|
5099
|
-
const sendsEmails = !!webhook && REQUIRED_EMAIL_TYPES.every((type) => webhook.events.has(type));
|
|
5100
|
-
const password = sendsEmails ? { minLength: passwordMinLength(read("PASSWORD_MIN_LENGTH")) } : void 0;
|
|
5101
|
-
if (!clientId && !password) {
|
|
5102
|
-
throw new ConfigError(
|
|
5103
|
-
`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,email.no_password. See docs/server.md.`
|
|
5104
|
-
);
|
|
5105
|
-
}
|
|
5106
|
-
const store = overrides.store ?? await storeFromDatabaseUrl(read("DATABASE_URL"));
|
|
5107
|
-
if (!store) {
|
|
5108
|
-
throw new ConfigError(
|
|
5109
|
-
"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."
|
|
5110
|
-
);
|
|
5111
|
-
}
|
|
5112
|
-
const ttlValue = read("SESSION_TTL");
|
|
5113
|
-
const sessionTtlSeconds = ttlValue ? Number(ttlValue) : DEFAULT_SESSION_TTL;
|
|
5114
|
-
if (!Number.isInteger(sessionTtlSeconds) || sessionTtlSeconds < 60) {
|
|
5115
|
-
throw new ConfigError(`SESSION_TTL must be a whole number of seconds (at least 60) but is "${ttlValue}".`);
|
|
5116
|
-
}
|
|
5117
|
-
const renewalValue = read("SESSION_RENEWAL_TTL");
|
|
5118
|
-
const renewalTtlSeconds = renewalValue ? Number(renewalValue) : Math.max(DEFAULT_RENEWAL_TTL, sessionTtlSeconds);
|
|
5119
|
-
if (!Number.isInteger(renewalTtlSeconds) || renewalTtlSeconds < sessionTtlSeconds) {
|
|
5120
|
-
throw new ConfigError(
|
|
5121
|
-
`SESSION_RENEWAL_TTL must be a whole number of seconds, not less than SESSION_TTL (${sessionTtlSeconds}), but is "${renewalValue}".`
|
|
5122
|
-
);
|
|
5123
|
-
}
|
|
5124
|
-
return {
|
|
5125
|
-
issuer,
|
|
5126
|
-
signingKey,
|
|
5127
|
-
allowedOrigins,
|
|
5128
|
-
sessionTtlSeconds,
|
|
5129
|
-
renewalTtlSeconds,
|
|
5130
|
-
cookieDomain: read("COOKIE_DOMAIN"),
|
|
5131
|
-
google: clientId ? { clientId, clientSecret: read("GOOGLE_CLIENT_SECRET") } : void 0,
|
|
5132
|
-
store,
|
|
5133
|
-
password,
|
|
5134
|
-
webhook
|
|
5135
|
-
};
|
|
5136
|
-
}
|
|
5137
|
-
function passwordMinLength(value) {
|
|
5138
|
-
const minLength = value ? Number(value) : DEFAULT_PASSWORD_MIN_LENGTH;
|
|
5139
|
-
if (!Number.isInteger(minLength) || minLength < 1 || minLength > 128) {
|
|
5140
|
-
throw new ConfigError(`PASSWORD_MIN_LENGTH must be a whole number from 1 to 128 but is "${value}".`);
|
|
5141
|
-
}
|
|
5142
|
-
return minLength;
|
|
5143
|
-
}
|
|
5144
|
-
async function storeFromDatabaseUrl(url) {
|
|
5145
|
-
if (!url) return void 0;
|
|
5146
|
-
if (url.startsWith("dynamodb:")) return dynamoDbStore(url.slice("dynamodb:".length));
|
|
5147
|
-
if (!url.startsWith("sqlite:")) {
|
|
5148
|
-
throw new ConfigError(
|
|
5149
|
-
`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).`
|
|
5150
|
-
);
|
|
5151
|
-
}
|
|
5152
|
-
const path = url.slice("sqlite:".length);
|
|
5153
|
-
if (!path) throw new ConfigError("DATABASE_URL needs a file path, e.g. sqlite:/data/madauth.db");
|
|
5154
|
-
const { createSqliteAdapter: createSqliteAdapter2 } = await Promise.resolve().then(() => (init_sqlite(), sqlite_exports));
|
|
5155
|
-
try {
|
|
5156
|
-
return createSqliteAdapter2(path);
|
|
5157
|
-
} catch (e) {
|
|
5158
|
-
throw new ConfigError(`DATABASE_URL: could not open "${path}": ${e.message}`);
|
|
5159
|
-
}
|
|
5160
|
-
}
|
|
5161
|
-
async function dynamoDbStore(tableName2) {
|
|
5162
|
-
if (!tableName2) throw new ConfigError("DATABASE_URL needs a table name, e.g. dynamodb:madauth");
|
|
5163
|
-
if (!/^[\w.-]{3,255}$/.test(tableName2)) {
|
|
5164
|
-
throw new ConfigError(
|
|
5165
|
-
`DATABASE_URL=dynamodb: needs a DynamoDB table name (3 to 255 letters, digits, _ . -) but has "${tableName2}"`
|
|
5166
|
-
);
|
|
5167
|
-
}
|
|
5168
|
-
try {
|
|
5169
|
-
await import("@aws-sdk/client-dynamodb");
|
|
5170
|
-
} catch (e) {
|
|
5171
|
-
throw new ConfigError(
|
|
5172
|
-
`DATABASE_URL=dynamodb: needs the AWS SDK. Install it next to madAuth: npm install @aws-sdk/client-dynamodb (${e.message})`
|
|
5173
|
-
);
|
|
5174
|
-
}
|
|
5175
|
-
const { createDynamoDbAdapter: createDynamoDbAdapter2 } = await Promise.resolve().then(() => (init_dynamodb(), dynamodb_exports));
|
|
5176
|
-
return createDynamoDbAdapter2({ tableName: tableName2 });
|
|
5177
|
-
}
|
|
5178
|
-
var RENAMED_TYPES = { "password.reset": "email.password_reset", "roles.changed": "user.claims_changed" };
|
|
5179
|
-
var LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]", "host.docker.internal"];
|
|
5180
|
-
function webhookFromEnv(url, secret, events) {
|
|
5181
|
-
if (!url) {
|
|
5182
|
-
if (secret || events) throw new ConfigError("WEBHOOK_SECRET and WEBHOOK_EVENTS need WEBHOOK_URL.");
|
|
5183
|
-
return void 0;
|
|
5184
|
-
}
|
|
5185
|
-
let parsed;
|
|
5186
|
-
try {
|
|
5187
|
-
parsed = new URL(url);
|
|
5188
|
-
} catch {
|
|
5189
|
-
throw new ConfigError(`WEBHOOK_URL: "${url}" is not a URL.`);
|
|
5190
|
-
}
|
|
5191
|
-
if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && LOCAL_HOSTS.includes(parsed.hostname))) {
|
|
5192
|
-
throw new ConfigError(`WEBHOOK_URL must be an https URL (http only for localhost) but is "${url}".`);
|
|
5193
|
-
}
|
|
5194
|
-
if (!secret) {
|
|
5195
|
-
throw new ConfigError(
|
|
5196
|
-
`WEBHOOK_SECRET is not set. Here is a newly generated secret you can use (keep it secret; your webhook receiver needs the same one):
|
|
5197
|
-
|
|
5198
|
-
WEBHOOK_SECRET=${generateWebhookSecret()}
|
|
5199
|
-
`
|
|
5200
|
-
);
|
|
5201
|
-
}
|
|
5202
|
-
const problem = checkWebhookSecret(secret);
|
|
5203
|
-
if (problem) {
|
|
5204
|
-
throw new ConfigError(`WEBHOOK_SECRET ${problem}. Generate one with: npx @madauth/server generate-webhook-secret`);
|
|
5205
|
-
}
|
|
5206
|
-
const selected = new Set((events ?? "").split(",").map((e) => e.trim()).filter(Boolean));
|
|
5207
|
-
const allTypes = `All types: ${WEBHOOK_TYPES.join(", ")}. See "Webhooks" in docs/server.md.`;
|
|
5208
|
-
if (!selected.size) {
|
|
5209
|
-
throw new ConfigError(
|
|
5210
|
-
`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:
|
|
5211
|
-
|
|
5212
|
-
WEBHOOK_EVENTS=email.verify,email.reset,email.already_registered,email.no_password
|
|
5213
|
-
|
|
5214
|
-
For one that only wants to know who signed in:
|
|
5215
|
-
|
|
5216
|
-
WEBHOOK_EVENTS=user.signed_in
|
|
5217
|
-
|
|
5218
|
-
` + allTypes
|
|
5219
|
-
);
|
|
5220
|
-
}
|
|
5221
|
-
const unknown = [...selected].filter((e) => !WEBHOOK_TYPES.includes(e));
|
|
5222
|
-
if (unknown.length) {
|
|
5223
|
-
const renamed = unknown.filter((e) => e in RENAMED_TYPES).map((e) => `${e} is now ${RENAMED_TYPES[e]}`);
|
|
5224
|
-
throw new ConfigError(
|
|
5225
|
-
`WEBHOOK_EVENTS: unknown type ${unknown.join(", ")}. ${renamed.length ? `${renamed.join("; ")}. ` : ""}Known types: ${WEBHOOK_TYPES.join(", ")}.`
|
|
5226
|
-
);
|
|
5227
|
-
}
|
|
5228
|
-
return { url: parsed.href, secret, events: selected };
|
|
5229
|
-
}
|
|
5230
|
-
|
|
5231
5871
|
// src/entry/lambda.ts
|
|
5232
5872
|
function createHandler(options = {}) {
|
|
5233
5873
|
const { env, ...overrides } = options;
|