@madauth/server 0.2.0-beta.2 → 0.3.0-beta.2
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/dist/bin.js +99 -32
- package/dist/{chunk-6EJDVZCD.js → chunk-3QRKWLR7.js} +10 -2
- package/dist/{chunk-CNL562LZ.js → chunk-56MOVFO4.js} +24 -2
- package/dist/{chunk-XNBWZZHT.js → chunk-CD7Y4T66.js} +11 -9
- package/dist/{chunk-7CTAB3QV.js → chunk-DCDTO5LG.js} +2 -2
- package/dist/{chunk-YALS7OMV.js → chunk-DVVBIFIV.js} +222 -122
- package/dist/{chunk-5CKEMC6M.js → chunk-TI35AZ2U.js} +227 -88
- package/dist/{config-BkxADgtp.d.ts → config-Dg2fZuWR.d.ts} +7 -6
- 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 +30 -7
- package/dist/index.js +15 -7
- package/dist/{schema-BSgYVRpd.d.ts → schema-3yhCc2jH.d.ts} +17 -9
- package/dist/sqlite.js +2 -2
- package/dist/standalone/azure.mjs +847 -597
- package/dist/standalone/lambda.mjs +847 -597
- package/dist/standalone/main.mjs +594 -344
- 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 +12 -0
- package/dist/verify.d.ts +1 -1
- package/dist/verify.js +1 -1
- package/dist/{webhook-ltwulgcB.d.ts → webhook-j5u9Kv9l.d.ts} +1 -1
- package/dist/webhook.d.ts +1 -1
- package/dist/webhook.js +1 -1
- package/package.json +1 -1
- package/dist/user-C1A3BfOo.d.ts +0 -12
package/dist/standalone/main.mjs
CHANGED
|
@@ -38,8 +38,9 @@ var init_schema = __esm({
|
|
|
38
38
|
"src/store/schema.ts"() {
|
|
39
39
|
"use strict";
|
|
40
40
|
madauthSchema = {
|
|
41
|
-
version:
|
|
41
|
+
version: 4,
|
|
42
42
|
models: {
|
|
43
|
+
/** Someone who signed in, whichever way: the e-mail address is the identity shared by the sign-in methods. */
|
|
43
44
|
user: {
|
|
44
45
|
fields: {
|
|
45
46
|
id: { type: "string", primaryKey: true },
|
|
@@ -51,10 +52,12 @@ var init_schema = __esm({
|
|
|
51
52
|
lastMailAt: { type: "number" },
|
|
52
53
|
/** Wrong e-mail codes in a row, counted across e-mails (since version 2). */
|
|
53
54
|
wrongCodes: { type: "number" },
|
|
55
|
+
/** What admins attached to the user, as JSON, e.g. `{"roles":["admin"]}` (since version 4). */
|
|
56
|
+
claims: { type: "string", nullable: true },
|
|
54
57
|
createdAt: { type: "number" }
|
|
55
58
|
}
|
|
56
59
|
},
|
|
57
|
-
/** How a user signs in. `key` is
|
|
60
|
+
/** How a user signs in. `key` is `password:<userId>` or `google:<sub>`; later e.g. `totp:<userId>`. */
|
|
58
61
|
account: {
|
|
59
62
|
fields: {
|
|
60
63
|
id: { type: "string", primaryKey: true },
|
|
@@ -63,6 +66,8 @@ var init_schema = __esm({
|
|
|
63
66
|
secret: { type: "string", nullable: true },
|
|
64
67
|
failedAttempts: { type: "number" },
|
|
65
68
|
lockedUntil: { type: "number" },
|
|
69
|
+
/** The address the provider reported at the last sign-in, e.g. Google's current primary address (since version 4). */
|
|
70
|
+
email: { type: "string", nullable: true },
|
|
66
71
|
createdAt: { type: "number" }
|
|
67
72
|
}
|
|
68
73
|
},
|
|
@@ -77,16 +82,13 @@ var init_schema = __esm({
|
|
|
77
82
|
expiresAt: { type: "number" }
|
|
78
83
|
}
|
|
79
84
|
},
|
|
80
|
-
/**
|
|
81
|
-
|
|
82
|
-
* apply whichever way its owner signs in. `roles` holds the names separated by spaces.
|
|
83
|
-
*/
|
|
84
|
-
role: {
|
|
85
|
+
/** A setting admins change at runtime, as JSON (since version 4), e.g. `methods`: which sign-in methods are on. */
|
|
86
|
+
setting: {
|
|
85
87
|
fields: {
|
|
86
88
|
id: { type: "string", primaryKey: true },
|
|
87
|
-
|
|
89
|
+
value: { type: "string" },
|
|
88
90
|
updatedAt: { type: "number" },
|
|
89
|
-
/** The address of the admin who set
|
|
91
|
+
/** The address of the admin who set it; empty when set from the command line. */
|
|
90
92
|
updatedBy: { type: "string", nullable: true }
|
|
91
93
|
}
|
|
92
94
|
}
|
|
@@ -141,8 +143,30 @@ var init_sql = __esm({
|
|
|
141
143
|
sql: (dialect) => `ALTER TABLE ${tableName("user")} ADD COLUMN ${columnName("wrongCodes")} ${columnTypes[dialect].number} NOT NULL DEFAULT 0;`
|
|
142
144
|
},
|
|
143
145
|
{
|
|
146
|
+
// Roles were per e-mail address in their own table; since version 4 they are one of the user's claims.
|
|
144
147
|
version: 3,
|
|
145
|
-
sql: (dialect) => createTablesSql(dialect, {
|
|
148
|
+
sql: (dialect) => createTablesSql(dialect, {
|
|
149
|
+
version: 3,
|
|
150
|
+
models: {
|
|
151
|
+
role: {
|
|
152
|
+
fields: {
|
|
153
|
+
id: { type: "string", primaryKey: true },
|
|
154
|
+
roles: { type: "string" },
|
|
155
|
+
updatedAt: { type: "number" },
|
|
156
|
+
updatedBy: { type: "string", nullable: true }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
version: 4,
|
|
164
|
+
sql: (dialect) => [
|
|
165
|
+
`ALTER TABLE ${tableName("user")} ADD COLUMN ${columnName("claims")} ${columnTypes[dialect].string};`,
|
|
166
|
+
`ALTER TABLE ${tableName("account")} ADD COLUMN ${columnName("email")} ${columnTypes[dialect].string};`,
|
|
167
|
+
`DROP TABLE ${tableName("role")};`,
|
|
168
|
+
createTablesSql(dialect, { version: 4, models: { setting: madauthSchema.models.setting } })
|
|
169
|
+
].join("\n")
|
|
146
170
|
}
|
|
147
171
|
];
|
|
148
172
|
}
|
|
@@ -29996,14 +30020,20 @@ var WEBHOOK_TYPES = [
|
|
|
29996
30020
|
"email.verify",
|
|
29997
30021
|
"email.reset",
|
|
29998
30022
|
"email.already_registered",
|
|
30023
|
+
"email.no_password",
|
|
29999
30024
|
"signup.before",
|
|
30000
30025
|
"user.created",
|
|
30001
30026
|
"email.verified",
|
|
30002
|
-
"
|
|
30027
|
+
"email.password_reset",
|
|
30003
30028
|
"user.signed_in",
|
|
30004
30029
|
"user.deleted",
|
|
30005
|
-
"
|
|
30030
|
+
"user.claims_changed"
|
|
30006
30031
|
];
|
|
30032
|
+
var WEBHOOK_TIMEOUT_MS = 1e4;
|
|
30033
|
+
var MAX_LOCALE_LENGTH = 35;
|
|
30034
|
+
function localeOf(value) {
|
|
30035
|
+
return typeof value === "string" && value.length <= MAX_LOCALE_LENGTH ? value : void 0;
|
|
30036
|
+
}
|
|
30007
30037
|
var REQUIRED_EMAIL_TYPES = ["email.verify", "email.reset"];
|
|
30008
30038
|
var SECRET_PREFIX = "whsec_";
|
|
30009
30039
|
var DEV_WEBHOOK_RECEIVER_URL = "https://github.com/inouiw/madAuth-webhooks/tree/main/dev-receiver";
|
|
@@ -30124,19 +30154,18 @@ MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
|
|
|
30124
30154
|
if (clientId && !clientId.endsWith(".apps.googleusercontent.com")) {
|
|
30125
30155
|
throw new ConfigError(`GOOGLE_CLIENT_ID must end with ".apps.googleusercontent.com" but is "${clientId}".`);
|
|
30126
30156
|
}
|
|
30127
|
-
const
|
|
30128
|
-
const
|
|
30129
|
-
|
|
30157
|
+
const webhook = webhookFromEnv(read("WEBHOOK_URL"), read("WEBHOOK_SECRET"), read("WEBHOOK_EVENTS"));
|
|
30158
|
+
const sendsEmails = !!webhook && REQUIRED_EMAIL_TYPES.every((type) => webhook.events.has(type));
|
|
30159
|
+
const password = sendsEmails ? { minLength: passwordMinLength(read("PASSWORD_MIN_LENGTH")) } : void 0;
|
|
30160
|
+
if (!clientId && !password) {
|
|
30130
30161
|
throw new ConfigError(
|
|
30131
|
-
`
|
|
30162
|
+
`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.`
|
|
30132
30163
|
);
|
|
30133
30164
|
}
|
|
30134
30165
|
const store = overrides.store ?? await storeFromDatabaseUrl(read("DATABASE_URL"));
|
|
30135
|
-
|
|
30136
|
-
if (store) password = { minLength: passwordMinLength(read("PASSWORD_MIN_LENGTH")), store };
|
|
30137
|
-
if (!clientId && !password) {
|
|
30166
|
+
if (!store) {
|
|
30138
30167
|
throw new ConfigError(
|
|
30139
|
-
"
|
|
30168
|
+
"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."
|
|
30140
30169
|
);
|
|
30141
30170
|
}
|
|
30142
30171
|
const ttlValue = read("SESSION_TTL");
|
|
@@ -30159,6 +30188,7 @@ MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
|
|
|
30159
30188
|
renewalTtlSeconds,
|
|
30160
30189
|
cookieDomain: read("COOKIE_DOMAIN"),
|
|
30161
30190
|
google: clientId ? { clientId, clientSecret: read("GOOGLE_CLIENT_SECRET") } : void 0,
|
|
30191
|
+
store,
|
|
30162
30192
|
password,
|
|
30163
30193
|
webhook
|
|
30164
30194
|
};
|
|
@@ -30204,8 +30234,9 @@ async function dynamoDbStore(tableName2) {
|
|
|
30204
30234
|
const { createDynamoDbAdapter: createDynamoDbAdapter2 } = await Promise.resolve().then(() => (init_dynamodb(), dynamodb_exports));
|
|
30205
30235
|
return createDynamoDbAdapter2({ tableName: tableName2 });
|
|
30206
30236
|
}
|
|
30237
|
+
var RENAMED_TYPES = { "password.reset": "email.password_reset", "roles.changed": "user.claims_changed" };
|
|
30207
30238
|
var LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]", "host.docker.internal"];
|
|
30208
|
-
function webhookFromEnv(url, secret, events
|
|
30239
|
+
function webhookFromEnv(url, secret, events) {
|
|
30209
30240
|
if (!url) {
|
|
30210
30241
|
if (secret || events) throw new ConfigError("WEBHOOK_SECRET and WEBHOOK_EVENTS need WEBHOOK_URL.");
|
|
30211
30242
|
return void 0;
|
|
@@ -30235,24 +30266,22 @@ WEBHOOK_SECRET=${generateWebhookSecret()}
|
|
|
30235
30266
|
const allTypes = `All types: ${WEBHOOK_TYPES.join(", ")}. See "Webhooks" in docs/server.md.`;
|
|
30236
30267
|
if (!selected.size) {
|
|
30237
30268
|
throw new ConfigError(
|
|
30238
|
-
|
|
30269
|
+
`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:
|
|
30239
30270
|
|
|
30240
|
-
WEBHOOK_EVENTS=email.verify,email.reset,email.already_registered
|
|
30271
|
+
WEBHOOK_EVENTS=email.verify,email.reset,email.already_registered,email.no_password
|
|
30241
30272
|
|
|
30242
|
-
|
|
30273
|
+
For one that only wants to know who signed in:
|
|
30274
|
+
|
|
30275
|
+
WEBHOOK_EVENTS=user.signed_in
|
|
30276
|
+
|
|
30277
|
+
` + allTypes
|
|
30243
30278
|
);
|
|
30244
30279
|
}
|
|
30245
30280
|
const unknown = [...selected].filter((e6) => !WEBHOOK_TYPES.includes(e6));
|
|
30246
30281
|
if (unknown.length) {
|
|
30247
|
-
|
|
30248
|
-
}
|
|
30249
|
-
const missing = sendsEmails ? REQUIRED_EMAIL_TYPES.filter((type) => !selected.has(type)) : [];
|
|
30250
|
-
if (missing.length) {
|
|
30282
|
+
const renamed = unknown.filter((e6) => e6 in RENAMED_TYPES).map((e6) => `${e6} is now ${RENAMED_TYPES[e6]}`);
|
|
30251
30283
|
throw new ConfigError(
|
|
30252
|
-
`WEBHOOK_EVENTS
|
|
30253
|
-
|
|
30254
|
-
WEBHOOK_EVENTS=${[...selected, ...missing].join(",")}
|
|
30255
|
-
`
|
|
30284
|
+
`WEBHOOK_EVENTS: unknown type ${unknown.join(", ")}. ${renamed.length ? `${renamed.join("; ")}. ` : ""}Known types: ${WEBHOOK_TYPES.join(", ")}.`
|
|
30256
30285
|
);
|
|
30257
30286
|
}
|
|
30258
30287
|
return { url: parsed.href, secret, events: selected };
|
|
@@ -33551,6 +33580,63 @@ var cors = (options) => {
|
|
|
33551
33580
|
};
|
|
33552
33581
|
};
|
|
33553
33582
|
|
|
33583
|
+
// src/claims.ts
|
|
33584
|
+
var ADMIN_ROLE = "admin";
|
|
33585
|
+
var MAX_ROLES = 20;
|
|
33586
|
+
var MAX_CLAIMS_LENGTH = 2048;
|
|
33587
|
+
var ROLE_NAME = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
33588
|
+
var CLAIM_NAME = /^[a-zA-Z][a-zA-Z0-9_]{0,63}$/;
|
|
33589
|
+
function parseRoles(input) {
|
|
33590
|
+
if (!Array.isArray(input)) return null;
|
|
33591
|
+
if (!input.every((role) => typeof role === "string" && ROLE_NAME.test(role))) return null;
|
|
33592
|
+
const roles = [...new Set(input)].sort();
|
|
33593
|
+
return roles.length <= MAX_ROLES ? roles : null;
|
|
33594
|
+
}
|
|
33595
|
+
function parseClaims(input) {
|
|
33596
|
+
if (!isJsonObject(input)) return null;
|
|
33597
|
+
const claims = {};
|
|
33598
|
+
for (const key of Object.keys(input).sort()) {
|
|
33599
|
+
const value = input[key];
|
|
33600
|
+
if (!CLAIM_NAME.test(key) || !isJsonValue(value)) return null;
|
|
33601
|
+
if (key === "roles") {
|
|
33602
|
+
const roles = parseRoles(value);
|
|
33603
|
+
if (!roles) return null;
|
|
33604
|
+
claims[key] = roles;
|
|
33605
|
+
} else {
|
|
33606
|
+
claims[key] = value;
|
|
33607
|
+
}
|
|
33608
|
+
}
|
|
33609
|
+
return JSON.stringify(claims).length <= MAX_CLAIMS_LENGTH ? claims : null;
|
|
33610
|
+
}
|
|
33611
|
+
function isJsonObject(value) {
|
|
33612
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
33613
|
+
}
|
|
33614
|
+
function isJsonValue(value) {
|
|
33615
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
|
|
33616
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
33617
|
+
if (Array.isArray(value)) return value.every(isJsonValue);
|
|
33618
|
+
return isJsonObject(value) && Object.values(value).every(isJsonValue);
|
|
33619
|
+
}
|
|
33620
|
+
function sameClaims(a6, b6) {
|
|
33621
|
+
return JSON.stringify(a6 ?? {}) === JSON.stringify(b6 ?? {});
|
|
33622
|
+
}
|
|
33623
|
+
function claimsFromJson(json2) {
|
|
33624
|
+
if (!json2) return {};
|
|
33625
|
+
try {
|
|
33626
|
+
const parsed = JSON.parse(json2);
|
|
33627
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
33628
|
+
} catch {
|
|
33629
|
+
return {};
|
|
33630
|
+
}
|
|
33631
|
+
}
|
|
33632
|
+
function rolesOf(user) {
|
|
33633
|
+
const roles = user.claims?.roles;
|
|
33634
|
+
return Array.isArray(roles) ? roles.filter((role) => typeof role === "string") : [];
|
|
33635
|
+
}
|
|
33636
|
+
function isAdmin(user) {
|
|
33637
|
+
return rolesOf(user).includes(ADMIN_ROLE);
|
|
33638
|
+
}
|
|
33639
|
+
|
|
33554
33640
|
// src/password.ts
|
|
33555
33641
|
import { createHash as createHash4, createHmac as createHmac3, hkdfSync, randomBytes as randomBytes2, randomInt, scrypt, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
33556
33642
|
var HASH_PARAMS = { logN: 15, r: 8, p: 3 };
|
|
@@ -33626,45 +33712,6 @@ function safeEqual(a6, b6) {
|
|
|
33626
33712
|
return x2.length === y2.length && timingSafeEqual2(x2, y2);
|
|
33627
33713
|
}
|
|
33628
33714
|
|
|
33629
|
-
// src/roles.ts
|
|
33630
|
-
var ADMIN_ROLE = "admin";
|
|
33631
|
-
var MAX_ROLES = 20;
|
|
33632
|
-
var ROLE_NAME = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
33633
|
-
function parseRoles(input) {
|
|
33634
|
-
if (!Array.isArray(input)) return null;
|
|
33635
|
-
if (!input.every((role) => typeof role === "string" && ROLE_NAME.test(role))) return null;
|
|
33636
|
-
const roles = [...new Set(input)].sort();
|
|
33637
|
-
return roles.length <= MAX_ROLES ? roles : null;
|
|
33638
|
-
}
|
|
33639
|
-
function sameRoles(a6, b6) {
|
|
33640
|
-
return a6.length === b6.length && a6.every((role, i6) => role === b6[i6]);
|
|
33641
|
-
}
|
|
33642
|
-
var Roles = class {
|
|
33643
|
-
constructor(store) {
|
|
33644
|
-
this.store = store;
|
|
33645
|
-
}
|
|
33646
|
-
/** The address's roles, sorted; empty if it has none. */
|
|
33647
|
-
async get(emailNormalized) {
|
|
33648
|
-
const record = await this.store.findOne("role", { id: emailNormalized });
|
|
33649
|
-
return typeof record?.roles === "string" && record.roles ? record.roles.split(" ").filter(Boolean) : [];
|
|
33650
|
-
}
|
|
33651
|
-
/** Replaces the address's roles. `updatedBy` is the address of the admin, or null for the command line. */
|
|
33652
|
-
async set(emailNormalized, roles, updatedBy) {
|
|
33653
|
-
if (!roles.length) {
|
|
33654
|
-
await this.remove(emailNormalized);
|
|
33655
|
-
return;
|
|
33656
|
-
}
|
|
33657
|
-
const data = { roles: roles.join(" "), updatedAt: Date.now(), updatedBy };
|
|
33658
|
-
if (await this.store.update("role", { id: emailNormalized }, data) === 1) return;
|
|
33659
|
-
if (!await this.store.create("role", { id: emailNormalized, ...data })) {
|
|
33660
|
-
await this.store.update("role", { id: emailNormalized }, data);
|
|
33661
|
-
}
|
|
33662
|
-
}
|
|
33663
|
-
async remove(emailNormalized) {
|
|
33664
|
-
await this.store.delete("role", { id: emailNormalized });
|
|
33665
|
-
}
|
|
33666
|
-
};
|
|
33667
|
-
|
|
33668
33715
|
// src/google.ts
|
|
33669
33716
|
var GOOGLE_ISSUERS = ["https://accounts.google.com", "accounts.google.com"];
|
|
33670
33717
|
var googleJwks;
|
|
@@ -33689,14 +33736,13 @@ async function verifyGoogleIdToken(idToken, opts) {
|
|
|
33689
33736
|
if (payload.nonce !== opts.nonce) {
|
|
33690
33737
|
return { ok: false, error: "verification_failed", reason: "nonce mismatch" };
|
|
33691
33738
|
}
|
|
33692
|
-
if (payload.email_verified !== true) {
|
|
33739
|
+
if (typeof payload.email !== "string" || !payload.email || payload.email_verified !== true) {
|
|
33693
33740
|
return { ok: false, error: "email_unverified", reason: "e-mail address not verified" };
|
|
33694
33741
|
}
|
|
33695
|
-
const
|
|
33696
|
-
if (typeof payload.
|
|
33697
|
-
if (typeof payload.
|
|
33698
|
-
|
|
33699
|
-
return { ok: true, user };
|
|
33742
|
+
const profile = { sub: payload.sub, email: payload.email };
|
|
33743
|
+
if (typeof payload.name === "string") profile.name = payload.name;
|
|
33744
|
+
if (typeof payload.picture === "string") profile.picture = payload.picture;
|
|
33745
|
+
return { ok: true, profile };
|
|
33700
33746
|
}
|
|
33701
33747
|
|
|
33702
33748
|
// src/user.ts
|
|
@@ -33730,7 +33776,7 @@ function signSession(keys, issuer, ttlSeconds, user, amr, extra = {}) {
|
|
|
33730
33776
|
email_verified: user.email ? true : void 0,
|
|
33731
33777
|
name: user.name,
|
|
33732
33778
|
picture: user.picture,
|
|
33733
|
-
|
|
33779
|
+
claims: user.claims && Object.keys(user.claims).length ? user.claims : void 0,
|
|
33734
33780
|
amr
|
|
33735
33781
|
};
|
|
33736
33782
|
return signToken(keys, issuer, SESSION_TYP, claims, ttlSeconds);
|
|
@@ -33752,10 +33798,280 @@ function userFromClaims(claims) {
|
|
|
33752
33798
|
if (claims.email) user.email = claims.email;
|
|
33753
33799
|
if (claims.name) user.name = claims.name;
|
|
33754
33800
|
if (claims.picture) user.picture = claims.picture;
|
|
33755
|
-
if (
|
|
33801
|
+
if (claims.claims && typeof claims.claims === "object" && Object.keys(claims.claims).length) user.claims = claims.claims;
|
|
33756
33802
|
return user;
|
|
33757
33803
|
}
|
|
33758
33804
|
|
|
33805
|
+
// src/users.ts
|
|
33806
|
+
import { randomBytes as randomBytes3 } from "crypto";
|
|
33807
|
+
|
|
33808
|
+
// src/settings.ts
|
|
33809
|
+
var SIGN_IN_METHODS = ["google", "password"];
|
|
33810
|
+
var METHODS_SETTING = "methods";
|
|
33811
|
+
function parseMethodSettings(input) {
|
|
33812
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) return null;
|
|
33813
|
+
const settings = {};
|
|
33814
|
+
for (const [key, value] of Object.entries(input)) {
|
|
33815
|
+
if (!SIGN_IN_METHODS.includes(key) || typeof value !== "boolean") return null;
|
|
33816
|
+
settings[key] = value;
|
|
33817
|
+
}
|
|
33818
|
+
return settings;
|
|
33819
|
+
}
|
|
33820
|
+
var MAX_TRIES = 3;
|
|
33821
|
+
var Settings = class {
|
|
33822
|
+
constructor(store) {
|
|
33823
|
+
this.store = store;
|
|
33824
|
+
}
|
|
33825
|
+
/** The setting's value, or undefined if it was never set. */
|
|
33826
|
+
async get(id) {
|
|
33827
|
+
return (await this.read(id)).value;
|
|
33828
|
+
}
|
|
33829
|
+
/** The stored JSON and what it holds; both undefined if the setting was never set. */
|
|
33830
|
+
async read(id) {
|
|
33831
|
+
const record = await this.store.findOne("setting", { id });
|
|
33832
|
+
if (typeof record?.value !== "string") return {};
|
|
33833
|
+
try {
|
|
33834
|
+
return { json: record.value, value: JSON.parse(record.value) };
|
|
33835
|
+
} catch {
|
|
33836
|
+
return { json: record.value };
|
|
33837
|
+
}
|
|
33838
|
+
}
|
|
33839
|
+
/**
|
|
33840
|
+
* Changes the setting: `apply` gets the current value and returns the new one, or null to refuse the
|
|
33841
|
+
* change. The write only lands if nobody changed the setting since `apply` read it; otherwise `apply` runs
|
|
33842
|
+
* again with the newer value. Resolves to the new value, or null if `apply` refused.
|
|
33843
|
+
* `updatedBy` is the address of the admin, or null for the command line.
|
|
33844
|
+
*/
|
|
33845
|
+
async change(id, apply, updatedBy) {
|
|
33846
|
+
for (let attempt = 0; attempt < MAX_TRIES; attempt++) {
|
|
33847
|
+
const { json: json2, value } = await this.read(id);
|
|
33848
|
+
const next = apply(value);
|
|
33849
|
+
if (next === null) return null;
|
|
33850
|
+
const patch = { value: JSON.stringify(next), updatedAt: Date.now(), updatedBy };
|
|
33851
|
+
const written = json2 === void 0 ? await this.store.create("setting", { id, ...patch }) : await this.store.update("setting", { id, value: json2 }, patch) === 1;
|
|
33852
|
+
if (written) return next;
|
|
33853
|
+
}
|
|
33854
|
+
throw new Error(`Setting "${id}" kept changing; try again.`);
|
|
33855
|
+
}
|
|
33856
|
+
/** Replaces the setting. */
|
|
33857
|
+
async set(id, value, updatedBy) {
|
|
33858
|
+
await this.change(id, () => value, updatedBy);
|
|
33859
|
+
}
|
|
33860
|
+
/** Which sign-in methods are on; a method that was never switched off is on. */
|
|
33861
|
+
async methods() {
|
|
33862
|
+
return parseMethodSettings(await this.get(METHODS_SETTING)) ?? {};
|
|
33863
|
+
}
|
|
33864
|
+
/** Changes which methods are on, see {@link change}; `apply` gets the methods as they are now. */
|
|
33865
|
+
async changeMethods(apply, updatedBy) {
|
|
33866
|
+
return this.change(METHODS_SETTING, (current) => apply(parseMethodSettings(current) ?? {}), updatedBy);
|
|
33867
|
+
}
|
|
33868
|
+
};
|
|
33869
|
+
function isOn(method, available, methods) {
|
|
33870
|
+
return available(method) && methods[method] !== false;
|
|
33871
|
+
}
|
|
33872
|
+
function anyOn(available, methods) {
|
|
33873
|
+
return SIGN_IN_METHODS.some((method) => isOn(method, available, methods));
|
|
33874
|
+
}
|
|
33875
|
+
|
|
33876
|
+
// src/users.ts
|
|
33877
|
+
var MAX_CODE_ATTEMPTS = 5;
|
|
33878
|
+
var MAX_WRONG_CODES = 10;
|
|
33879
|
+
var newId = (prefix) => `${prefix}_${randomBytes3(16).toString("base64url")}`;
|
|
33880
|
+
function passwordAccountKey(userId) {
|
|
33881
|
+
return `password:${userId}`;
|
|
33882
|
+
}
|
|
33883
|
+
function googleAccountKey(sub) {
|
|
33884
|
+
return `google:${sub}`;
|
|
33885
|
+
}
|
|
33886
|
+
function toMadauthUser(user, profile = {}) {
|
|
33887
|
+
const result = { id: user.id, email: user.email };
|
|
33888
|
+
const name = user.name ?? profile.name;
|
|
33889
|
+
if (name) result.name = name;
|
|
33890
|
+
if (profile.picture) result.picture = profile.picture;
|
|
33891
|
+
const claims = claimsFromJson(user.claims);
|
|
33892
|
+
if (Object.keys(claims).length) result.claims = claims;
|
|
33893
|
+
return result;
|
|
33894
|
+
}
|
|
33895
|
+
var Users = class {
|
|
33896
|
+
/** `codeKey` keys the e-mail codes (see deriveCodeKey); only needed to issue and check them. */
|
|
33897
|
+
constructor(store, codeKey) {
|
|
33898
|
+
this.store = store;
|
|
33899
|
+
this.codeKey = codeKey;
|
|
33900
|
+
}
|
|
33901
|
+
get key() {
|
|
33902
|
+
if (!this.codeKey) throw new Error("Users needs a code key to issue or check e-mail codes.");
|
|
33903
|
+
return this.codeKey;
|
|
33904
|
+
}
|
|
33905
|
+
async findByEmail(emailNormalized) {
|
|
33906
|
+
return await this.store.findOne("user", { emailNormalized });
|
|
33907
|
+
}
|
|
33908
|
+
async findById(id) {
|
|
33909
|
+
return await this.store.findOne("user", { id });
|
|
33910
|
+
}
|
|
33911
|
+
async passwordAccount(userId) {
|
|
33912
|
+
return this.findAccountByKey(passwordAccountKey(userId));
|
|
33913
|
+
}
|
|
33914
|
+
/** How the user signs in, from their accounts: e.g. `['google']` for a user without a password. */
|
|
33915
|
+
async signInMethods(userId) {
|
|
33916
|
+
const accounts = await this.store.findMany("account", { userId });
|
|
33917
|
+
const methods = accounts.map((account) => String(account.key).split(":")[0]);
|
|
33918
|
+
return SIGN_IN_METHODS.filter((method) => methods.includes(method));
|
|
33919
|
+
}
|
|
33920
|
+
async findAccountByKey(key) {
|
|
33921
|
+
return await this.store.findOne("account", { key });
|
|
33922
|
+
}
|
|
33923
|
+
/**
|
|
33924
|
+
* Creates a user with their first account. Resolves to null if the e-mail address or the account key is
|
|
33925
|
+
* taken. `account.key` is `password:<userId>` for a password (its hash as `secret`), or e.g. `google:<sub>`.
|
|
33926
|
+
*/
|
|
33927
|
+
async createUser(data) {
|
|
33928
|
+
const user = {
|
|
33929
|
+
id: newId("usr"),
|
|
33930
|
+
email: data.email,
|
|
33931
|
+
emailNormalized: data.emailNormalized,
|
|
33932
|
+
emailVerified: data.emailVerified,
|
|
33933
|
+
name: data.name,
|
|
33934
|
+
sessionVersion: 0,
|
|
33935
|
+
lastMailAt: 0,
|
|
33936
|
+
wrongCodes: 0,
|
|
33937
|
+
claims: null,
|
|
33938
|
+
createdAt: Date.now()
|
|
33939
|
+
};
|
|
33940
|
+
if (!await this.store.create("user", user)) return null;
|
|
33941
|
+
const account = typeof data.account === "function" ? data.account(user.id) : data.account;
|
|
33942
|
+
if (!await this.linkAccount(user.id, account)) {
|
|
33943
|
+
await this.store.delete("user", { id: user.id });
|
|
33944
|
+
return null;
|
|
33945
|
+
}
|
|
33946
|
+
return user;
|
|
33947
|
+
}
|
|
33948
|
+
/**
|
|
33949
|
+
* A provider verified the user's address: an unconfirmed password sign-up with it, whose password nobody
|
|
33950
|
+
* has proven, is dropped (the account and its pending links and codes), and the user counts as verified.
|
|
33951
|
+
*/
|
|
33952
|
+
async verifyByProvider(user) {
|
|
33953
|
+
if (user.emailVerified) return;
|
|
33954
|
+
await this.store.delete("account", { key: passwordAccountKey(user.id) });
|
|
33955
|
+
await this.clearVerifications(user.id);
|
|
33956
|
+
await this.updateUser(user.id, { emailVerified: true, wrongCodes: 0 });
|
|
33957
|
+
}
|
|
33958
|
+
/** Removes an account record, e.g. one left behind by a deletion that did not finish. */
|
|
33959
|
+
async deleteAccount(id) {
|
|
33960
|
+
await this.store.delete("account", { id });
|
|
33961
|
+
}
|
|
33962
|
+
/** Adds a sign-in method to a user. Resolves to null if the account key is taken. */
|
|
33963
|
+
async linkAccount(userId, account) {
|
|
33964
|
+
const record = {
|
|
33965
|
+
id: newId("acc"),
|
|
33966
|
+
userId,
|
|
33967
|
+
key: account.key,
|
|
33968
|
+
secret: account.secret ?? null,
|
|
33969
|
+
failedAttempts: 0,
|
|
33970
|
+
lockedUntil: 0,
|
|
33971
|
+
email: account.email ?? null,
|
|
33972
|
+
createdAt: Date.now()
|
|
33973
|
+
};
|
|
33974
|
+
return await this.store.create("account", record) ? record : null;
|
|
33975
|
+
}
|
|
33976
|
+
/**
|
|
33977
|
+
* Deletes the user and everything stored for them. Resolves to false if there is no such user (any more).
|
|
33978
|
+
*/
|
|
33979
|
+
async deleteById(id) {
|
|
33980
|
+
const user = await this.findById(id);
|
|
33981
|
+
if (!user) return false;
|
|
33982
|
+
if (await this.store.delete("user", { id: user.id }) !== 1) return false;
|
|
33983
|
+
try {
|
|
33984
|
+
await this.store.delete("account", { userId: user.id });
|
|
33985
|
+
await this.store.delete("verification", { userId: user.id });
|
|
33986
|
+
} catch (e6) {
|
|
33987
|
+
console.error(`[madauth] Deleted user ${user.id}, but not all of their records: ${e6.message}`);
|
|
33988
|
+
}
|
|
33989
|
+
return true;
|
|
33990
|
+
}
|
|
33991
|
+
async updateUser(id, patch) {
|
|
33992
|
+
await this.store.update("user", { id }, patch);
|
|
33993
|
+
}
|
|
33994
|
+
/** Changes an account. Resolves to false if it is gone meanwhile. */
|
|
33995
|
+
async updateAccount(id, patch) {
|
|
33996
|
+
return await this.store.update("account", { id }, patch) === 1;
|
|
33997
|
+
}
|
|
33998
|
+
/**
|
|
33999
|
+
* Counts a sign-in attempt: writes `patch` only if the account's `failedAttempts` is still the value that
|
|
34000
|
+
* was read. Resolves to false if another request counted an attempt meanwhile.
|
|
34001
|
+
*/
|
|
34002
|
+
async countAttempt(account, patch) {
|
|
34003
|
+
const changed = await this.store.update("account", { id: account.id, failedAttempts: account.failedAttempts }, patch);
|
|
34004
|
+
return changed === 1;
|
|
34005
|
+
}
|
|
34006
|
+
/** Deletes the user's pending verifications and password resets; their links and codes stop working. */
|
|
34007
|
+
async clearVerifications(userId) {
|
|
34008
|
+
await this.store.delete("verification", { userId });
|
|
34009
|
+
}
|
|
34010
|
+
/**
|
|
34011
|
+
* Starts a verification or password reset: replaces the user's earlier one for the same purpose and
|
|
34012
|
+
* returns the link token and the code for the e-mail. Only their hashes are stored.
|
|
34013
|
+
*/
|
|
34014
|
+
async issueVerification(userId, purpose, ttlMs) {
|
|
34015
|
+
await this.store.delete("verification", { userId, purpose });
|
|
34016
|
+
const token = generateLinkToken();
|
|
34017
|
+
const code = generateCode();
|
|
34018
|
+
const record = {
|
|
34019
|
+
id: hashLinkToken(token),
|
|
34020
|
+
userId,
|
|
34021
|
+
purpose,
|
|
34022
|
+
codeHash: hashCode(this.key, code),
|
|
34023
|
+
attempts: 0,
|
|
34024
|
+
expiresAt: Date.now() + ttlMs
|
|
34025
|
+
};
|
|
34026
|
+
await this.store.create("verification", record);
|
|
34027
|
+
return { token, code };
|
|
34028
|
+
}
|
|
34029
|
+
/** Uses up the verification a link token belongs to. Resolves to its user ID, or null if invalid, expired or used. */
|
|
34030
|
+
async consumeLinkToken(token, purpose) {
|
|
34031
|
+
const id = hashLinkToken(token);
|
|
34032
|
+
const record = await this.store.findOne("verification", { id, purpose });
|
|
34033
|
+
return record ? this.consume(record) : null;
|
|
34034
|
+
}
|
|
34035
|
+
/**
|
|
34036
|
+
* Uses up the user's verification if `code` matches. Too many wrong codes delete it, and after
|
|
34037
|
+
* MAX_WRONG_CODES wrong codes in a row the user's codes stop working until an e-mail link is used.
|
|
34038
|
+
*/
|
|
34039
|
+
async consumeCode(user, purpose, code) {
|
|
34040
|
+
if ((user.wrongCodes ?? 0) >= MAX_WRONG_CODES) return { userId: null, locked: true };
|
|
34041
|
+
const record = await this.store.findOne("verification", { userId: user.id, purpose });
|
|
34042
|
+
if (!record) return { userId: null, locked: false };
|
|
34043
|
+
const attempts = record.attempts + 1;
|
|
34044
|
+
if (await this.store.update("verification", { id: record.id, attempts: record.attempts }, { attempts }) !== 1) {
|
|
34045
|
+
return { userId: null, locked: false };
|
|
34046
|
+
}
|
|
34047
|
+
const wrongCodes = await this.countWrongCode(user);
|
|
34048
|
+
if (wrongCodes === null) return { userId: null, locked: false };
|
|
34049
|
+
if (wrongCodes > MAX_WRONG_CODES) return { userId: null, locked: true };
|
|
34050
|
+
if (!safeEqual(hashCode(this.key, code), record.codeHash)) {
|
|
34051
|
+
if (attempts >= MAX_CODE_ATTEMPTS) await this.store.delete("verification", { id: record.id });
|
|
34052
|
+
return { userId: null, locked: wrongCodes >= MAX_WRONG_CODES };
|
|
34053
|
+
}
|
|
34054
|
+
return { userId: await this.consume(record), locked: false };
|
|
34055
|
+
}
|
|
34056
|
+
/** Counts a code for the user and resolves to the new count; null if other requests kept changing it. */
|
|
34057
|
+
async countWrongCode(user) {
|
|
34058
|
+
let current = user;
|
|
34059
|
+
for (let attempt = 0; attempt < 3 && current; attempt++) {
|
|
34060
|
+
const wrongCodes = (current.wrongCodes ?? 0) + 1;
|
|
34061
|
+
if (await this.store.update("user", { id: user.id, wrongCodes: current.wrongCodes ?? null }, { wrongCodes }) === 1) {
|
|
34062
|
+
return wrongCodes;
|
|
34063
|
+
}
|
|
34064
|
+
current = await this.findById(user.id);
|
|
34065
|
+
}
|
|
34066
|
+
return null;
|
|
34067
|
+
}
|
|
34068
|
+
async consume(record) {
|
|
34069
|
+
const deleted = await this.store.delete("verification", { id: record.id });
|
|
34070
|
+
if (deleted !== 1 || record.expiresAt <= Date.now()) return null;
|
|
34071
|
+
return record.userId;
|
|
34072
|
+
}
|
|
34073
|
+
};
|
|
34074
|
+
|
|
33759
34075
|
// src/routes/google.ts
|
|
33760
34076
|
var NONCE_COOKIE = "madauth_nonce";
|
|
33761
34077
|
var NONCE_TYP = "madauth-nonce+jwt";
|
|
@@ -33771,10 +34087,57 @@ async function pkceChallenge(verifier) {
|
|
|
33771
34087
|
return base64url_exports.encode(new Uint8Array(digest2));
|
|
33772
34088
|
}
|
|
33773
34089
|
function googleRoutes(app, ctx, google) {
|
|
33774
|
-
const { config, keys, secure } = ctx;
|
|
34090
|
+
const { config, keys, secure, users } = ctx;
|
|
33775
34091
|
const { issuer } = config;
|
|
33776
34092
|
const callbackUrl = `${issuer}/auth/google/callback`;
|
|
34093
|
+
const resolveUser = async (profile, locale) => {
|
|
34094
|
+
const key = googleAccountKey(profile.sub);
|
|
34095
|
+
const emailNormalized = normalizeEmail(profile.email);
|
|
34096
|
+
const signedIn = (user) => ({ ok: true, user: toMadauthUser(user, profile), sv: user.sessionVersion });
|
|
34097
|
+
const account = await users.findAccountByKey(key);
|
|
34098
|
+
if (account) {
|
|
34099
|
+
const user = await users.findById(account.userId);
|
|
34100
|
+
if (user) {
|
|
34101
|
+
if (account.email !== profile.email) await users.updateAccount(account.id, { email: profile.email });
|
|
34102
|
+
return signedIn(user);
|
|
34103
|
+
}
|
|
34104
|
+
await users.deleteAccount(account.id);
|
|
34105
|
+
}
|
|
34106
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
34107
|
+
const existing = await users.findByEmail(emailNormalized);
|
|
34108
|
+
if (existing) {
|
|
34109
|
+
await users.verifyByProvider(existing);
|
|
34110
|
+
if (!await users.linkAccount(existing.id, { key, email: profile.email })) {
|
|
34111
|
+
const linked = await users.findAccountByKey(key);
|
|
34112
|
+
const owner = linked && await users.findById(linked.userId);
|
|
34113
|
+
if (!owner) return { ok: false, error: "temporarily_unavailable", message: "Please try again." };
|
|
34114
|
+
return signedIn(owner);
|
|
34115
|
+
}
|
|
34116
|
+
return signedIn(existing);
|
|
34117
|
+
}
|
|
34118
|
+
if (attempt === 0) {
|
|
34119
|
+
const check2 = await ctx.checkSignup({ email: profile.email, name: profile.name, locale, method: "google" });
|
|
34120
|
+
if (!check2.ok) {
|
|
34121
|
+
return check2.reason === "rejected" ? { ok: false, error: "signup_rejected", message: check2.message } : { ok: false, error: "temporarily_unavailable", message: "Signing up is not possible right now. Please try again later." };
|
|
34122
|
+
}
|
|
34123
|
+
}
|
|
34124
|
+
const user = await users.createUser({
|
|
34125
|
+
email: profile.email,
|
|
34126
|
+
emailNormalized,
|
|
34127
|
+
name: profile.name ?? null,
|
|
34128
|
+
emailVerified: true,
|
|
34129
|
+
account: { key, email: profile.email }
|
|
34130
|
+
});
|
|
34131
|
+
if (user) {
|
|
34132
|
+
await ctx.emit("user.created", { user: toMadauthUser(user), method: "google" });
|
|
34133
|
+
return signedIn(user);
|
|
34134
|
+
}
|
|
34135
|
+
}
|
|
34136
|
+
return { ok: false, error: "temporarily_unavailable", message: "Please try again." };
|
|
34137
|
+
};
|
|
34138
|
+
const disabled = (c6) => c6.json({ error: "method_disabled", message: "Google sign-in is switched off." }, 403);
|
|
33777
34139
|
app.post("/auth/google/nonce", async (c6) => {
|
|
34140
|
+
if (!await ctx.enabled("google")) return disabled(c6);
|
|
33778
34141
|
const nonce = randomString();
|
|
33779
34142
|
const token = await signToken(await keys, issuer, NONCE_TYP, { nonce }, NONCE_TTL);
|
|
33780
34143
|
setCookie(c6, NONCE_COOKIE, token, {
|
|
@@ -33787,6 +34150,7 @@ function googleRoutes(app, ctx, google) {
|
|
|
33787
34150
|
return c6.json({ nonce });
|
|
33788
34151
|
});
|
|
33789
34152
|
app.post("/auth/google/verify", async (c6) => {
|
|
34153
|
+
if (!await ctx.enabled("google")) return disabled(c6);
|
|
33790
34154
|
const body = await c6.req.json().catch(() => ({}));
|
|
33791
34155
|
if (typeof body.credential !== "string" || !body.credential) {
|
|
33792
34156
|
return c6.json({ error: "verification_failed", message: "credential is missing" }, 400);
|
|
@@ -33804,12 +34168,17 @@ function googleRoutes(app, ctx, google) {
|
|
|
33804
34168
|
return c6.json({ error: result.error, message: result.reason }, result.error === "email_unverified" ? 403 : 401);
|
|
33805
34169
|
}
|
|
33806
34170
|
deleteCookie(c6, NONCE_COOKIE, { path: "/auth/google", secure });
|
|
33807
|
-
const
|
|
34171
|
+
const resolved = await resolveUser(result.profile, localeOf(body.locale));
|
|
34172
|
+
if (!resolved.ok) {
|
|
34173
|
+
return c6.json({ error: resolved.error, message: resolved.message }, resolved.error === "signup_rejected" ? 403 : 503);
|
|
34174
|
+
}
|
|
34175
|
+
const user = await ctx.startSession(c6, resolved.user, ["google"], { sv: resolved.sv });
|
|
33808
34176
|
await ctx.emit("user.signed_in", { user, method: "google" });
|
|
33809
34177
|
return c6.json({ user });
|
|
33810
34178
|
});
|
|
33811
34179
|
const requireCodeFlow = async (c6, next) => {
|
|
33812
34180
|
if (!google.clientSecret) return c6.json({ error: "not_found" }, 404);
|
|
34181
|
+
if (!await ctx.enabled("google")) return disabled(c6);
|
|
33813
34182
|
await next();
|
|
33814
34183
|
};
|
|
33815
34184
|
app.get("/auth/google/start", requireCodeFlow, async (c6) => {
|
|
@@ -33819,7 +34188,8 @@ function googleRoutes(app, ctx, google) {
|
|
|
33819
34188
|
state: randomString(),
|
|
33820
34189
|
nonce: randomString(),
|
|
33821
34190
|
verifier: randomString(),
|
|
33822
|
-
returnTo: returnUrl.href
|
|
34191
|
+
returnTo: returnUrl.href,
|
|
34192
|
+
locale: localeOf(c6.req.query("locale"))
|
|
33823
34193
|
};
|
|
33824
34194
|
const token = await signToken(await keys, issuer, OAUTH_TYP, { ...oauth }, OAUTH_TTL);
|
|
33825
34195
|
setCookie(c6, OAUTH_COOKIE, token, {
|
|
@@ -33880,7 +34250,9 @@ function googleRoutes(app, ctx, google) {
|
|
|
33880
34250
|
keys: config.jwksResolver
|
|
33881
34251
|
});
|
|
33882
34252
|
if (!result.ok) return back(result.error);
|
|
33883
|
-
const
|
|
34253
|
+
const resolved = await resolveUser(result.profile, typeof oauth.locale === "string" ? oauth.locale : void 0);
|
|
34254
|
+
if (!resolved.ok) return back(resolved.error);
|
|
34255
|
+
const user = await ctx.startSession(c6, resolved.user, ["google"], { sv: resolved.sv });
|
|
33884
34256
|
await ctx.emit("user.signed_in", { user, method: "google" });
|
|
33885
34257
|
return back();
|
|
33886
34258
|
});
|
|
@@ -33892,18 +34264,16 @@ var MAX_LOCK_MS = 15 * 60 * 1e3;
|
|
|
33892
34264
|
var MAIL_INTERVAL_MS = 60 * 1e3;
|
|
33893
34265
|
var VERIFY_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
33894
34266
|
var RESET_TTL_MS = 30 * 60 * 1e3;
|
|
33895
|
-
var WEBHOOK_TIMEOUT_MS = 1e4;
|
|
33896
34267
|
var VERIFY_LINK_PARAM = "madauth_verify";
|
|
33897
34268
|
var RESET_LINK_PARAM = "madauth_reset";
|
|
33898
34269
|
var MAX_NAME_LENGTH = 100;
|
|
33899
|
-
var MAX_LOCALE_LENGTH = 35;
|
|
33900
|
-
function toMadauthUser(user) {
|
|
33901
|
-
const result = { id: user.id, email: user.email };
|
|
33902
|
-
if (user.name) result.name = user.name;
|
|
33903
|
-
return result;
|
|
33904
|
-
}
|
|
33905
34270
|
function passwordRoutes(app, ctx, password, users, webhook) {
|
|
33906
34271
|
const { minLength } = password;
|
|
34272
|
+
const disabled = (c6) => c6.json({ error: "method_disabled", message: "E-mail & password sign-in is switched off." }, 403);
|
|
34273
|
+
app.use("/auth/password/*", async (c6, next) => {
|
|
34274
|
+
if (!await ctx.enabled("password")) return disabled(c6);
|
|
34275
|
+
await next();
|
|
34276
|
+
});
|
|
33907
34277
|
const body = async (c6) => {
|
|
33908
34278
|
const data = await c6.req.json().catch(() => null);
|
|
33909
34279
|
return data && typeof data === "object" ? data : {};
|
|
@@ -33939,7 +34309,11 @@ function passwordRoutes(app, ctx, password, users, webhook) {
|
|
|
33939
34309
|
locale
|
|
33940
34310
|
});
|
|
33941
34311
|
};
|
|
33942
|
-
const
|
|
34312
|
+
const sendAccountEmail = async (user, type, target, locale) => {
|
|
34313
|
+
if (!webhook.wants(type)) return true;
|
|
34314
|
+
const methods = await users.signInMethods(user.id);
|
|
34315
|
+
return sendEmail(user, type, { link: target.href, site: target.host, locale, methods });
|
|
34316
|
+
};
|
|
33943
34317
|
const consume = async (data, purpose) => {
|
|
33944
34318
|
if (typeof data.token === "string" && data.token) {
|
|
33945
34319
|
return { userId: await users.consumeLinkToken(data.token, purpose), via: "link" };
|
|
@@ -34001,42 +34375,31 @@ function passwordRoutes(app, ctx, password, users, webhook) {
|
|
|
34001
34375
|
if (policy) return error(c6, 400, "weak_password", policy);
|
|
34002
34376
|
const name = typeof data.name === "string" && data.name.trim() ? data.name.trim().slice(0, MAX_NAME_LENGTH) : null;
|
|
34003
34377
|
const email = data.email.trim();
|
|
34004
|
-
const locale = localeOf(data);
|
|
34005
|
-
|
|
34006
|
-
|
|
34007
|
-
if (!check2.ok) {
|
|
34008
|
-
console.error(`[madauth] Webhook "signup.before" failed: ${check2.reason}`);
|
|
34009
|
-
return unavailable(c6);
|
|
34010
|
-
}
|
|
34011
|
-
const answer = check2.body;
|
|
34012
|
-
if (answer?.allow === false) {
|
|
34013
|
-
const message2 = typeof answer.message === "string" && answer.message ? answer.message : "Sign-up is not possible with this e-mail address.";
|
|
34014
|
-
return error(c6, 403, "signup_rejected", message2);
|
|
34015
|
-
}
|
|
34016
|
-
}
|
|
34378
|
+
const locale = localeOf(data.locale);
|
|
34379
|
+
const check2 = await ctx.checkSignup({ email, name: name ?? void 0, locale, method: "password" });
|
|
34380
|
+
if (!check2.ok) return check2.reason === "rejected" ? error(c6, 403, "signup_rejected", check2.message) : unavailable(c6);
|
|
34017
34381
|
const passwordHash = await hashPassword(data.password);
|
|
34018
34382
|
const existing = await users.findByEmail(normalizeEmail(email));
|
|
34019
34383
|
if (existing?.emailVerified) {
|
|
34020
|
-
|
|
34021
|
-
const sent = await sendEmail(existing, "email.already_registered", { link: target.href, site: target.host, locale });
|
|
34022
|
-
return sent ? accepted(c6) : unavailable(c6);
|
|
34384
|
+
return await sendAccountEmail(existing, "email.already_registered", target, locale) ? accepted(c6) : unavailable(c6);
|
|
34023
34385
|
}
|
|
34024
34386
|
if (existing) {
|
|
34025
34387
|
if (Date.now() - existing.lastMailAt < MAIL_INTERVAL_MS) return accepted(c6);
|
|
34026
34388
|
const account = await users.passwordAccount(existing.id);
|
|
34027
34389
|
if (account) await users.updateAccount(account.id, { secret: passwordHash, failedAttempts: 0, lockedUntil: 0 });
|
|
34390
|
+
else await users.linkAccount(existing.id, { key: passwordAccountKey(existing.id), secret: passwordHash });
|
|
34028
34391
|
await users.updateUser(existing.id, { name });
|
|
34029
34392
|
return await sendLinkEmail({ ...existing, name }, "verify", target, locale) ? accepted(c6) : unavailable(c6);
|
|
34030
34393
|
}
|
|
34031
|
-
const user = await users.
|
|
34394
|
+
const user = await users.createUser({
|
|
34032
34395
|
email,
|
|
34033
34396
|
emailNormalized: normalizeEmail(email),
|
|
34034
34397
|
name,
|
|
34035
|
-
|
|
34036
|
-
|
|
34398
|
+
emailVerified: false,
|
|
34399
|
+
account: (userId) => ({ key: passwordAccountKey(userId), secret: passwordHash })
|
|
34037
34400
|
});
|
|
34038
34401
|
if (!user) return accepted(c6);
|
|
34039
|
-
await ctx.emit("user.created", { user: toMadauthUser(user) });
|
|
34402
|
+
await ctx.emit("user.created", { user: toMadauthUser(user), method: "password" });
|
|
34040
34403
|
return await sendLinkEmail(user, "verify", target, locale) ? accepted(c6) : unavailable(c6);
|
|
34041
34404
|
});
|
|
34042
34405
|
app.post("/auth/password/send-verification", async (c6) => {
|
|
@@ -34046,7 +34409,7 @@ function passwordRoutes(app, ctx, password, users, webhook) {
|
|
|
34046
34409
|
if (!isValidEmail(data.email)) return error(c6, 400, "invalid_email", "This is not a valid e-mail address.");
|
|
34047
34410
|
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
34048
34411
|
if (user && !user.emailVerified && await users.passwordAccount(user.id)) {
|
|
34049
|
-
if (!await sendLinkEmail(user, "verify", target, localeOf(data))) return unavailable(c6);
|
|
34412
|
+
if (!await sendLinkEmail(user, "verify", target, localeOf(data.locale))) return unavailable(c6);
|
|
34050
34413
|
}
|
|
34051
34414
|
return accepted(c6);
|
|
34052
34415
|
});
|
|
@@ -34067,8 +34430,11 @@ function passwordRoutes(app, ctx, password, users, webhook) {
|
|
|
34067
34430
|
if (target instanceof Response) return target;
|
|
34068
34431
|
if (!isValidEmail(data.email)) return error(c6, 400, "invalid_email", "This is not a valid e-mail address.");
|
|
34069
34432
|
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
34433
|
+
const locale = localeOf(data.locale);
|
|
34070
34434
|
if (user && await users.passwordAccount(user.id)) {
|
|
34071
|
-
if (!await sendLinkEmail(user, "reset", target,
|
|
34435
|
+
if (!await sendLinkEmail(user, "reset", target, locale)) return unavailable(c6);
|
|
34436
|
+
} else if (user) {
|
|
34437
|
+
if (!await sendAccountEmail(user, "email.no_password", target, locale)) return unavailable(c6);
|
|
34072
34438
|
}
|
|
34073
34439
|
return accepted(c6);
|
|
34074
34440
|
});
|
|
@@ -34080,173 +34446,18 @@ function passwordRoutes(app, ctx, password, users, webhook) {
|
|
|
34080
34446
|
const user = consumed.userId ? await users.findById(consumed.userId) : null;
|
|
34081
34447
|
const account = user ? await users.passwordAccount(user.id) : null;
|
|
34082
34448
|
if (!user || !account) return invalidVerification(c6, consumed);
|
|
34083
|
-
|
|
34449
|
+
const secret = await hashPassword(data.password);
|
|
34450
|
+
if (!await users.updateAccount(account.id, { secret, failedAttempts: 0, lockedUntil: 0 })) return invalidVerification(c6, consumed);
|
|
34084
34451
|
const sessionVersion = user.sessionVersion + 1;
|
|
34085
34452
|
await users.updateUser(user.id, { emailVerified: true, sessionVersion, wrongCodes: 0 });
|
|
34086
34453
|
await users.clearVerifications(user.id);
|
|
34087
34454
|
const result = await ctx.startSession(c6, toMadauthUser(user), ["pwd"], { sv: sessionVersion });
|
|
34088
|
-
await ctx.emit("
|
|
34455
|
+
await ctx.emit("email.password_reset", { user: result });
|
|
34089
34456
|
await ctx.emit("user.signed_in", { user: result, method: "password" });
|
|
34090
34457
|
return c6.json({ user: result });
|
|
34091
34458
|
});
|
|
34092
34459
|
}
|
|
34093
34460
|
|
|
34094
|
-
// src/users.ts
|
|
34095
|
-
import { randomBytes as randomBytes3 } from "crypto";
|
|
34096
|
-
var MAX_CODE_ATTEMPTS = 5;
|
|
34097
|
-
var MAX_WRONG_CODES = 10;
|
|
34098
|
-
var newId = (prefix) => `${prefix}_${randomBytes3(16).toString("base64url")}`;
|
|
34099
|
-
function passwordAccountKey(userId) {
|
|
34100
|
-
return `password:${userId}`;
|
|
34101
|
-
}
|
|
34102
|
-
var Users = class {
|
|
34103
|
-
/** `codeKey` keys the e-mail codes (see deriveCodeKey); only needed to issue and check them. */
|
|
34104
|
-
constructor(store, codeKey) {
|
|
34105
|
-
this.store = store;
|
|
34106
|
-
this.codeKey = codeKey;
|
|
34107
|
-
}
|
|
34108
|
-
get key() {
|
|
34109
|
-
if (!this.codeKey) throw new Error("Users needs a code key to issue or check e-mail codes.");
|
|
34110
|
-
return this.codeKey;
|
|
34111
|
-
}
|
|
34112
|
-
async findByEmail(emailNormalized) {
|
|
34113
|
-
return await this.store.findOne("user", { emailNormalized });
|
|
34114
|
-
}
|
|
34115
|
-
async findById(id) {
|
|
34116
|
-
return await this.store.findOne("user", { id });
|
|
34117
|
-
}
|
|
34118
|
-
async passwordAccount(userId) {
|
|
34119
|
-
return await this.store.findOne("account", { key: passwordAccountKey(userId) });
|
|
34120
|
-
}
|
|
34121
|
-
/** Creates a user with a password. Resolves to null if the e-mail address is taken. */
|
|
34122
|
-
async createPasswordUser(data) {
|
|
34123
|
-
const now = Date.now();
|
|
34124
|
-
const user = {
|
|
34125
|
-
id: newId("usr"),
|
|
34126
|
-
email: data.email,
|
|
34127
|
-
emailNormalized: data.emailNormalized,
|
|
34128
|
-
emailVerified: data.emailVerified,
|
|
34129
|
-
name: data.name,
|
|
34130
|
-
sessionVersion: 0,
|
|
34131
|
-
lastMailAt: 0,
|
|
34132
|
-
wrongCodes: 0,
|
|
34133
|
-
createdAt: now
|
|
34134
|
-
};
|
|
34135
|
-
if (!await this.store.create("user", user)) return null;
|
|
34136
|
-
const account = {
|
|
34137
|
-
id: newId("acc"),
|
|
34138
|
-
userId: user.id,
|
|
34139
|
-
key: passwordAccountKey(user.id),
|
|
34140
|
-
secret: data.passwordHash,
|
|
34141
|
-
failedAttempts: 0,
|
|
34142
|
-
lockedUntil: 0,
|
|
34143
|
-
createdAt: now
|
|
34144
|
-
};
|
|
34145
|
-
if (!await this.store.create("account", account)) {
|
|
34146
|
-
await this.store.delete("user", { id: user.id });
|
|
34147
|
-
return null;
|
|
34148
|
-
}
|
|
34149
|
-
return user;
|
|
34150
|
-
}
|
|
34151
|
-
/**
|
|
34152
|
-
* Deletes the user with this e-mail address and everything stored for them. Resolves to the deleted
|
|
34153
|
-
* user's ID, or null if there is no such user (any more).
|
|
34154
|
-
*/
|
|
34155
|
-
async deleteByEmail(emailNormalized) {
|
|
34156
|
-
const user = await this.findByEmail(emailNormalized);
|
|
34157
|
-
if (!user) return null;
|
|
34158
|
-
if (await this.store.delete("user", { id: user.id }) !== 1) return null;
|
|
34159
|
-
try {
|
|
34160
|
-
await this.store.delete("account", { userId: user.id });
|
|
34161
|
-
await this.store.delete("verification", { userId: user.id });
|
|
34162
|
-
} catch (e6) {
|
|
34163
|
-
console.error(`[madauth] Deleted user ${user.id}, but not all of their records: ${e6.message}`);
|
|
34164
|
-
}
|
|
34165
|
-
return user.id;
|
|
34166
|
-
}
|
|
34167
|
-
async updateUser(id, patch) {
|
|
34168
|
-
await this.store.update("user", { id }, patch);
|
|
34169
|
-
}
|
|
34170
|
-
async updateAccount(id, patch) {
|
|
34171
|
-
await this.store.update("account", { id }, patch);
|
|
34172
|
-
}
|
|
34173
|
-
/**
|
|
34174
|
-
* Counts a sign-in attempt: writes `patch` only if the account's `failedAttempts` is still the value that
|
|
34175
|
-
* was read. Resolves to false if another request counted an attempt meanwhile.
|
|
34176
|
-
*/
|
|
34177
|
-
async countAttempt(account, patch) {
|
|
34178
|
-
const changed = await this.store.update("account", { id: account.id, failedAttempts: account.failedAttempts }, patch);
|
|
34179
|
-
return changed === 1;
|
|
34180
|
-
}
|
|
34181
|
-
/** Deletes the user's pending verifications and password resets; their links and codes stop working. */
|
|
34182
|
-
async clearVerifications(userId) {
|
|
34183
|
-
await this.store.delete("verification", { userId });
|
|
34184
|
-
}
|
|
34185
|
-
/**
|
|
34186
|
-
* Starts a verification or password reset: replaces the user's earlier one for the same purpose and
|
|
34187
|
-
* returns the link token and the code for the e-mail. Only their hashes are stored.
|
|
34188
|
-
*/
|
|
34189
|
-
async issueVerification(userId, purpose, ttlMs) {
|
|
34190
|
-
await this.store.delete("verification", { userId, purpose });
|
|
34191
|
-
const token = generateLinkToken();
|
|
34192
|
-
const code = generateCode();
|
|
34193
|
-
const record = {
|
|
34194
|
-
id: hashLinkToken(token),
|
|
34195
|
-
userId,
|
|
34196
|
-
purpose,
|
|
34197
|
-
codeHash: hashCode(this.key, code),
|
|
34198
|
-
attempts: 0,
|
|
34199
|
-
expiresAt: Date.now() + ttlMs
|
|
34200
|
-
};
|
|
34201
|
-
await this.store.create("verification", record);
|
|
34202
|
-
return { token, code };
|
|
34203
|
-
}
|
|
34204
|
-
/** Uses up the verification a link token belongs to. Resolves to its user ID, or null if invalid, expired or used. */
|
|
34205
|
-
async consumeLinkToken(token, purpose) {
|
|
34206
|
-
const id = hashLinkToken(token);
|
|
34207
|
-
const record = await this.store.findOne("verification", { id, purpose });
|
|
34208
|
-
return record ? this.consume(record) : null;
|
|
34209
|
-
}
|
|
34210
|
-
/**
|
|
34211
|
-
* Uses up the user's verification if `code` matches. Too many wrong codes delete it, and after
|
|
34212
|
-
* MAX_WRONG_CODES wrong codes in a row the user's codes stop working until an e-mail link is used.
|
|
34213
|
-
*/
|
|
34214
|
-
async consumeCode(user, purpose, code) {
|
|
34215
|
-
if ((user.wrongCodes ?? 0) >= MAX_WRONG_CODES) return { userId: null, locked: true };
|
|
34216
|
-
const record = await this.store.findOne("verification", { userId: user.id, purpose });
|
|
34217
|
-
if (!record) return { userId: null, locked: false };
|
|
34218
|
-
const attempts = record.attempts + 1;
|
|
34219
|
-
if (await this.store.update("verification", { id: record.id, attempts: record.attempts }, { attempts }) !== 1) {
|
|
34220
|
-
return { userId: null, locked: false };
|
|
34221
|
-
}
|
|
34222
|
-
const wrongCodes = await this.countWrongCode(user);
|
|
34223
|
-
if (wrongCodes === null) return { userId: null, locked: false };
|
|
34224
|
-
if (wrongCodes > MAX_WRONG_CODES) return { userId: null, locked: true };
|
|
34225
|
-
if (!safeEqual(hashCode(this.key, code), record.codeHash)) {
|
|
34226
|
-
if (attempts >= MAX_CODE_ATTEMPTS) await this.store.delete("verification", { id: record.id });
|
|
34227
|
-
return { userId: null, locked: wrongCodes >= MAX_WRONG_CODES };
|
|
34228
|
-
}
|
|
34229
|
-
return { userId: await this.consume(record), locked: false };
|
|
34230
|
-
}
|
|
34231
|
-
/** Counts a code for the user and resolves to the new count; null if other requests kept changing it. */
|
|
34232
|
-
async countWrongCode(user) {
|
|
34233
|
-
let current = user;
|
|
34234
|
-
for (let attempt = 0; attempt < 3 && current; attempt++) {
|
|
34235
|
-
const wrongCodes = (current.wrongCodes ?? 0) + 1;
|
|
34236
|
-
if (await this.store.update("user", { id: user.id, wrongCodes: current.wrongCodes ?? null }, { wrongCodes }) === 1) {
|
|
34237
|
-
return wrongCodes;
|
|
34238
|
-
}
|
|
34239
|
-
current = await this.findById(user.id);
|
|
34240
|
-
}
|
|
34241
|
-
return null;
|
|
34242
|
-
}
|
|
34243
|
-
async consume(record) {
|
|
34244
|
-
const deleted = await this.store.delete("verification", { id: record.id });
|
|
34245
|
-
if (deleted !== 1 || record.expiresAt <= Date.now()) return null;
|
|
34246
|
-
return record.userId;
|
|
34247
|
-
}
|
|
34248
|
-
};
|
|
34249
|
-
|
|
34250
34461
|
// src/app.ts
|
|
34251
34462
|
var EVENT_TIMEOUT_MS = 5e3;
|
|
34252
34463
|
function createApp(config) {
|
|
@@ -34254,12 +34465,9 @@ function createApp(config) {
|
|
|
34254
34465
|
const keys = importSigningKeys(config.signingKey);
|
|
34255
34466
|
const secure = issuer.startsWith("https:");
|
|
34256
34467
|
const isAllowedOrigin = (origin) => !!origin && allowedOrigins.includes(origin);
|
|
34257
|
-
const
|
|
34258
|
-
const
|
|
34259
|
-
|
|
34260
|
-
const current = roles && user.email ? await roles.get(normalizeEmail(user.email)) : [];
|
|
34261
|
-
return current.length ? { ...rest, roles: current } : rest;
|
|
34262
|
-
};
|
|
34468
|
+
const users = new Users(config.store, deriveCodeKey(config.signingKey.d));
|
|
34469
|
+
const settings = new Settings(config.store);
|
|
34470
|
+
const available = (method) => method === "google" ? !!config.google : !!config.password;
|
|
34263
34471
|
const issueSession = async (c6, user, amr, extra) => {
|
|
34264
34472
|
const signingKeys = await keys;
|
|
34265
34473
|
const [sessionToken, renewalToken] = await Promise.all([
|
|
@@ -34311,12 +34519,29 @@ function createApp(config) {
|
|
|
34311
34519
|
return url;
|
|
34312
34520
|
},
|
|
34313
34521
|
async startSession(c6, user, amr, extra) {
|
|
34314
|
-
|
|
34315
|
-
|
|
34316
|
-
return signedIn;
|
|
34522
|
+
await issueSession(c6, user, amr, extra);
|
|
34523
|
+
return user;
|
|
34317
34524
|
},
|
|
34318
|
-
users
|
|
34525
|
+
users,
|
|
34526
|
+
settings,
|
|
34319
34527
|
webhook: config.webhook ? createWebhookClient(config.webhook, config.webhookFetch) : void 0,
|
|
34528
|
+
async enabled(method) {
|
|
34529
|
+
return isOn(method, available, await settings.methods());
|
|
34530
|
+
},
|
|
34531
|
+
async checkSignup(data) {
|
|
34532
|
+
if (!this.webhook?.wants("signup.before")) return { ok: true };
|
|
34533
|
+
const check2 = await this.webhook.call("signup.before", data, WEBHOOK_TIMEOUT_MS);
|
|
34534
|
+
if (!check2.ok) {
|
|
34535
|
+
console.error(`[madauth] Webhook "signup.before" failed: ${check2.reason}`);
|
|
34536
|
+
return { ok: false, reason: "unavailable" };
|
|
34537
|
+
}
|
|
34538
|
+
const answer = check2.body;
|
|
34539
|
+
if (answer?.allow === false) {
|
|
34540
|
+
const message2 = typeof answer.message === "string" && answer.message ? answer.message : "Sign-up is not possible with this e-mail address.";
|
|
34541
|
+
return { ok: false, reason: "rejected", message: message2 };
|
|
34542
|
+
}
|
|
34543
|
+
return { ok: true };
|
|
34544
|
+
},
|
|
34320
34545
|
async emit(type, data) {
|
|
34321
34546
|
if (!this.webhook?.wants(type)) return;
|
|
34322
34547
|
const result = await this.webhook.call(type, data, EVENT_TIMEOUT_MS);
|
|
@@ -34346,29 +34571,28 @@ function createApp(config) {
|
|
|
34346
34571
|
c6.header("Cache-Control", "public, max-age=3600");
|
|
34347
34572
|
return c6.json({ keys: [publicJwk] });
|
|
34348
34573
|
});
|
|
34349
|
-
app.get(
|
|
34350
|
-
|
|
34351
|
-
(
|
|
34352
|
-
|
|
34353
|
-
|
|
34354
|
-
|
|
34355
|
-
|
|
34574
|
+
app.get("/auth/config", async (c6) => {
|
|
34575
|
+
const methods = await settings.methods();
|
|
34576
|
+
const on = (method) => isOn(method, available, methods);
|
|
34577
|
+
return c6.json({
|
|
34578
|
+
google: on("google") ? { clientId: config.google.clientId, codeFlow: !!config.google.clientSecret } : null,
|
|
34579
|
+
password: on("password") ? { minLength: config.password.minLength } : null
|
|
34580
|
+
});
|
|
34581
|
+
});
|
|
34356
34582
|
if (config.google) googleRoutes(app, ctx, config.google);
|
|
34357
|
-
if (config.password && ctx.
|
|
34583
|
+
if (config.password && ctx.webhook) passwordRoutes(app, ctx, config.password, users, ctx.webhook);
|
|
34358
34584
|
const tokenClaims = async (c6, cookie, typ) => readToken(await keys, issuer, typ, getCookie(c6, cookie));
|
|
34359
|
-
const hasEnded = async (claims) => {
|
|
34360
|
-
if (!claims.sub.startsWith("usr_")) return false;
|
|
34361
|
-
const stored = await ctx.users?.findById(claims.sub);
|
|
34362
|
-
return !stored || stored.sessionVersion !== claims.sv;
|
|
34363
|
-
};
|
|
34364
34585
|
const checked = async (claims) => {
|
|
34365
|
-
const
|
|
34366
|
-
|
|
34586
|
+
const stored = await users.findById(claims.sub);
|
|
34587
|
+
if (!stored || stored.sessionVersion !== claims.sv) return null;
|
|
34588
|
+
const { claims: _, ...user } = userFromClaims(claims);
|
|
34589
|
+
const current = claimsFromJson(stored.claims);
|
|
34590
|
+
return Object.keys(current).length ? { ...user, claims: current } : user;
|
|
34367
34591
|
};
|
|
34368
34592
|
const currentSession = async (c6) => {
|
|
34369
34593
|
const session = await tokenClaims(c6, SESSION_COOKIE, SESSION_TYP);
|
|
34370
34594
|
const user = session ? await checked(session) : null;
|
|
34371
|
-
const upToDate = session && user &&
|
|
34595
|
+
const upToDate = session && user && sameClaims(user.claims, session.claims) && Date.now() / 1e3 - session.iat <= sessionTtlSeconds / 2;
|
|
34372
34596
|
if (upToDate) return user;
|
|
34373
34597
|
const renewal = await tokenClaims(c6, RENEWAL_COOKIE, RENEWAL_TYP);
|
|
34374
34598
|
const sameSignIn = session && user && renewal && renewal.sub === session.sub && renewal.sv === session.sv && renewal.email === session.email;
|
|
@@ -34394,56 +34618,82 @@ function createApp(config) {
|
|
|
34394
34618
|
app.post("/auth/account/delete", async (c6) => {
|
|
34395
34619
|
const user = await currentSession(c6);
|
|
34396
34620
|
if (!user) return c6.json({ error: "no_session" }, 401);
|
|
34397
|
-
|
|
34398
|
-
const passwordUserId = ctx.users && user.email ? await ctx.users.deleteByEmail(normalizeEmail(user.email)) : null;
|
|
34621
|
+
await users.deleteById(user.id);
|
|
34399
34622
|
clearSession(c6);
|
|
34400
|
-
await ctx.emit("user.deleted", { user
|
|
34623
|
+
await ctx.emit("user.deleted", { user });
|
|
34401
34624
|
return c6.body(null, 204);
|
|
34402
34625
|
});
|
|
34403
|
-
|
|
34404
|
-
const
|
|
34405
|
-
|
|
34406
|
-
|
|
34407
|
-
|
|
34408
|
-
|
|
34409
|
-
|
|
34410
|
-
|
|
34411
|
-
|
|
34412
|
-
|
|
34413
|
-
|
|
34414
|
-
|
|
34415
|
-
|
|
34416
|
-
|
|
34417
|
-
|
|
34418
|
-
|
|
34419
|
-
|
|
34420
|
-
|
|
34421
|
-
|
|
34422
|
-
|
|
34423
|
-
|
|
34424
|
-
|
|
34425
|
-
|
|
34426
|
-
|
|
34427
|
-
|
|
34428
|
-
|
|
34429
|
-
|
|
34430
|
-
|
|
34431
|
-
|
|
34432
|
-
|
|
34433
|
-
|
|
34434
|
-
|
|
34435
|
-
|
|
34436
|
-
|
|
34437
|
-
|
|
34438
|
-
|
|
34439
|
-
|
|
34440
|
-
|
|
34441
|
-
|
|
34442
|
-
|
|
34443
|
-
|
|
34444
|
-
|
|
34445
|
-
});
|
|
34446
|
-
|
|
34626
|
+
const admin = async (c6) => {
|
|
34627
|
+
const user = await currentSession(c6);
|
|
34628
|
+
if (!user) return { answer: c6.json({ error: "no_session" }, 401) };
|
|
34629
|
+
const email = user.email ? normalizeEmail(user.email) : void 0;
|
|
34630
|
+
if (!email || !isAdmin(user)) {
|
|
34631
|
+
const message2 = `Only users with the role "${ADMIN_ROLE}" can manage claims and settings.`;
|
|
34632
|
+
return { answer: c6.json({ error: "forbidden", message: message2 }, 403) };
|
|
34633
|
+
}
|
|
34634
|
+
return { email };
|
|
34635
|
+
};
|
|
34636
|
+
const body = async (c6) => {
|
|
34637
|
+
const data = await c6.req.json().catch(() => null);
|
|
34638
|
+
return data && typeof data === "object" ? data : {};
|
|
34639
|
+
};
|
|
34640
|
+
const invalidEmail = (c6) => c6.json({ error: "invalid_email", message: "This is not a valid e-mail address." }, 400);
|
|
34641
|
+
const noUser = (c6) => c6.json({ error: "user_not_found", message: "No user has this e-mail address." }, 404);
|
|
34642
|
+
app.post("/auth/admin/claims/get", async (c6) => {
|
|
34643
|
+
const caller = await admin(c6);
|
|
34644
|
+
if ("answer" in caller) return caller.answer;
|
|
34645
|
+
const data = await body(c6);
|
|
34646
|
+
if (!isValidEmail(data.email)) return invalidEmail(c6);
|
|
34647
|
+
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
34648
|
+
if (!user) return noUser(c6);
|
|
34649
|
+
return c6.json({ email: user.emailNormalized, userId: user.id, claims: claimsFromJson(user.claims) });
|
|
34650
|
+
});
|
|
34651
|
+
app.post("/auth/admin/claims/set", async (c6) => {
|
|
34652
|
+
const caller = await admin(c6);
|
|
34653
|
+
if ("answer" in caller) return caller.answer;
|
|
34654
|
+
const data = await body(c6);
|
|
34655
|
+
if (!isValidEmail(data.email)) return invalidEmail(c6);
|
|
34656
|
+
const claims = parseClaims(data.claims);
|
|
34657
|
+
if (!claims) {
|
|
34658
|
+
return c6.json(
|
|
34659
|
+
{
|
|
34660
|
+
error: "invalid_claims",
|
|
34661
|
+
message: 'claims must be a JSON object of at most 2048 characters whose keys are names (letters, digits and "_", starting with a letter). "roles" must be a list of names of lower-case letters, digits, "-" and "_".'
|
|
34662
|
+
},
|
|
34663
|
+
400
|
|
34664
|
+
);
|
|
34665
|
+
}
|
|
34666
|
+
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
34667
|
+
if (!user) return noUser(c6);
|
|
34668
|
+
await users.updateUser(user.id, { claims: Object.keys(claims).length ? JSON.stringify(claims) : null });
|
|
34669
|
+
const email = user.emailNormalized;
|
|
34670
|
+
await ctx.emit("user.claims_changed", { userId: user.id, email, claims, by: caller.email });
|
|
34671
|
+
return c6.json({ email, userId: user.id, claims });
|
|
34672
|
+
});
|
|
34673
|
+
const settingsAnswer = (methods) => ({
|
|
34674
|
+
methods: Object.fromEntries(
|
|
34675
|
+
SIGN_IN_METHODS.map((method) => [method, { available: available(method), enabled: isOn(method, available, methods) }])
|
|
34676
|
+
)
|
|
34677
|
+
});
|
|
34678
|
+
app.post("/auth/admin/settings/get", async (c6) => {
|
|
34679
|
+
const caller = await admin(c6);
|
|
34680
|
+
if ("answer" in caller) return caller.answer;
|
|
34681
|
+
return c6.json(settingsAnswer(await settings.methods()));
|
|
34682
|
+
});
|
|
34683
|
+
app.post("/auth/admin/settings/set", async (c6) => {
|
|
34684
|
+
const caller = await admin(c6);
|
|
34685
|
+
if ("answer" in caller) return caller.answer;
|
|
34686
|
+
const data = await body(c6);
|
|
34687
|
+
const invalid2 = (message2) => c6.json({ error: "invalid_settings", message: message2 }, 400);
|
|
34688
|
+
const changes = parseMethodSettings(data.methods);
|
|
34689
|
+
if (!changes) return invalid2(`methods must be an object with true or false for ${SIGN_IN_METHODS.join(" and/or ")}.`);
|
|
34690
|
+
const methods = await settings.changeMethods((current) => {
|
|
34691
|
+
const next = { ...current, ...changes };
|
|
34692
|
+
return anyOn(available, next) ? next : null;
|
|
34693
|
+
}, caller.email);
|
|
34694
|
+
if (!methods) return invalid2("At least one sign-in method must stay on.");
|
|
34695
|
+
return c6.json(settingsAnswer(methods));
|
|
34696
|
+
});
|
|
34447
34697
|
return app;
|
|
34448
34698
|
}
|
|
34449
34699
|
|