@madauth/server 0.2.0-beta.2 → 0.3.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/dist/bin.js +98 -31
- 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-6EJDVZCD.js → chunk-NPNM26J3.js} +9 -2
- package/dist/{chunk-5CKEMC6M.js → chunk-ODITZLMM.js} +218 -86
- package/dist/{chunk-YALS7OMV.js → chunk-ZSBKNV5W.js} +217 -122
- package/dist/{config-BkxADgtp.d.ts → config-DEBYi8hb.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 +799 -564
- package/dist/standalone/lambda.mjs +799 -564
- package/dist/standalone/main.mjs +563 -328
- 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-DCDP6SaE.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
|
@@ -4107,8 +4107,9 @@ var init_schema = __esm({
|
|
|
4107
4107
|
"src/store/schema.ts"() {
|
|
4108
4108
|
"use strict";
|
|
4109
4109
|
madauthSchema = {
|
|
4110
|
-
version:
|
|
4110
|
+
version: 4,
|
|
4111
4111
|
models: {
|
|
4112
|
+
/** Someone who signed in, whichever way: the e-mail address is the identity shared by the sign-in methods. */
|
|
4112
4113
|
user: {
|
|
4113
4114
|
fields: {
|
|
4114
4115
|
id: { type: "string", primaryKey: true },
|
|
@@ -4120,10 +4121,12 @@ var init_schema = __esm({
|
|
|
4120
4121
|
lastMailAt: { type: "number" },
|
|
4121
4122
|
/** Wrong e-mail codes in a row, counted across e-mails (since version 2). */
|
|
4122
4123
|
wrongCodes: { type: "number" },
|
|
4124
|
+
/** What admins attached to the user, as JSON, e.g. `{"roles":["admin"]}` (since version 4). */
|
|
4125
|
+
claims: { type: "string", nullable: true },
|
|
4123
4126
|
createdAt: { type: "number" }
|
|
4124
4127
|
}
|
|
4125
4128
|
},
|
|
4126
|
-
/** How a user signs in. `key` is
|
|
4129
|
+
/** How a user signs in. `key` is `password:<userId>` or `google:<sub>`; later e.g. `totp:<userId>`. */
|
|
4127
4130
|
account: {
|
|
4128
4131
|
fields: {
|
|
4129
4132
|
id: { type: "string", primaryKey: true },
|
|
@@ -4132,6 +4135,8 @@ var init_schema = __esm({
|
|
|
4132
4135
|
secret: { type: "string", nullable: true },
|
|
4133
4136
|
failedAttempts: { type: "number" },
|
|
4134
4137
|
lockedUntil: { type: "number" },
|
|
4138
|
+
/** The address the provider reported at the last sign-in, e.g. Google's current primary address (since version 4). */
|
|
4139
|
+
email: { type: "string", nullable: true },
|
|
4135
4140
|
createdAt: { type: "number" }
|
|
4136
4141
|
}
|
|
4137
4142
|
},
|
|
@@ -4146,16 +4151,13 @@ var init_schema = __esm({
|
|
|
4146
4151
|
expiresAt: { type: "number" }
|
|
4147
4152
|
}
|
|
4148
4153
|
},
|
|
4149
|
-
/**
|
|
4150
|
-
|
|
4151
|
-
* apply whichever way its owner signs in. `roles` holds the names separated by spaces.
|
|
4152
|
-
*/
|
|
4153
|
-
role: {
|
|
4154
|
+
/** A setting admins change at runtime, as JSON (since version 4), e.g. `methods`: which sign-in methods are on. */
|
|
4155
|
+
setting: {
|
|
4154
4156
|
fields: {
|
|
4155
4157
|
id: { type: "string", primaryKey: true },
|
|
4156
|
-
|
|
4158
|
+
value: { type: "string" },
|
|
4157
4159
|
updatedAt: { type: "number" },
|
|
4158
|
-
/** The address of the admin who set
|
|
4160
|
+
/** The address of the admin who set it; empty when set from the command line. */
|
|
4159
4161
|
updatedBy: { type: "string", nullable: true }
|
|
4160
4162
|
}
|
|
4161
4163
|
}
|
|
@@ -4210,8 +4212,30 @@ var init_sql = __esm({
|
|
|
4210
4212
|
sql: (dialect) => `ALTER TABLE ${tableName("user")} ADD COLUMN ${columnName("wrongCodes")} ${columnTypes[dialect].number} NOT NULL DEFAULT 0;`
|
|
4211
4213
|
},
|
|
4212
4214
|
{
|
|
4215
|
+
// Roles were per e-mail address in their own table; since version 4 they are one of the user's claims.
|
|
4213
4216
|
version: 3,
|
|
4214
|
-
sql: (dialect) => createTablesSql(dialect, {
|
|
4217
|
+
sql: (dialect) => createTablesSql(dialect, {
|
|
4218
|
+
version: 3,
|
|
4219
|
+
models: {
|
|
4220
|
+
role: {
|
|
4221
|
+
fields: {
|
|
4222
|
+
id: { type: "string", primaryKey: true },
|
|
4223
|
+
roles: { type: "string" },
|
|
4224
|
+
updatedAt: { type: "number" },
|
|
4225
|
+
updatedBy: { type: "string", nullable: true }
|
|
4226
|
+
}
|
|
4227
|
+
}
|
|
4228
|
+
}
|
|
4229
|
+
})
|
|
4230
|
+
},
|
|
4231
|
+
{
|
|
4232
|
+
version: 4,
|
|
4233
|
+
sql: (dialect) => [
|
|
4234
|
+
`ALTER TABLE ${tableName("user")} ADD COLUMN ${columnName("claims")} ${columnTypes[dialect].string};`,
|
|
4235
|
+
`ALTER TABLE ${tableName("account")} ADD COLUMN ${columnName("email")} ${columnTypes[dialect].string};`,
|
|
4236
|
+
`DROP TABLE ${tableName("role")};`,
|
|
4237
|
+
createTablesSql(dialect, { version: 4, models: { setting: madauthSchema.models.setting } })
|
|
4238
|
+
].join("\n")
|
|
4215
4239
|
}
|
|
4216
4240
|
];
|
|
4217
4241
|
}
|
|
@@ -6531,6 +6555,63 @@ var cors = (options) => {
|
|
|
6531
6555
|
};
|
|
6532
6556
|
};
|
|
6533
6557
|
|
|
6558
|
+
// src/claims.ts
|
|
6559
|
+
var ADMIN_ROLE = "admin";
|
|
6560
|
+
var MAX_ROLES = 20;
|
|
6561
|
+
var MAX_CLAIMS_LENGTH = 2048;
|
|
6562
|
+
var ROLE_NAME = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
6563
|
+
var CLAIM_NAME = /^[a-zA-Z][a-zA-Z0-9_]{0,63}$/;
|
|
6564
|
+
function parseRoles(input) {
|
|
6565
|
+
if (!Array.isArray(input)) return null;
|
|
6566
|
+
if (!input.every((role) => typeof role === "string" && ROLE_NAME.test(role))) return null;
|
|
6567
|
+
const roles = [...new Set(input)].sort();
|
|
6568
|
+
return roles.length <= MAX_ROLES ? roles : null;
|
|
6569
|
+
}
|
|
6570
|
+
function parseClaims(input) {
|
|
6571
|
+
if (!isJsonObject(input)) return null;
|
|
6572
|
+
const claims = {};
|
|
6573
|
+
for (const key of Object.keys(input).sort()) {
|
|
6574
|
+
const value = input[key];
|
|
6575
|
+
if (!CLAIM_NAME.test(key) || !isJsonValue(value)) return null;
|
|
6576
|
+
if (key === "roles") {
|
|
6577
|
+
const roles = parseRoles(value);
|
|
6578
|
+
if (!roles) return null;
|
|
6579
|
+
claims[key] = roles;
|
|
6580
|
+
} else {
|
|
6581
|
+
claims[key] = value;
|
|
6582
|
+
}
|
|
6583
|
+
}
|
|
6584
|
+
return JSON.stringify(claims).length <= MAX_CLAIMS_LENGTH ? claims : null;
|
|
6585
|
+
}
|
|
6586
|
+
function isJsonObject(value) {
|
|
6587
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
6588
|
+
}
|
|
6589
|
+
function isJsonValue(value) {
|
|
6590
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
|
|
6591
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
6592
|
+
if (Array.isArray(value)) return value.every(isJsonValue);
|
|
6593
|
+
return isJsonObject(value) && Object.values(value).every(isJsonValue);
|
|
6594
|
+
}
|
|
6595
|
+
function sameClaims(a, b) {
|
|
6596
|
+
return JSON.stringify(a ?? {}) === JSON.stringify(b ?? {});
|
|
6597
|
+
}
|
|
6598
|
+
function claimsFromJson(json) {
|
|
6599
|
+
if (!json) return {};
|
|
6600
|
+
try {
|
|
6601
|
+
const parsed = JSON.parse(json);
|
|
6602
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
6603
|
+
} catch {
|
|
6604
|
+
return {};
|
|
6605
|
+
}
|
|
6606
|
+
}
|
|
6607
|
+
function rolesOf(user) {
|
|
6608
|
+
const roles = user.claims?.roles;
|
|
6609
|
+
return Array.isArray(roles) ? roles.filter((role) => typeof role === "string") : [];
|
|
6610
|
+
}
|
|
6611
|
+
function isAdmin(user) {
|
|
6612
|
+
return rolesOf(user).includes(ADMIN_ROLE);
|
|
6613
|
+
}
|
|
6614
|
+
|
|
6534
6615
|
// ../../node_modules/jose/dist/webapi/lib/buffer_utils.js
|
|
6535
6616
|
var encoder = new TextEncoder();
|
|
6536
6617
|
var decoder = new TextDecoder();
|
|
@@ -7737,45 +7818,6 @@ function safeEqual(a, b) {
|
|
|
7737
7818
|
return x.length === y.length && timingSafeEqual(x, y);
|
|
7738
7819
|
}
|
|
7739
7820
|
|
|
7740
|
-
// src/roles.ts
|
|
7741
|
-
var ADMIN_ROLE = "admin";
|
|
7742
|
-
var MAX_ROLES = 20;
|
|
7743
|
-
var ROLE_NAME = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
7744
|
-
function parseRoles(input) {
|
|
7745
|
-
if (!Array.isArray(input)) return null;
|
|
7746
|
-
if (!input.every((role) => typeof role === "string" && ROLE_NAME.test(role))) return null;
|
|
7747
|
-
const roles = [...new Set(input)].sort();
|
|
7748
|
-
return roles.length <= MAX_ROLES ? roles : null;
|
|
7749
|
-
}
|
|
7750
|
-
function sameRoles(a, b) {
|
|
7751
|
-
return a.length === b.length && a.every((role, i) => role === b[i]);
|
|
7752
|
-
}
|
|
7753
|
-
var Roles = class {
|
|
7754
|
-
constructor(store) {
|
|
7755
|
-
this.store = store;
|
|
7756
|
-
}
|
|
7757
|
-
/** The address's roles, sorted; empty if it has none. */
|
|
7758
|
-
async get(emailNormalized) {
|
|
7759
|
-
const record = await this.store.findOne("role", { id: emailNormalized });
|
|
7760
|
-
return typeof record?.roles === "string" && record.roles ? record.roles.split(" ").filter(Boolean) : [];
|
|
7761
|
-
}
|
|
7762
|
-
/** Replaces the address's roles. `updatedBy` is the address of the admin, or null for the command line. */
|
|
7763
|
-
async set(emailNormalized, roles, updatedBy) {
|
|
7764
|
-
if (!roles.length) {
|
|
7765
|
-
await this.remove(emailNormalized);
|
|
7766
|
-
return;
|
|
7767
|
-
}
|
|
7768
|
-
const data = { roles: roles.join(" "), updatedAt: Date.now(), updatedBy };
|
|
7769
|
-
if (await this.store.update("role", { id: emailNormalized }, data) === 1) return;
|
|
7770
|
-
if (!await this.store.create("role", { id: emailNormalized, ...data })) {
|
|
7771
|
-
await this.store.update("role", { id: emailNormalized }, data);
|
|
7772
|
-
}
|
|
7773
|
-
}
|
|
7774
|
-
async remove(emailNormalized) {
|
|
7775
|
-
await this.store.delete("role", { id: emailNormalized });
|
|
7776
|
-
}
|
|
7777
|
-
};
|
|
7778
|
-
|
|
7779
7821
|
// src/google.ts
|
|
7780
7822
|
var GOOGLE_ISSUERS = ["https://accounts.google.com", "accounts.google.com"];
|
|
7781
7823
|
var googleJwks;
|
|
@@ -7800,14 +7842,13 @@ async function verifyGoogleIdToken(idToken, opts) {
|
|
|
7800
7842
|
if (payload.nonce !== opts.nonce) {
|
|
7801
7843
|
return { ok: false, error: "verification_failed", reason: "nonce mismatch" };
|
|
7802
7844
|
}
|
|
7803
|
-
if (payload.email_verified !== true) {
|
|
7845
|
+
if (typeof payload.email !== "string" || !payload.email || payload.email_verified !== true) {
|
|
7804
7846
|
return { ok: false, error: "email_unverified", reason: "e-mail address not verified" };
|
|
7805
7847
|
}
|
|
7806
|
-
const
|
|
7807
|
-
if (typeof payload.
|
|
7808
|
-
if (typeof payload.
|
|
7809
|
-
|
|
7810
|
-
return { ok: true, user };
|
|
7848
|
+
const profile = { sub: payload.sub, email: payload.email };
|
|
7849
|
+
if (typeof payload.name === "string") profile.name = payload.name;
|
|
7850
|
+
if (typeof payload.picture === "string") profile.picture = payload.picture;
|
|
7851
|
+
return { ok: true, profile };
|
|
7811
7852
|
}
|
|
7812
7853
|
|
|
7813
7854
|
// src/user.ts
|
|
@@ -7841,7 +7882,7 @@ function signSession(keys, issuer, ttlSeconds, user, amr, extra = {}) {
|
|
|
7841
7882
|
email_verified: user.email ? true : void 0,
|
|
7842
7883
|
name: user.name,
|
|
7843
7884
|
picture: user.picture,
|
|
7844
|
-
|
|
7885
|
+
claims: user.claims && Object.keys(user.claims).length ? user.claims : void 0,
|
|
7845
7886
|
amr
|
|
7846
7887
|
};
|
|
7847
7888
|
return signToken(keys, issuer, SESSION_TYP, claims, ttlSeconds);
|
|
@@ -7863,194 +7904,518 @@ function userFromClaims(claims) {
|
|
|
7863
7904
|
if (claims.email) user.email = claims.email;
|
|
7864
7905
|
if (claims.name) user.name = claims.name;
|
|
7865
7906
|
if (claims.picture) user.picture = claims.picture;
|
|
7866
|
-
if (
|
|
7907
|
+
if (claims.claims && typeof claims.claims === "object" && Object.keys(claims.claims).length) user.claims = claims.claims;
|
|
7867
7908
|
return user;
|
|
7868
7909
|
}
|
|
7869
7910
|
|
|
7870
|
-
// src/
|
|
7871
|
-
|
|
7872
|
-
var
|
|
7873
|
-
var
|
|
7874
|
-
var
|
|
7875
|
-
|
|
7876
|
-
|
|
7877
|
-
var GOOGLE_AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
7878
|
-
var GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
|
|
7879
|
-
var REDIRECT_ERROR_PARAM = "madauth_error";
|
|
7880
|
-
async function pkceChallenge(verifier) {
|
|
7881
|
-
const digest2 = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
7882
|
-
return base64url_exports.encode(new Uint8Array(digest2));
|
|
7911
|
+
// src/users.ts
|
|
7912
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
7913
|
+
var MAX_CODE_ATTEMPTS = 5;
|
|
7914
|
+
var MAX_WRONG_CODES = 10;
|
|
7915
|
+
var newId = (prefix) => `${prefix}_${randomBytes2(16).toString("base64url")}`;
|
|
7916
|
+
function passwordAccountKey(userId) {
|
|
7917
|
+
return `password:${userId}`;
|
|
7883
7918
|
}
|
|
7884
|
-
function
|
|
7885
|
-
|
|
7886
|
-
const { issuer } = config;
|
|
7887
|
-
const callbackUrl = `${issuer}/auth/google/callback`;
|
|
7888
|
-
app2.post("/auth/google/nonce", async (c) => {
|
|
7889
|
-
const nonce = randomString();
|
|
7890
|
-
const token = await signToken(await keys, issuer, NONCE_TYP, { nonce }, NONCE_TTL);
|
|
7891
|
-
setCookie(c, NONCE_COOKIE, token, {
|
|
7892
|
-
path: "/auth/google",
|
|
7893
|
-
httpOnly: true,
|
|
7894
|
-
secure,
|
|
7895
|
-
sameSite: "Strict",
|
|
7896
|
-
maxAge: NONCE_TTL
|
|
7897
|
-
});
|
|
7898
|
-
return c.json({ nonce });
|
|
7899
|
-
});
|
|
7900
|
-
app2.post("/auth/google/verify", async (c) => {
|
|
7901
|
-
const body = await c.req.json().catch(() => ({}));
|
|
7902
|
-
if (typeof body.credential !== "string" || !body.credential) {
|
|
7903
|
-
return c.json({ error: "verification_failed", message: "credential is missing" }, 400);
|
|
7904
|
-
}
|
|
7905
|
-
const nonceClaims = await readToken(await keys, issuer, NONCE_TYP, getCookie(c, NONCE_COOKIE));
|
|
7906
|
-
if (!nonceClaims) {
|
|
7907
|
-
return c.json({ error: "verification_failed", message: "nonce cookie missing or expired" }, 401);
|
|
7908
|
-
}
|
|
7909
|
-
const result = await verifyGoogleIdToken(body.credential, {
|
|
7910
|
-
clientId: google.clientId,
|
|
7911
|
-
nonce: nonceClaims.nonce,
|
|
7912
|
-
keys: config.jwksResolver
|
|
7913
|
-
});
|
|
7914
|
-
if (!result.ok) {
|
|
7915
|
-
return c.json({ error: result.error, message: result.reason }, result.error === "email_unverified" ? 403 : 401);
|
|
7916
|
-
}
|
|
7917
|
-
deleteCookie(c, NONCE_COOKIE, { path: "/auth/google", secure });
|
|
7918
|
-
const user = await ctx.startSession(c, result.user, ["google"]);
|
|
7919
|
-
await ctx.emit("user.signed_in", { user, method: "google" });
|
|
7920
|
-
return c.json({ user });
|
|
7921
|
-
});
|
|
7922
|
-
const requireCodeFlow = async (c, next) => {
|
|
7923
|
-
if (!google.clientSecret) return c.json({ error: "not_found" }, 404);
|
|
7924
|
-
await next();
|
|
7925
|
-
};
|
|
7926
|
-
app2.get("/auth/google/start", requireCodeFlow, async (c) => {
|
|
7927
|
-
const returnUrl = ctx.allowedUrl(c.req.query("return_to"));
|
|
7928
|
-
if (!returnUrl) return c.text("return_to must be an absolute URL whose origin is in ALLOWED_ORIGINS", 400);
|
|
7929
|
-
const oauth = {
|
|
7930
|
-
state: randomString(),
|
|
7931
|
-
nonce: randomString(),
|
|
7932
|
-
verifier: randomString(),
|
|
7933
|
-
returnTo: returnUrl.href
|
|
7934
|
-
};
|
|
7935
|
-
const token = await signToken(await keys, issuer, OAUTH_TYP, { ...oauth }, OAUTH_TTL);
|
|
7936
|
-
setCookie(c, OAUTH_COOKIE, token, {
|
|
7937
|
-
path: "/auth/google",
|
|
7938
|
-
httpOnly: true,
|
|
7939
|
-
secure,
|
|
7940
|
-
sameSite: "Lax",
|
|
7941
|
-
maxAge: OAUTH_TTL
|
|
7942
|
-
});
|
|
7943
|
-
const url = new URL(GOOGLE_AUTH_ENDPOINT);
|
|
7944
|
-
url.search = new URLSearchParams({
|
|
7945
|
-
client_id: google.clientId,
|
|
7946
|
-
redirect_uri: callbackUrl,
|
|
7947
|
-
response_type: "code",
|
|
7948
|
-
scope: "openid email profile",
|
|
7949
|
-
state: oauth.state,
|
|
7950
|
-
nonce: oauth.nonce,
|
|
7951
|
-
code_challenge: await pkceChallenge(oauth.verifier),
|
|
7952
|
-
code_challenge_method: "S256"
|
|
7953
|
-
}).toString();
|
|
7954
|
-
return c.redirect(url.href, 302);
|
|
7955
|
-
});
|
|
7956
|
-
app2.get("/auth/google/callback", requireCodeFlow, async (c) => {
|
|
7957
|
-
const oauth = await readToken(await keys, issuer, OAUTH_TYP, getCookie(c, OAUTH_COOKIE));
|
|
7958
|
-
if (!oauth) return c.text("Sign-in expired or was started in another browser. Please try again.", 400);
|
|
7959
|
-
deleteCookie(c, OAUTH_COOKIE, { path: "/auth/google", secure });
|
|
7960
|
-
const back = (error) => {
|
|
7961
|
-
const url = new URL(oauth.returnTo);
|
|
7962
|
-
if (error) url.hash = `${REDIRECT_ERROR_PARAM}=${error}`;
|
|
7963
|
-
return c.redirect(url.href, 302);
|
|
7964
|
-
};
|
|
7965
|
-
if (c.req.query("error")) return back(c.req.query("error") === "access_denied" ? "cancelled" : "verification_failed");
|
|
7966
|
-
const code = c.req.query("code");
|
|
7967
|
-
if (!code || c.req.query("state") !== oauth.state) return back("verification_failed");
|
|
7968
|
-
let idToken;
|
|
7969
|
-
try {
|
|
7970
|
-
const res = await fetch(GOOGLE_TOKEN_ENDPOINT, {
|
|
7971
|
-
method: "POST",
|
|
7972
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
7973
|
-
body: new URLSearchParams({
|
|
7974
|
-
code,
|
|
7975
|
-
client_id: google.clientId,
|
|
7976
|
-
client_secret: google.clientSecret,
|
|
7977
|
-
redirect_uri: callbackUrl,
|
|
7978
|
-
grant_type: "authorization_code",
|
|
7979
|
-
code_verifier: oauth.verifier
|
|
7980
|
-
})
|
|
7981
|
-
});
|
|
7982
|
-
if (!res.ok) return back("verification_failed");
|
|
7983
|
-
({ id_token: idToken } = await res.json());
|
|
7984
|
-
} catch {
|
|
7985
|
-
return back("verification_failed");
|
|
7986
|
-
}
|
|
7987
|
-
if (typeof idToken !== "string") return back("verification_failed");
|
|
7988
|
-
const result = await verifyGoogleIdToken(idToken, {
|
|
7989
|
-
clientId: google.clientId,
|
|
7990
|
-
nonce: oauth.nonce,
|
|
7991
|
-
keys: config.jwksResolver
|
|
7992
|
-
});
|
|
7993
|
-
if (!result.ok) return back(result.error);
|
|
7994
|
-
const user = await ctx.startSession(c, result.user, ["google"]);
|
|
7995
|
-
await ctx.emit("user.signed_in", { user, method: "google" });
|
|
7996
|
-
return back();
|
|
7997
|
-
});
|
|
7919
|
+
function googleAccountKey(sub) {
|
|
7920
|
+
return `google:${sub}`;
|
|
7998
7921
|
}
|
|
7999
|
-
|
|
8000
|
-
// src/routes/password.ts
|
|
8001
|
-
var FREE_ATTEMPTS = 5;
|
|
8002
|
-
var MAX_LOCK_MS = 15 * 60 * 1e3;
|
|
8003
|
-
var MAIL_INTERVAL_MS = 60 * 1e3;
|
|
8004
|
-
var VERIFY_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
8005
|
-
var RESET_TTL_MS = 30 * 60 * 1e3;
|
|
8006
|
-
var WEBHOOK_TIMEOUT_MS = 1e4;
|
|
8007
|
-
var VERIFY_LINK_PARAM = "madauth_verify";
|
|
8008
|
-
var RESET_LINK_PARAM = "madauth_reset";
|
|
8009
|
-
var MAX_NAME_LENGTH = 100;
|
|
8010
|
-
var MAX_LOCALE_LENGTH = 35;
|
|
8011
|
-
function toMadauthUser(user) {
|
|
7922
|
+
function toMadauthUser(user, profile = {}) {
|
|
8012
7923
|
const result = { id: user.id, email: user.email };
|
|
8013
|
-
|
|
7924
|
+
const name = user.name ?? profile.name;
|
|
7925
|
+
if (name) result.name = name;
|
|
7926
|
+
if (profile.picture) result.picture = profile.picture;
|
|
7927
|
+
const claims = claimsFromJson(user.claims);
|
|
7928
|
+
if (Object.keys(claims).length) result.claims = claims;
|
|
8014
7929
|
return result;
|
|
8015
7930
|
}
|
|
8016
|
-
|
|
8017
|
-
|
|
8018
|
-
|
|
8019
|
-
|
|
8020
|
-
|
|
8021
|
-
}
|
|
8022
|
-
|
|
8023
|
-
|
|
8024
|
-
|
|
8025
|
-
|
|
8026
|
-
|
|
8027
|
-
return
|
|
8028
|
-
}
|
|
8029
|
-
|
|
8030
|
-
|
|
8031
|
-
|
|
8032
|
-
|
|
8033
|
-
|
|
8034
|
-
|
|
7931
|
+
var Users = class {
|
|
7932
|
+
/** `codeKey` keys the e-mail codes (see deriveCodeKey); only needed to issue and check them. */
|
|
7933
|
+
constructor(store, codeKey) {
|
|
7934
|
+
this.store = store;
|
|
7935
|
+
this.codeKey = codeKey;
|
|
7936
|
+
}
|
|
7937
|
+
get key() {
|
|
7938
|
+
if (!this.codeKey) throw new Error("Users needs a code key to issue or check e-mail codes.");
|
|
7939
|
+
return this.codeKey;
|
|
7940
|
+
}
|
|
7941
|
+
async findByEmail(emailNormalized) {
|
|
7942
|
+
return await this.store.findOne("user", { emailNormalized });
|
|
7943
|
+
}
|
|
7944
|
+
async findById(id) {
|
|
7945
|
+
return await this.store.findOne("user", { id });
|
|
7946
|
+
}
|
|
7947
|
+
async passwordAccount(userId) {
|
|
7948
|
+
return this.findAccountByKey(passwordAccountKey(userId));
|
|
7949
|
+
}
|
|
7950
|
+
async findAccountByKey(key) {
|
|
7951
|
+
return await this.store.findOne("account", { key });
|
|
7952
|
+
}
|
|
7953
|
+
/**
|
|
7954
|
+
* Creates a user with their first account. Resolves to null if the e-mail address or the account key is
|
|
7955
|
+
* taken. `account.key` is `password:<userId>` for a password (its hash as `secret`), or e.g. `google:<sub>`.
|
|
7956
|
+
*/
|
|
7957
|
+
async createUser(data) {
|
|
7958
|
+
const user = {
|
|
7959
|
+
id: newId("usr"),
|
|
7960
|
+
email: data.email,
|
|
7961
|
+
emailNormalized: data.emailNormalized,
|
|
7962
|
+
emailVerified: data.emailVerified,
|
|
7963
|
+
name: data.name,
|
|
7964
|
+
sessionVersion: 0,
|
|
7965
|
+
lastMailAt: 0,
|
|
7966
|
+
wrongCodes: 0,
|
|
7967
|
+
claims: null,
|
|
7968
|
+
createdAt: Date.now()
|
|
7969
|
+
};
|
|
7970
|
+
if (!await this.store.create("user", user)) return null;
|
|
7971
|
+
const account = typeof data.account === "function" ? data.account(user.id) : data.account;
|
|
7972
|
+
if (!await this.linkAccount(user.id, account)) {
|
|
7973
|
+
await this.store.delete("user", { id: user.id });
|
|
7974
|
+
return null;
|
|
8035
7975
|
}
|
|
8036
|
-
|
|
8037
|
-
|
|
8038
|
-
|
|
8039
|
-
|
|
8040
|
-
|
|
8041
|
-
|
|
8042
|
-
|
|
8043
|
-
|
|
8044
|
-
|
|
8045
|
-
|
|
8046
|
-
|
|
7976
|
+
return user;
|
|
7977
|
+
}
|
|
7978
|
+
/**
|
|
7979
|
+
* A provider verified the user's address: an unconfirmed password sign-up with it, whose password nobody
|
|
7980
|
+
* has proven, is dropped (the account and its pending links and codes), and the user counts as verified.
|
|
7981
|
+
*/
|
|
7982
|
+
async verifyByProvider(user) {
|
|
7983
|
+
if (user.emailVerified) return;
|
|
7984
|
+
await this.store.delete("account", { key: passwordAccountKey(user.id) });
|
|
7985
|
+
await this.clearVerifications(user.id);
|
|
7986
|
+
await this.updateUser(user.id, { emailVerified: true, wrongCodes: 0 });
|
|
7987
|
+
}
|
|
7988
|
+
/** Removes an account record, e.g. one left behind by a deletion that did not finish. */
|
|
7989
|
+
async deleteAccount(id) {
|
|
7990
|
+
await this.store.delete("account", { id });
|
|
7991
|
+
}
|
|
7992
|
+
/** Adds a sign-in method to a user. Resolves to null if the account key is taken. */
|
|
7993
|
+
async linkAccount(userId, account) {
|
|
7994
|
+
const record = {
|
|
7995
|
+
id: newId("acc"),
|
|
7996
|
+
userId,
|
|
7997
|
+
key: account.key,
|
|
7998
|
+
secret: account.secret ?? null,
|
|
7999
|
+
failedAttempts: 0,
|
|
8000
|
+
lockedUntil: 0,
|
|
8001
|
+
email: account.email ?? null,
|
|
8002
|
+
createdAt: Date.now()
|
|
8003
|
+
};
|
|
8004
|
+
return await this.store.create("account", record) ? record : null;
|
|
8005
|
+
}
|
|
8006
|
+
/**
|
|
8007
|
+
* Deletes the user and everything stored for them. Resolves to false if there is no such user (any more).
|
|
8008
|
+
*/
|
|
8009
|
+
async deleteById(id) {
|
|
8010
|
+
const user = await this.findById(id);
|
|
8011
|
+
if (!user) return false;
|
|
8012
|
+
if (await this.store.delete("user", { id: user.id }) !== 1) return false;
|
|
8013
|
+
try {
|
|
8014
|
+
await this.store.delete("account", { userId: user.id });
|
|
8015
|
+
await this.store.delete("verification", { userId: user.id });
|
|
8016
|
+
} catch (e) {
|
|
8017
|
+
console.error(`[madauth] Deleted user ${user.id}, but not all of their records: ${e.message}`);
|
|
8018
|
+
}
|
|
8019
|
+
return true;
|
|
8020
|
+
}
|
|
8021
|
+
async updateUser(id, patch) {
|
|
8022
|
+
await this.store.update("user", { id }, patch);
|
|
8023
|
+
}
|
|
8024
|
+
async updateAccount(id, patch) {
|
|
8025
|
+
await this.store.update("account", { id }, patch);
|
|
8026
|
+
}
|
|
8027
|
+
/**
|
|
8028
|
+
* Counts a sign-in attempt: writes `patch` only if the account's `failedAttempts` is still the value that
|
|
8029
|
+
* was read. Resolves to false if another request counted an attempt meanwhile.
|
|
8030
|
+
*/
|
|
8031
|
+
async countAttempt(account, patch) {
|
|
8032
|
+
const changed = await this.store.update("account", { id: account.id, failedAttempts: account.failedAttempts }, patch);
|
|
8033
|
+
return changed === 1;
|
|
8034
|
+
}
|
|
8035
|
+
/** Deletes the user's pending verifications and password resets; their links and codes stop working. */
|
|
8036
|
+
async clearVerifications(userId) {
|
|
8037
|
+
await this.store.delete("verification", { userId });
|
|
8038
|
+
}
|
|
8039
|
+
/**
|
|
8040
|
+
* Starts a verification or password reset: replaces the user's earlier one for the same purpose and
|
|
8041
|
+
* returns the link token and the code for the e-mail. Only their hashes are stored.
|
|
8042
|
+
*/
|
|
8043
|
+
async issueVerification(userId, purpose, ttlMs) {
|
|
8044
|
+
await this.store.delete("verification", { userId, purpose });
|
|
8045
|
+
const token = generateLinkToken();
|
|
8046
|
+
const code = generateCode();
|
|
8047
|
+
const record = {
|
|
8048
|
+
id: hashLinkToken(token),
|
|
8049
|
+
userId,
|
|
8050
|
+
purpose,
|
|
8051
|
+
codeHash: hashCode(this.key, code),
|
|
8052
|
+
attempts: 0,
|
|
8053
|
+
expiresAt: Date.now() + ttlMs
|
|
8054
|
+
};
|
|
8055
|
+
await this.store.create("verification", record);
|
|
8056
|
+
return { token, code };
|
|
8057
|
+
}
|
|
8058
|
+
/** Uses up the verification a link token belongs to. Resolves to its user ID, or null if invalid, expired or used. */
|
|
8059
|
+
async consumeLinkToken(token, purpose) {
|
|
8060
|
+
const id = hashLinkToken(token);
|
|
8061
|
+
const record = await this.store.findOne("verification", { id, purpose });
|
|
8062
|
+
return record ? this.consume(record) : null;
|
|
8063
|
+
}
|
|
8064
|
+
/**
|
|
8065
|
+
* Uses up the user's verification if `code` matches. Too many wrong codes delete it, and after
|
|
8066
|
+
* MAX_WRONG_CODES wrong codes in a row the user's codes stop working until an e-mail link is used.
|
|
8067
|
+
*/
|
|
8068
|
+
async consumeCode(user, purpose, code) {
|
|
8069
|
+
if ((user.wrongCodes ?? 0) >= MAX_WRONG_CODES) return { userId: null, locked: true };
|
|
8070
|
+
const record = await this.store.findOne("verification", { userId: user.id, purpose });
|
|
8071
|
+
if (!record) return { userId: null, locked: false };
|
|
8072
|
+
const attempts = record.attempts + 1;
|
|
8073
|
+
if (await this.store.update("verification", { id: record.id, attempts: record.attempts }, { attempts }) !== 1) {
|
|
8074
|
+
return { userId: null, locked: false };
|
|
8075
|
+
}
|
|
8076
|
+
const wrongCodes = await this.countWrongCode(user);
|
|
8077
|
+
if (wrongCodes === null) return { userId: null, locked: false };
|
|
8078
|
+
if (wrongCodes > MAX_WRONG_CODES) return { userId: null, locked: true };
|
|
8079
|
+
if (!safeEqual(hashCode(this.key, code), record.codeHash)) {
|
|
8080
|
+
if (attempts >= MAX_CODE_ATTEMPTS) await this.store.delete("verification", { id: record.id });
|
|
8081
|
+
return { userId: null, locked: wrongCodes >= MAX_WRONG_CODES };
|
|
8082
|
+
}
|
|
8083
|
+
return { userId: await this.consume(record), locked: false };
|
|
8084
|
+
}
|
|
8085
|
+
/** Counts a code for the user and resolves to the new count; null if other requests kept changing it. */
|
|
8086
|
+
async countWrongCode(user) {
|
|
8087
|
+
let current = user;
|
|
8088
|
+
for (let attempt = 0; attempt < 3 && current; attempt++) {
|
|
8089
|
+
const wrongCodes = (current.wrongCodes ?? 0) + 1;
|
|
8090
|
+
if (await this.store.update("user", { id: user.id, wrongCodes: current.wrongCodes ?? null }, { wrongCodes }) === 1) {
|
|
8091
|
+
return wrongCodes;
|
|
8092
|
+
}
|
|
8093
|
+
current = await this.findById(user.id);
|
|
8094
|
+
}
|
|
8095
|
+
return null;
|
|
8096
|
+
}
|
|
8097
|
+
async consume(record) {
|
|
8098
|
+
const deleted = await this.store.delete("verification", { id: record.id });
|
|
8099
|
+
if (deleted !== 1 || record.expiresAt <= Date.now()) return null;
|
|
8100
|
+
return record.userId;
|
|
8101
|
+
}
|
|
8102
|
+
};
|
|
8103
|
+
|
|
8104
|
+
// src/webhooks.ts
|
|
8105
|
+
import { createHmac as createHmac2, randomBytes as randomBytes3, randomUUID, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
8106
|
+
var WEBHOOK_TYPES = [
|
|
8107
|
+
"email.verify",
|
|
8108
|
+
"email.reset",
|
|
8109
|
+
"email.already_registered",
|
|
8110
|
+
"signup.before",
|
|
8111
|
+
"user.created",
|
|
8112
|
+
"email.verified",
|
|
8113
|
+
"email.password_reset",
|
|
8114
|
+
"user.signed_in",
|
|
8115
|
+
"user.deleted",
|
|
8116
|
+
"user.claims_changed"
|
|
8117
|
+
];
|
|
8118
|
+
var WEBHOOK_TIMEOUT_MS = 1e4;
|
|
8119
|
+
var MAX_LOCALE_LENGTH = 35;
|
|
8120
|
+
function localeOf(value) {
|
|
8121
|
+
return typeof value === "string" && value.length <= MAX_LOCALE_LENGTH ? value : void 0;
|
|
8122
|
+
}
|
|
8123
|
+
var REQUIRED_EMAIL_TYPES = ["email.verify", "email.reset"];
|
|
8124
|
+
var SECRET_PREFIX = "whsec_";
|
|
8125
|
+
var DEV_WEBHOOK_RECEIVER_URL = "https://github.com/inouiw/madAuth-webhooks/tree/main/dev-receiver";
|
|
8126
|
+
var WEBHOOK_TOLERANCE_SECONDS = 5 * 60;
|
|
8127
|
+
function generateWebhookSecret() {
|
|
8128
|
+
return `${SECRET_PREFIX}${randomBytes3(32).toString("base64")}`;
|
|
8129
|
+
}
|
|
8130
|
+
function checkWebhookSecret(secret) {
|
|
8131
|
+
if (!secret.startsWith(SECRET_PREFIX)) return `must start with "${SECRET_PREFIX}"`;
|
|
8132
|
+
const key = Buffer.from(secret.slice(SECRET_PREFIX.length), "base64");
|
|
8133
|
+
if (key.length < 24 || key.length > 64) return "must contain 24 to 64 bytes in base64";
|
|
8134
|
+
return null;
|
|
8135
|
+
}
|
|
8136
|
+
function hmac2(secret, content) {
|
|
8137
|
+
const key = Buffer.from(secret.slice(SECRET_PREFIX.length), "base64");
|
|
8138
|
+
return createHmac2("sha256", key).update(content).digest("base64");
|
|
8139
|
+
}
|
|
8140
|
+
function signWebhook(secret, id, timestamp, body) {
|
|
8141
|
+
return `v1,${hmac2(secret, `${id}.${timestamp}.${body}`)}`;
|
|
8142
|
+
}
|
|
8143
|
+
function createWebhookClient(settings, fetchImpl = fetch) {
|
|
8144
|
+
const wants = (type) => settings.events.has(type);
|
|
8145
|
+
return {
|
|
8146
|
+
wants,
|
|
8147
|
+
async call(type, data, timeoutMs) {
|
|
8148
|
+
if (!wants(type)) return { ok: true, body: void 0 };
|
|
8149
|
+
const id = `msg_${randomUUID()}`;
|
|
8150
|
+
const timestamp = Math.floor(Date.now() / 1e3);
|
|
8151
|
+
const body = JSON.stringify({ type, data });
|
|
8152
|
+
let response;
|
|
8153
|
+
try {
|
|
8154
|
+
response = await fetchImpl(settings.url, {
|
|
8155
|
+
method: "POST",
|
|
8156
|
+
headers: {
|
|
8157
|
+
"Content-Type": "application/json",
|
|
8158
|
+
"webhook-id": id,
|
|
8159
|
+
"webhook-timestamp": String(timestamp),
|
|
8160
|
+
"webhook-signature": signWebhook(settings.secret, id, timestamp, body)
|
|
8161
|
+
},
|
|
8162
|
+
body,
|
|
8163
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
8164
|
+
});
|
|
8165
|
+
} catch (e) {
|
|
8166
|
+
const reason = e.name === "TimeoutError" ? `no answer within ${timeoutMs} ms` : e.message;
|
|
8167
|
+
return { ok: false, reason };
|
|
8168
|
+
}
|
|
8169
|
+
const text = await response.text().catch(() => "");
|
|
8170
|
+
if (!response.ok) return { ok: false, reason: `HTTP ${response.status}` };
|
|
8171
|
+
let parsed;
|
|
8172
|
+
try {
|
|
8173
|
+
parsed = text ? JSON.parse(text) : void 0;
|
|
8174
|
+
} catch {
|
|
8175
|
+
parsed = void 0;
|
|
8176
|
+
}
|
|
8177
|
+
return { ok: true, body: parsed };
|
|
8178
|
+
}
|
|
8179
|
+
};
|
|
8180
|
+
}
|
|
8181
|
+
|
|
8182
|
+
// src/routes/google.ts
|
|
8183
|
+
var NONCE_COOKIE = "madauth_nonce";
|
|
8184
|
+
var NONCE_TYP = "madauth-nonce+jwt";
|
|
8185
|
+
var NONCE_TTL = 5 * 60;
|
|
8186
|
+
var OAUTH_COOKIE = "madauth_oauth";
|
|
8187
|
+
var OAUTH_TYP = "madauth-oauth-state+jwt";
|
|
8188
|
+
var OAUTH_TTL = 10 * 60;
|
|
8189
|
+
var GOOGLE_AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
8190
|
+
var GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
|
|
8191
|
+
var REDIRECT_ERROR_PARAM = "madauth_error";
|
|
8192
|
+
async function pkceChallenge(verifier) {
|
|
8193
|
+
const digest2 = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
8194
|
+
return base64url_exports.encode(new Uint8Array(digest2));
|
|
8195
|
+
}
|
|
8196
|
+
function googleRoutes(app2, ctx, google) {
|
|
8197
|
+
const { config, keys, secure, users } = ctx;
|
|
8198
|
+
const { issuer } = config;
|
|
8199
|
+
const callbackUrl = `${issuer}/auth/google/callback`;
|
|
8200
|
+
const resolveUser = async (profile, locale) => {
|
|
8201
|
+
const key = googleAccountKey(profile.sub);
|
|
8202
|
+
const emailNormalized = normalizeEmail(profile.email);
|
|
8203
|
+
const signedIn = (user) => ({ ok: true, user: toMadauthUser(user, profile), sv: user.sessionVersion });
|
|
8204
|
+
const account = await users.findAccountByKey(key);
|
|
8205
|
+
if (account) {
|
|
8206
|
+
const user = await users.findById(account.userId);
|
|
8207
|
+
if (user) {
|
|
8208
|
+
if (account.email !== profile.email) await users.updateAccount(account.id, { email: profile.email });
|
|
8209
|
+
return signedIn(user);
|
|
8210
|
+
}
|
|
8211
|
+
await users.deleteAccount(account.id);
|
|
8212
|
+
}
|
|
8213
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
8214
|
+
const existing = await users.findByEmail(emailNormalized);
|
|
8215
|
+
if (existing) {
|
|
8216
|
+
await users.verifyByProvider(existing);
|
|
8217
|
+
if (!await users.linkAccount(existing.id, { key, email: profile.email })) {
|
|
8218
|
+
const linked = await users.findAccountByKey(key);
|
|
8219
|
+
const owner = linked && await users.findById(linked.userId);
|
|
8220
|
+
if (!owner) return { ok: false, error: "temporarily_unavailable", message: "Please try again." };
|
|
8221
|
+
return signedIn(owner);
|
|
8222
|
+
}
|
|
8223
|
+
return signedIn(existing);
|
|
8224
|
+
}
|
|
8225
|
+
if (attempt === 0) {
|
|
8226
|
+
const check2 = await ctx.checkSignup({ email: profile.email, name: profile.name, locale, method: "google" });
|
|
8227
|
+
if (!check2.ok) {
|
|
8228
|
+
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." };
|
|
8229
|
+
}
|
|
8230
|
+
}
|
|
8231
|
+
const user = await users.createUser({
|
|
8232
|
+
email: profile.email,
|
|
8233
|
+
emailNormalized,
|
|
8234
|
+
name: profile.name ?? null,
|
|
8235
|
+
emailVerified: true,
|
|
8236
|
+
account: { key, email: profile.email }
|
|
8237
|
+
});
|
|
8238
|
+
if (user) {
|
|
8239
|
+
await ctx.emit("user.created", { user: toMadauthUser(user), method: "google" });
|
|
8240
|
+
return signedIn(user);
|
|
8241
|
+
}
|
|
8242
|
+
}
|
|
8243
|
+
return { ok: false, error: "temporarily_unavailable", message: "Please try again." };
|
|
8244
|
+
};
|
|
8245
|
+
const disabled = (c) => c.json({ error: "method_disabled", message: "Google sign-in is switched off." }, 403);
|
|
8246
|
+
app2.post("/auth/google/nonce", async (c) => {
|
|
8247
|
+
if (!await ctx.enabled("google")) return disabled(c);
|
|
8248
|
+
const nonce = randomString();
|
|
8249
|
+
const token = await signToken(await keys, issuer, NONCE_TYP, { nonce }, NONCE_TTL);
|
|
8250
|
+
setCookie(c, NONCE_COOKIE, token, {
|
|
8251
|
+
path: "/auth/google",
|
|
8252
|
+
httpOnly: true,
|
|
8253
|
+
secure,
|
|
8254
|
+
sameSite: "Strict",
|
|
8255
|
+
maxAge: NONCE_TTL
|
|
8256
|
+
});
|
|
8257
|
+
return c.json({ nonce });
|
|
8258
|
+
});
|
|
8259
|
+
app2.post("/auth/google/verify", async (c) => {
|
|
8260
|
+
if (!await ctx.enabled("google")) return disabled(c);
|
|
8261
|
+
const body = await c.req.json().catch(() => ({}));
|
|
8262
|
+
if (typeof body.credential !== "string" || !body.credential) {
|
|
8263
|
+
return c.json({ error: "verification_failed", message: "credential is missing" }, 400);
|
|
8264
|
+
}
|
|
8265
|
+
const nonceClaims = await readToken(await keys, issuer, NONCE_TYP, getCookie(c, NONCE_COOKIE));
|
|
8266
|
+
if (!nonceClaims) {
|
|
8267
|
+
return c.json({ error: "verification_failed", message: "nonce cookie missing or expired" }, 401);
|
|
8268
|
+
}
|
|
8269
|
+
const result = await verifyGoogleIdToken(body.credential, {
|
|
8270
|
+
clientId: google.clientId,
|
|
8271
|
+
nonce: nonceClaims.nonce,
|
|
8272
|
+
keys: config.jwksResolver
|
|
8273
|
+
});
|
|
8274
|
+
if (!result.ok) {
|
|
8275
|
+
return c.json({ error: result.error, message: result.reason }, result.error === "email_unverified" ? 403 : 401);
|
|
8276
|
+
}
|
|
8277
|
+
deleteCookie(c, NONCE_COOKIE, { path: "/auth/google", secure });
|
|
8278
|
+
const resolved = await resolveUser(result.profile, localeOf(body.locale));
|
|
8279
|
+
if (!resolved.ok) {
|
|
8280
|
+
return c.json({ error: resolved.error, message: resolved.message }, resolved.error === "signup_rejected" ? 403 : 503);
|
|
8281
|
+
}
|
|
8282
|
+
const user = await ctx.startSession(c, resolved.user, ["google"], { sv: resolved.sv });
|
|
8283
|
+
await ctx.emit("user.signed_in", { user, method: "google" });
|
|
8284
|
+
return c.json({ user });
|
|
8285
|
+
});
|
|
8286
|
+
const requireCodeFlow = async (c, next) => {
|
|
8287
|
+
if (!google.clientSecret) return c.json({ error: "not_found" }, 404);
|
|
8288
|
+
if (!await ctx.enabled("google")) return disabled(c);
|
|
8289
|
+
await next();
|
|
8290
|
+
};
|
|
8291
|
+
app2.get("/auth/google/start", requireCodeFlow, async (c) => {
|
|
8292
|
+
const returnUrl = ctx.allowedUrl(c.req.query("return_to"));
|
|
8293
|
+
if (!returnUrl) return c.text("return_to must be an absolute URL whose origin is in ALLOWED_ORIGINS", 400);
|
|
8294
|
+
const oauth = {
|
|
8295
|
+
state: randomString(),
|
|
8296
|
+
nonce: randomString(),
|
|
8297
|
+
verifier: randomString(),
|
|
8298
|
+
returnTo: returnUrl.href,
|
|
8299
|
+
locale: localeOf(c.req.query("locale"))
|
|
8300
|
+
};
|
|
8301
|
+
const token = await signToken(await keys, issuer, OAUTH_TYP, { ...oauth }, OAUTH_TTL);
|
|
8302
|
+
setCookie(c, OAUTH_COOKIE, token, {
|
|
8303
|
+
path: "/auth/google",
|
|
8304
|
+
httpOnly: true,
|
|
8305
|
+
secure,
|
|
8306
|
+
sameSite: "Lax",
|
|
8307
|
+
maxAge: OAUTH_TTL
|
|
8308
|
+
});
|
|
8309
|
+
const url = new URL(GOOGLE_AUTH_ENDPOINT);
|
|
8310
|
+
url.search = new URLSearchParams({
|
|
8311
|
+
client_id: google.clientId,
|
|
8312
|
+
redirect_uri: callbackUrl,
|
|
8313
|
+
response_type: "code",
|
|
8314
|
+
scope: "openid email profile",
|
|
8315
|
+
state: oauth.state,
|
|
8316
|
+
nonce: oauth.nonce,
|
|
8317
|
+
code_challenge: await pkceChallenge(oauth.verifier),
|
|
8318
|
+
code_challenge_method: "S256"
|
|
8319
|
+
}).toString();
|
|
8320
|
+
return c.redirect(url.href, 302);
|
|
8321
|
+
});
|
|
8322
|
+
app2.get("/auth/google/callback", requireCodeFlow, async (c) => {
|
|
8323
|
+
const oauth = await readToken(await keys, issuer, OAUTH_TYP, getCookie(c, OAUTH_COOKIE));
|
|
8324
|
+
if (!oauth) return c.text("Sign-in expired or was started in another browser. Please try again.", 400);
|
|
8325
|
+
deleteCookie(c, OAUTH_COOKIE, { path: "/auth/google", secure });
|
|
8326
|
+
const back = (error) => {
|
|
8327
|
+
const url = new URL(oauth.returnTo);
|
|
8328
|
+
if (error) url.hash = `${REDIRECT_ERROR_PARAM}=${error}`;
|
|
8329
|
+
return c.redirect(url.href, 302);
|
|
8330
|
+
};
|
|
8331
|
+
if (c.req.query("error")) return back(c.req.query("error") === "access_denied" ? "cancelled" : "verification_failed");
|
|
8332
|
+
const code = c.req.query("code");
|
|
8333
|
+
if (!code || c.req.query("state") !== oauth.state) return back("verification_failed");
|
|
8334
|
+
let idToken;
|
|
8335
|
+
try {
|
|
8336
|
+
const res = await fetch(GOOGLE_TOKEN_ENDPOINT, {
|
|
8337
|
+
method: "POST",
|
|
8338
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
8339
|
+
body: new URLSearchParams({
|
|
8340
|
+
code,
|
|
8341
|
+
client_id: google.clientId,
|
|
8342
|
+
client_secret: google.clientSecret,
|
|
8343
|
+
redirect_uri: callbackUrl,
|
|
8344
|
+
grant_type: "authorization_code",
|
|
8345
|
+
code_verifier: oauth.verifier
|
|
8346
|
+
})
|
|
8347
|
+
});
|
|
8348
|
+
if (!res.ok) return back("verification_failed");
|
|
8349
|
+
({ id_token: idToken } = await res.json());
|
|
8350
|
+
} catch {
|
|
8351
|
+
return back("verification_failed");
|
|
8352
|
+
}
|
|
8353
|
+
if (typeof idToken !== "string") return back("verification_failed");
|
|
8354
|
+
const result = await verifyGoogleIdToken(idToken, {
|
|
8355
|
+
clientId: google.clientId,
|
|
8356
|
+
nonce: oauth.nonce,
|
|
8357
|
+
keys: config.jwksResolver
|
|
8358
|
+
});
|
|
8359
|
+
if (!result.ok) return back(result.error);
|
|
8360
|
+
const resolved = await resolveUser(result.profile, typeof oauth.locale === "string" ? oauth.locale : void 0);
|
|
8361
|
+
if (!resolved.ok) return back(resolved.error);
|
|
8362
|
+
const user = await ctx.startSession(c, resolved.user, ["google"], { sv: resolved.sv });
|
|
8363
|
+
await ctx.emit("user.signed_in", { user, method: "google" });
|
|
8364
|
+
return back();
|
|
8365
|
+
});
|
|
8366
|
+
}
|
|
8367
|
+
|
|
8368
|
+
// src/routes/password.ts
|
|
8369
|
+
var FREE_ATTEMPTS = 5;
|
|
8370
|
+
var MAX_LOCK_MS = 15 * 60 * 1e3;
|
|
8371
|
+
var MAIL_INTERVAL_MS = 60 * 1e3;
|
|
8372
|
+
var VERIFY_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
8373
|
+
var RESET_TTL_MS = 30 * 60 * 1e3;
|
|
8374
|
+
var VERIFY_LINK_PARAM = "madauth_verify";
|
|
8375
|
+
var RESET_LINK_PARAM = "madauth_reset";
|
|
8376
|
+
var MAX_NAME_LENGTH = 100;
|
|
8377
|
+
function passwordRoutes(app2, ctx, password, users, webhook) {
|
|
8378
|
+
const { minLength } = password;
|
|
8379
|
+
const disabled = (c) => c.json({ error: "method_disabled", message: "E-mail & password sign-in is switched off." }, 403);
|
|
8380
|
+
app2.use("/auth/password/*", async (c, next) => {
|
|
8381
|
+
if (!await ctx.enabled("password")) return disabled(c);
|
|
8382
|
+
await next();
|
|
8383
|
+
});
|
|
8384
|
+
const body = async (c) => {
|
|
8385
|
+
const data = await c.req.json().catch(() => null);
|
|
8386
|
+
return data && typeof data === "object" ? data : {};
|
|
8387
|
+
};
|
|
8388
|
+
const error = (c, status, code, message2) => c.json({ error: code, message: message2 }, status);
|
|
8389
|
+
const unavailable = (c) => error(c, 503, "temporarily_unavailable", "E-mails can not be sent right now. Please try again later.");
|
|
8390
|
+
const accepted = (c) => c.json({}, 202);
|
|
8391
|
+
const redirectTarget = (c, value) => {
|
|
8392
|
+
const url = ctx.allowedUrl(value);
|
|
8393
|
+
return url ?? error(c, 400, "invalid_options", "redirectTo must be an absolute URL whose origin is in ALLOWED_ORIGINS.");
|
|
8394
|
+
};
|
|
8395
|
+
const sendEmail = async (user, type, data) => {
|
|
8396
|
+
if (Date.now() - user.lastMailAt < MAIL_INTERVAL_MS) return true;
|
|
8397
|
+
const result = await webhook.call(type, { to: user.email, user: { id: user.id, name: user.name ?? void 0 }, ...data }, WEBHOOK_TIMEOUT_MS);
|
|
8398
|
+
if (!result.ok) {
|
|
8399
|
+
console.error(`[madauth] Webhook "${type}" for ${user.email} failed: ${result.reason}`);
|
|
8400
|
+
return false;
|
|
8401
|
+
}
|
|
8402
|
+
await users.updateUser(user.id, { lastMailAt: Date.now() });
|
|
8403
|
+
return true;
|
|
8404
|
+
};
|
|
8405
|
+
const sendLinkEmail = async (user, purpose, target, locale) => {
|
|
8406
|
+
if (Date.now() - user.lastMailAt < MAIL_INTERVAL_MS) return true;
|
|
8407
|
+
const ttl = purpose === "verify" ? VERIFY_TTL_MS : RESET_TTL_MS;
|
|
8408
|
+
const { token, code } = await users.issueVerification(user.id, purpose, ttl);
|
|
8409
|
+
const link = new URL(target);
|
|
8410
|
+
link.hash = `${purpose === "verify" ? VERIFY_LINK_PARAM : RESET_LINK_PARAM}=${token}`;
|
|
8411
|
+
return sendEmail(user, purpose === "verify" ? "email.verify" : "email.reset", {
|
|
8412
|
+
link: link.href,
|
|
8047
8413
|
code,
|
|
8048
8414
|
expiresAt: Date.now() + ttl,
|
|
8049
8415
|
site: target.host,
|
|
8050
8416
|
locale
|
|
8051
8417
|
});
|
|
8052
8418
|
};
|
|
8053
|
-
const localeOf = (data) => typeof data.locale === "string" && data.locale.length <= MAX_LOCALE_LENGTH ? data.locale : void 0;
|
|
8054
8419
|
const consume = async (data, purpose) => {
|
|
8055
8420
|
if (typeof data.token === "string" && data.token) {
|
|
8056
8421
|
return { userId: await users.consumeLinkToken(data.token, purpose), via: "link" };
|
|
@@ -8106,25 +8471,15 @@ function passwordRoutes(app2, ctx, password, users, webhook) {
|
|
|
8106
8471
|
app2.post("/auth/password/signup", async (c) => {
|
|
8107
8472
|
const data = await body(c);
|
|
8108
8473
|
const target = redirectTarget(c, data.redirectTo);
|
|
8109
|
-
if (target instanceof Response) return target;
|
|
8110
|
-
if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
|
|
8111
|
-
const policy = checkPasswordPolicy(data.password, minLength);
|
|
8112
|
-
if (policy) return error(c, 400, "weak_password", policy);
|
|
8113
|
-
const name = typeof data.name === "string" && data.name.trim() ? data.name.trim().slice(0, MAX_NAME_LENGTH) : null;
|
|
8114
|
-
const email = data.email.trim();
|
|
8115
|
-
const locale = localeOf(data);
|
|
8116
|
-
|
|
8117
|
-
|
|
8118
|
-
if (!check2.ok) {
|
|
8119
|
-
console.error(`[madauth] Webhook "signup.before" failed: ${check2.reason}`);
|
|
8120
|
-
return unavailable(c);
|
|
8121
|
-
}
|
|
8122
|
-
const answer = check2.body;
|
|
8123
|
-
if (answer?.allow === false) {
|
|
8124
|
-
const message2 = typeof answer.message === "string" && answer.message ? answer.message : "Sign-up is not possible with this e-mail address.";
|
|
8125
|
-
return error(c, 403, "signup_rejected", message2);
|
|
8126
|
-
}
|
|
8127
|
-
}
|
|
8474
|
+
if (target instanceof Response) return target;
|
|
8475
|
+
if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
|
|
8476
|
+
const policy = checkPasswordPolicy(data.password, minLength);
|
|
8477
|
+
if (policy) return error(c, 400, "weak_password", policy);
|
|
8478
|
+
const name = typeof data.name === "string" && data.name.trim() ? data.name.trim().slice(0, MAX_NAME_LENGTH) : null;
|
|
8479
|
+
const email = data.email.trim();
|
|
8480
|
+
const locale = localeOf(data.locale);
|
|
8481
|
+
const check2 = await ctx.checkSignup({ email, name: name ?? void 0, locale, method: "password" });
|
|
8482
|
+
if (!check2.ok) return check2.reason === "rejected" ? error(c, 403, "signup_rejected", check2.message) : unavailable(c);
|
|
8128
8483
|
const passwordHash = await hashPassword(data.password);
|
|
8129
8484
|
const existing = await users.findByEmail(normalizeEmail(email));
|
|
8130
8485
|
if (existing?.emailVerified) {
|
|
@@ -8136,18 +8491,19 @@ function passwordRoutes(app2, ctx, password, users, webhook) {
|
|
|
8136
8491
|
if (Date.now() - existing.lastMailAt < MAIL_INTERVAL_MS) return accepted(c);
|
|
8137
8492
|
const account = await users.passwordAccount(existing.id);
|
|
8138
8493
|
if (account) await users.updateAccount(account.id, { secret: passwordHash, failedAttempts: 0, lockedUntil: 0 });
|
|
8494
|
+
else await users.linkAccount(existing.id, { key: passwordAccountKey(existing.id), secret: passwordHash });
|
|
8139
8495
|
await users.updateUser(existing.id, { name });
|
|
8140
8496
|
return await sendLinkEmail({ ...existing, name }, "verify", target, locale) ? accepted(c) : unavailable(c);
|
|
8141
8497
|
}
|
|
8142
|
-
const user = await users.
|
|
8498
|
+
const user = await users.createUser({
|
|
8143
8499
|
email,
|
|
8144
8500
|
emailNormalized: normalizeEmail(email),
|
|
8145
8501
|
name,
|
|
8146
|
-
|
|
8147
|
-
|
|
8502
|
+
emailVerified: false,
|
|
8503
|
+
account: (userId) => ({ key: passwordAccountKey(userId), secret: passwordHash })
|
|
8148
8504
|
});
|
|
8149
8505
|
if (!user) return accepted(c);
|
|
8150
|
-
await ctx.emit("user.created", { user: toMadauthUser(user) });
|
|
8506
|
+
await ctx.emit("user.created", { user: toMadauthUser(user), method: "password" });
|
|
8151
8507
|
return await sendLinkEmail(user, "verify", target, locale) ? accepted(c) : unavailable(c);
|
|
8152
8508
|
});
|
|
8153
8509
|
app2.post("/auth/password/send-verification", async (c) => {
|
|
@@ -8157,7 +8513,7 @@ function passwordRoutes(app2, ctx, password, users, webhook) {
|
|
|
8157
8513
|
if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
|
|
8158
8514
|
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
8159
8515
|
if (user && !user.emailVerified && await users.passwordAccount(user.id)) {
|
|
8160
|
-
if (!await sendLinkEmail(user, "verify", target, localeOf(data))) return unavailable(c);
|
|
8516
|
+
if (!await sendLinkEmail(user, "verify", target, localeOf(data.locale))) return unavailable(c);
|
|
8161
8517
|
}
|
|
8162
8518
|
return accepted(c);
|
|
8163
8519
|
});
|
|
@@ -8178,8 +8534,8 @@ function passwordRoutes(app2, ctx, password, users, webhook) {
|
|
|
8178
8534
|
if (target instanceof Response) return target;
|
|
8179
8535
|
if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
|
|
8180
8536
|
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
8181
|
-
if (user && await users.passwordAccount(user.id)) {
|
|
8182
|
-
if (!await sendLinkEmail(user, "reset", target, localeOf(data))) return unavailable(c);
|
|
8537
|
+
if (user && (user.emailVerified || await users.passwordAccount(user.id))) {
|
|
8538
|
+
if (!await sendLinkEmail(user, "reset", target, localeOf(data.locale))) return unavailable(c);
|
|
8183
8539
|
}
|
|
8184
8540
|
return accepted(c);
|
|
8185
8541
|
});
|
|
@@ -8189,246 +8545,87 @@ function passwordRoutes(app2, ctx, password, users, webhook) {
|
|
|
8189
8545
|
if (policy) return error(c, 400, "weak_password", policy);
|
|
8190
8546
|
const consumed = await consume(data, "reset");
|
|
8191
8547
|
const user = consumed.userId ? await users.findById(consumed.userId) : null;
|
|
8192
|
-
|
|
8193
|
-
|
|
8194
|
-
|
|
8548
|
+
if (!user) return invalidVerification(c, consumed);
|
|
8549
|
+
const secret = await hashPassword(data.password);
|
|
8550
|
+
const account = await users.passwordAccount(user.id);
|
|
8551
|
+
if (account) await users.updateAccount(account.id, { secret, failedAttempts: 0, lockedUntil: 0 });
|
|
8552
|
+
else await users.linkAccount(user.id, { key: passwordAccountKey(user.id), secret });
|
|
8195
8553
|
const sessionVersion = user.sessionVersion + 1;
|
|
8196
8554
|
await users.updateUser(user.id, { emailVerified: true, sessionVersion, wrongCodes: 0 });
|
|
8197
8555
|
await users.clearVerifications(user.id);
|
|
8198
8556
|
const result = await ctx.startSession(c, toMadauthUser(user), ["pwd"], { sv: sessionVersion });
|
|
8199
|
-
await ctx.emit("
|
|
8557
|
+
await ctx.emit("email.password_reset", { user: result });
|
|
8200
8558
|
await ctx.emit("user.signed_in", { user: result, method: "password" });
|
|
8201
8559
|
return c.json({ user: result });
|
|
8202
8560
|
});
|
|
8203
8561
|
}
|
|
8204
8562
|
|
|
8205
|
-
// src/
|
|
8206
|
-
|
|
8207
|
-
var
|
|
8208
|
-
|
|
8209
|
-
|
|
8210
|
-
|
|
8211
|
-
|
|
8212
|
-
|
|
8213
|
-
|
|
8214
|
-
|
|
8215
|
-
|
|
8563
|
+
// src/settings.ts
|
|
8564
|
+
var SIGN_IN_METHODS = ["google", "password"];
|
|
8565
|
+
var METHODS_SETTING = "methods";
|
|
8566
|
+
function parseMethodSettings(input) {
|
|
8567
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) return null;
|
|
8568
|
+
const settings = {};
|
|
8569
|
+
for (const [key, value] of Object.entries(input)) {
|
|
8570
|
+
if (!SIGN_IN_METHODS.includes(key) || typeof value !== "boolean") return null;
|
|
8571
|
+
settings[key] = value;
|
|
8572
|
+
}
|
|
8573
|
+
return settings;
|
|
8574
|
+
}
|
|
8575
|
+
var MAX_TRIES = 3;
|
|
8576
|
+
var Settings = class {
|
|
8577
|
+
constructor(store) {
|
|
8216
8578
|
this.store = store;
|
|
8217
|
-
this.codeKey = codeKey;
|
|
8218
|
-
}
|
|
8219
|
-
get key() {
|
|
8220
|
-
if (!this.codeKey) throw new Error("Users needs a code key to issue or check e-mail codes.");
|
|
8221
|
-
return this.codeKey;
|
|
8222
|
-
}
|
|
8223
|
-
async findByEmail(emailNormalized) {
|
|
8224
|
-
return await this.store.findOne("user", { emailNormalized });
|
|
8225
|
-
}
|
|
8226
|
-
async findById(id) {
|
|
8227
|
-
return await this.store.findOne("user", { id });
|
|
8228
|
-
}
|
|
8229
|
-
async passwordAccount(userId) {
|
|
8230
|
-
return await this.store.findOne("account", { key: passwordAccountKey(userId) });
|
|
8231
8579
|
}
|
|
8232
|
-
/**
|
|
8233
|
-
async
|
|
8234
|
-
|
|
8235
|
-
const user = {
|
|
8236
|
-
id: newId("usr"),
|
|
8237
|
-
email: data.email,
|
|
8238
|
-
emailNormalized: data.emailNormalized,
|
|
8239
|
-
emailVerified: data.emailVerified,
|
|
8240
|
-
name: data.name,
|
|
8241
|
-
sessionVersion: 0,
|
|
8242
|
-
lastMailAt: 0,
|
|
8243
|
-
wrongCodes: 0,
|
|
8244
|
-
createdAt: now
|
|
8245
|
-
};
|
|
8246
|
-
if (!await this.store.create("user", user)) return null;
|
|
8247
|
-
const account = {
|
|
8248
|
-
id: newId("acc"),
|
|
8249
|
-
userId: user.id,
|
|
8250
|
-
key: passwordAccountKey(user.id),
|
|
8251
|
-
secret: data.passwordHash,
|
|
8252
|
-
failedAttempts: 0,
|
|
8253
|
-
lockedUntil: 0,
|
|
8254
|
-
createdAt: now
|
|
8255
|
-
};
|
|
8256
|
-
if (!await this.store.create("account", account)) {
|
|
8257
|
-
await this.store.delete("user", { id: user.id });
|
|
8258
|
-
return null;
|
|
8259
|
-
}
|
|
8260
|
-
return user;
|
|
8580
|
+
/** The setting's value, or undefined if it was never set. */
|
|
8581
|
+
async get(id) {
|
|
8582
|
+
return (await this.read(id)).value;
|
|
8261
8583
|
}
|
|
8262
|
-
/**
|
|
8263
|
-
|
|
8264
|
-
|
|
8265
|
-
|
|
8266
|
-
async deleteByEmail(emailNormalized) {
|
|
8267
|
-
const user = await this.findByEmail(emailNormalized);
|
|
8268
|
-
if (!user) return null;
|
|
8269
|
-
if (await this.store.delete("user", { id: user.id }) !== 1) return null;
|
|
8584
|
+
/** The stored JSON and what it holds; both undefined if the setting was never set. */
|
|
8585
|
+
async read(id) {
|
|
8586
|
+
const record = await this.store.findOne("setting", { id });
|
|
8587
|
+
if (typeof record?.value !== "string") return {};
|
|
8270
8588
|
try {
|
|
8271
|
-
|
|
8272
|
-
|
|
8273
|
-
|
|
8274
|
-
console.error(`[madauth] Deleted user ${user.id}, but not all of their records: ${e.message}`);
|
|
8589
|
+
return { json: record.value, value: JSON.parse(record.value) };
|
|
8590
|
+
} catch {
|
|
8591
|
+
return { json: record.value };
|
|
8275
8592
|
}
|
|
8276
|
-
return user.id;
|
|
8277
|
-
}
|
|
8278
|
-
async updateUser(id, patch) {
|
|
8279
|
-
await this.store.update("user", { id }, patch);
|
|
8280
|
-
}
|
|
8281
|
-
async updateAccount(id, patch) {
|
|
8282
|
-
await this.store.update("account", { id }, patch);
|
|
8283
|
-
}
|
|
8284
|
-
/**
|
|
8285
|
-
* Counts a sign-in attempt: writes `patch` only if the account's `failedAttempts` is still the value that
|
|
8286
|
-
* was read. Resolves to false if another request counted an attempt meanwhile.
|
|
8287
|
-
*/
|
|
8288
|
-
async countAttempt(account, patch) {
|
|
8289
|
-
const changed = await this.store.update("account", { id: account.id, failedAttempts: account.failedAttempts }, patch);
|
|
8290
|
-
return changed === 1;
|
|
8291
|
-
}
|
|
8292
|
-
/** Deletes the user's pending verifications and password resets; their links and codes stop working. */
|
|
8293
|
-
async clearVerifications(userId) {
|
|
8294
|
-
await this.store.delete("verification", { userId });
|
|
8295
|
-
}
|
|
8296
|
-
/**
|
|
8297
|
-
* Starts a verification or password reset: replaces the user's earlier one for the same purpose and
|
|
8298
|
-
* returns the link token and the code for the e-mail. Only their hashes are stored.
|
|
8299
|
-
*/
|
|
8300
|
-
async issueVerification(userId, purpose, ttlMs) {
|
|
8301
|
-
await this.store.delete("verification", { userId, purpose });
|
|
8302
|
-
const token = generateLinkToken();
|
|
8303
|
-
const code = generateCode();
|
|
8304
|
-
const record = {
|
|
8305
|
-
id: hashLinkToken(token),
|
|
8306
|
-
userId,
|
|
8307
|
-
purpose,
|
|
8308
|
-
codeHash: hashCode(this.key, code),
|
|
8309
|
-
attempts: 0,
|
|
8310
|
-
expiresAt: Date.now() + ttlMs
|
|
8311
|
-
};
|
|
8312
|
-
await this.store.create("verification", record);
|
|
8313
|
-
return { token, code };
|
|
8314
|
-
}
|
|
8315
|
-
/** Uses up the verification a link token belongs to. Resolves to its user ID, or null if invalid, expired or used. */
|
|
8316
|
-
async consumeLinkToken(token, purpose) {
|
|
8317
|
-
const id = hashLinkToken(token);
|
|
8318
|
-
const record = await this.store.findOne("verification", { id, purpose });
|
|
8319
|
-
return record ? this.consume(record) : null;
|
|
8320
8593
|
}
|
|
8321
8594
|
/**
|
|
8322
|
-
*
|
|
8323
|
-
*
|
|
8595
|
+
* Changes the setting: `apply` gets the current value and returns the new one, or null to refuse the
|
|
8596
|
+
* change. The write only lands if nobody changed the setting since `apply` read it; otherwise `apply` runs
|
|
8597
|
+
* again with the newer value. Resolves to the new value, or null if `apply` refused.
|
|
8598
|
+
* `updatedBy` is the address of the admin, or null for the command line.
|
|
8324
8599
|
*/
|
|
8325
|
-
async
|
|
8326
|
-
|
|
8327
|
-
|
|
8328
|
-
|
|
8329
|
-
|
|
8330
|
-
|
|
8331
|
-
|
|
8600
|
+
async change(id, apply, updatedBy) {
|
|
8601
|
+
for (let attempt = 0; attempt < MAX_TRIES; attempt++) {
|
|
8602
|
+
const { json, value } = await this.read(id);
|
|
8603
|
+
const next = apply(value);
|
|
8604
|
+
if (next === null) return null;
|
|
8605
|
+
const patch = { value: JSON.stringify(next), updatedAt: Date.now(), updatedBy };
|
|
8606
|
+
const written = json === void 0 ? await this.store.create("setting", { id, ...patch }) : await this.store.update("setting", { id, value: json }, patch) === 1;
|
|
8607
|
+
if (written) return next;
|
|
8332
8608
|
}
|
|
8333
|
-
|
|
8334
|
-
if (wrongCodes === null) return { userId: null, locked: false };
|
|
8335
|
-
if (wrongCodes > MAX_WRONG_CODES) return { userId: null, locked: true };
|
|
8336
|
-
if (!safeEqual(hashCode(this.key, code), record.codeHash)) {
|
|
8337
|
-
if (attempts >= MAX_CODE_ATTEMPTS) await this.store.delete("verification", { id: record.id });
|
|
8338
|
-
return { userId: null, locked: wrongCodes >= MAX_WRONG_CODES };
|
|
8339
|
-
}
|
|
8340
|
-
return { userId: await this.consume(record), locked: false };
|
|
8609
|
+
throw new Error(`Setting "${id}" kept changing; try again.`);
|
|
8341
8610
|
}
|
|
8342
|
-
/**
|
|
8343
|
-
async
|
|
8344
|
-
|
|
8345
|
-
for (let attempt = 0; attempt < 3 && current; attempt++) {
|
|
8346
|
-
const wrongCodes = (current.wrongCodes ?? 0) + 1;
|
|
8347
|
-
if (await this.store.update("user", { id: user.id, wrongCodes: current.wrongCodes ?? null }, { wrongCodes }) === 1) {
|
|
8348
|
-
return wrongCodes;
|
|
8349
|
-
}
|
|
8350
|
-
current = await this.findById(user.id);
|
|
8351
|
-
}
|
|
8352
|
-
return null;
|
|
8611
|
+
/** Replaces the setting. */
|
|
8612
|
+
async set(id, value, updatedBy) {
|
|
8613
|
+
await this.change(id, () => value, updatedBy);
|
|
8353
8614
|
}
|
|
8354
|
-
|
|
8355
|
-
|
|
8356
|
-
|
|
8357
|
-
|
|
8615
|
+
/** Which sign-in methods are on; a method that was never switched off is on. */
|
|
8616
|
+
async methods() {
|
|
8617
|
+
return parseMethodSettings(await this.get(METHODS_SETTING)) ?? {};
|
|
8618
|
+
}
|
|
8619
|
+
/** Changes which methods are on, see {@link change}; `apply` gets the methods as they are now. */
|
|
8620
|
+
async changeMethods(apply, updatedBy) {
|
|
8621
|
+
return this.change(METHODS_SETTING, (current) => apply(parseMethodSettings(current) ?? {}), updatedBy);
|
|
8358
8622
|
}
|
|
8359
8623
|
};
|
|
8360
|
-
|
|
8361
|
-
|
|
8362
|
-
import { createHmac as createHmac2, randomBytes as randomBytes3, randomUUID, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
8363
|
-
var WEBHOOK_TYPES = [
|
|
8364
|
-
"email.verify",
|
|
8365
|
-
"email.reset",
|
|
8366
|
-
"email.already_registered",
|
|
8367
|
-
"signup.before",
|
|
8368
|
-
"user.created",
|
|
8369
|
-
"email.verified",
|
|
8370
|
-
"password.reset",
|
|
8371
|
-
"user.signed_in",
|
|
8372
|
-
"user.deleted",
|
|
8373
|
-
"roles.changed"
|
|
8374
|
-
];
|
|
8375
|
-
var REQUIRED_EMAIL_TYPES = ["email.verify", "email.reset"];
|
|
8376
|
-
var SECRET_PREFIX = "whsec_";
|
|
8377
|
-
var DEV_WEBHOOK_RECEIVER_URL = "https://github.com/inouiw/madAuth-webhooks/tree/main/dev-receiver";
|
|
8378
|
-
var WEBHOOK_TOLERANCE_SECONDS = 5 * 60;
|
|
8379
|
-
function generateWebhookSecret() {
|
|
8380
|
-
return `${SECRET_PREFIX}${randomBytes3(32).toString("base64")}`;
|
|
8381
|
-
}
|
|
8382
|
-
function checkWebhookSecret(secret) {
|
|
8383
|
-
if (!secret.startsWith(SECRET_PREFIX)) return `must start with "${SECRET_PREFIX}"`;
|
|
8384
|
-
const key = Buffer.from(secret.slice(SECRET_PREFIX.length), "base64");
|
|
8385
|
-
if (key.length < 24 || key.length > 64) return "must contain 24 to 64 bytes in base64";
|
|
8386
|
-
return null;
|
|
8387
|
-
}
|
|
8388
|
-
function hmac2(secret, content) {
|
|
8389
|
-
const key = Buffer.from(secret.slice(SECRET_PREFIX.length), "base64");
|
|
8390
|
-
return createHmac2("sha256", key).update(content).digest("base64");
|
|
8391
|
-
}
|
|
8392
|
-
function signWebhook(secret, id, timestamp, body) {
|
|
8393
|
-
return `v1,${hmac2(secret, `${id}.${timestamp}.${body}`)}`;
|
|
8624
|
+
function isOn(method, available, methods) {
|
|
8625
|
+
return available(method) && methods[method] !== false;
|
|
8394
8626
|
}
|
|
8395
|
-
function
|
|
8396
|
-
|
|
8397
|
-
return {
|
|
8398
|
-
wants,
|
|
8399
|
-
async call(type, data, timeoutMs) {
|
|
8400
|
-
if (!wants(type)) return { ok: true, body: void 0 };
|
|
8401
|
-
const id = `msg_${randomUUID()}`;
|
|
8402
|
-
const timestamp = Math.floor(Date.now() / 1e3);
|
|
8403
|
-
const body = JSON.stringify({ type, data });
|
|
8404
|
-
let response;
|
|
8405
|
-
try {
|
|
8406
|
-
response = await fetchImpl(settings.url, {
|
|
8407
|
-
method: "POST",
|
|
8408
|
-
headers: {
|
|
8409
|
-
"Content-Type": "application/json",
|
|
8410
|
-
"webhook-id": id,
|
|
8411
|
-
"webhook-timestamp": String(timestamp),
|
|
8412
|
-
"webhook-signature": signWebhook(settings.secret, id, timestamp, body)
|
|
8413
|
-
},
|
|
8414
|
-
body,
|
|
8415
|
-
signal: AbortSignal.timeout(timeoutMs)
|
|
8416
|
-
});
|
|
8417
|
-
} catch (e) {
|
|
8418
|
-
const reason = e.name === "TimeoutError" ? `no answer within ${timeoutMs} ms` : e.message;
|
|
8419
|
-
return { ok: false, reason };
|
|
8420
|
-
}
|
|
8421
|
-
const text = await response.text().catch(() => "");
|
|
8422
|
-
if (!response.ok) return { ok: false, reason: `HTTP ${response.status}` };
|
|
8423
|
-
let parsed;
|
|
8424
|
-
try {
|
|
8425
|
-
parsed = text ? JSON.parse(text) : void 0;
|
|
8426
|
-
} catch {
|
|
8427
|
-
parsed = void 0;
|
|
8428
|
-
}
|
|
8429
|
-
return { ok: true, body: parsed };
|
|
8430
|
-
}
|
|
8431
|
-
};
|
|
8627
|
+
function anyOn(available, methods) {
|
|
8628
|
+
return SIGN_IN_METHODS.some((method) => isOn(method, available, methods));
|
|
8432
8629
|
}
|
|
8433
8630
|
|
|
8434
8631
|
// src/app.ts
|
|
@@ -8438,12 +8635,9 @@ function createApp(config) {
|
|
|
8438
8635
|
const keys = importSigningKeys(config.signingKey);
|
|
8439
8636
|
const secure = issuer.startsWith("https:");
|
|
8440
8637
|
const isAllowedOrigin = (origin) => !!origin && allowedOrigins.includes(origin);
|
|
8441
|
-
const
|
|
8442
|
-
const
|
|
8443
|
-
|
|
8444
|
-
const current = roles && user.email ? await roles.get(normalizeEmail(user.email)) : [];
|
|
8445
|
-
return current.length ? { ...rest, roles: current } : rest;
|
|
8446
|
-
};
|
|
8638
|
+
const users = new Users(config.store, deriveCodeKey(config.signingKey.d));
|
|
8639
|
+
const settings = new Settings(config.store);
|
|
8640
|
+
const available = (method) => method === "google" ? !!config.google : !!config.password;
|
|
8447
8641
|
const issueSession = async (c, user, amr, extra) => {
|
|
8448
8642
|
const signingKeys = await keys;
|
|
8449
8643
|
const [sessionToken, renewalToken] = await Promise.all([
|
|
@@ -8495,12 +8689,29 @@ function createApp(config) {
|
|
|
8495
8689
|
return url;
|
|
8496
8690
|
},
|
|
8497
8691
|
async startSession(c, user, amr, extra) {
|
|
8498
|
-
|
|
8499
|
-
|
|
8500
|
-
return signedIn;
|
|
8692
|
+
await issueSession(c, user, amr, extra);
|
|
8693
|
+
return user;
|
|
8501
8694
|
},
|
|
8502
|
-
users
|
|
8695
|
+
users,
|
|
8696
|
+
settings,
|
|
8503
8697
|
webhook: config.webhook ? createWebhookClient(config.webhook, config.webhookFetch) : void 0,
|
|
8698
|
+
async enabled(method) {
|
|
8699
|
+
return isOn(method, available, await settings.methods());
|
|
8700
|
+
},
|
|
8701
|
+
async checkSignup(data) {
|
|
8702
|
+
if (!this.webhook?.wants("signup.before")) return { ok: true };
|
|
8703
|
+
const check2 = await this.webhook.call("signup.before", data, WEBHOOK_TIMEOUT_MS);
|
|
8704
|
+
if (!check2.ok) {
|
|
8705
|
+
console.error(`[madauth] Webhook "signup.before" failed: ${check2.reason}`);
|
|
8706
|
+
return { ok: false, reason: "unavailable" };
|
|
8707
|
+
}
|
|
8708
|
+
const answer = check2.body;
|
|
8709
|
+
if (answer?.allow === false) {
|
|
8710
|
+
const message2 = typeof answer.message === "string" && answer.message ? answer.message : "Sign-up is not possible with this e-mail address.";
|
|
8711
|
+
return { ok: false, reason: "rejected", message: message2 };
|
|
8712
|
+
}
|
|
8713
|
+
return { ok: true };
|
|
8714
|
+
},
|
|
8504
8715
|
async emit(type, data) {
|
|
8505
8716
|
if (!this.webhook?.wants(type)) return;
|
|
8506
8717
|
const result = await this.webhook.call(type, data, EVENT_TIMEOUT_MS);
|
|
@@ -8530,29 +8741,28 @@ function createApp(config) {
|
|
|
8530
8741
|
c.header("Cache-Control", "public, max-age=3600");
|
|
8531
8742
|
return c.json({ keys: [publicJwk] });
|
|
8532
8743
|
});
|
|
8533
|
-
app2.get(
|
|
8534
|
-
|
|
8535
|
-
(
|
|
8536
|
-
|
|
8537
|
-
|
|
8538
|
-
|
|
8539
|
-
|
|
8744
|
+
app2.get("/auth/config", async (c) => {
|
|
8745
|
+
const methods = await settings.methods();
|
|
8746
|
+
const on = (method) => isOn(method, available, methods);
|
|
8747
|
+
return c.json({
|
|
8748
|
+
google: on("google") ? { clientId: config.google.clientId, codeFlow: !!config.google.clientSecret } : null,
|
|
8749
|
+
password: on("password") ? { minLength: config.password.minLength } : null
|
|
8750
|
+
});
|
|
8751
|
+
});
|
|
8540
8752
|
if (config.google) googleRoutes(app2, ctx, config.google);
|
|
8541
|
-
if (config.password && ctx.
|
|
8753
|
+
if (config.password && ctx.webhook) passwordRoutes(app2, ctx, config.password, users, ctx.webhook);
|
|
8542
8754
|
const tokenClaims = async (c, cookie, typ) => readToken(await keys, issuer, typ, getCookie(c, cookie));
|
|
8543
|
-
const hasEnded = async (claims) => {
|
|
8544
|
-
if (!claims.sub.startsWith("usr_")) return false;
|
|
8545
|
-
const stored = await ctx.users?.findById(claims.sub);
|
|
8546
|
-
return !stored || stored.sessionVersion !== claims.sv;
|
|
8547
|
-
};
|
|
8548
8755
|
const checked = async (claims) => {
|
|
8549
|
-
const
|
|
8550
|
-
|
|
8756
|
+
const stored = await users.findById(claims.sub);
|
|
8757
|
+
if (!stored || stored.sessionVersion !== claims.sv) return null;
|
|
8758
|
+
const { claims: _, ...user } = userFromClaims(claims);
|
|
8759
|
+
const current = claimsFromJson(stored.claims);
|
|
8760
|
+
return Object.keys(current).length ? { ...user, claims: current } : user;
|
|
8551
8761
|
};
|
|
8552
8762
|
const currentSession = async (c) => {
|
|
8553
8763
|
const session = await tokenClaims(c, SESSION_COOKIE, SESSION_TYP);
|
|
8554
8764
|
const user = session ? await checked(session) : null;
|
|
8555
|
-
const upToDate = session && user &&
|
|
8765
|
+
const upToDate = session && user && sameClaims(user.claims, session.claims) && Date.now() / 1e3 - session.iat <= sessionTtlSeconds / 2;
|
|
8556
8766
|
if (upToDate) return user;
|
|
8557
8767
|
const renewal = await tokenClaims(c, RENEWAL_COOKIE, RENEWAL_TYP);
|
|
8558
8768
|
const sameSignIn = session && user && renewal && renewal.sub === session.sub && renewal.sv === session.sv && renewal.email === session.email;
|
|
@@ -8578,56 +8788,82 @@ function createApp(config) {
|
|
|
8578
8788
|
app2.post("/auth/account/delete", async (c) => {
|
|
8579
8789
|
const user = await currentSession(c);
|
|
8580
8790
|
if (!user) return c.json({ error: "no_session" }, 401);
|
|
8581
|
-
|
|
8582
|
-
const passwordUserId = ctx.users && user.email ? await ctx.users.deleteByEmail(normalizeEmail(user.email)) : null;
|
|
8791
|
+
await users.deleteById(user.id);
|
|
8583
8792
|
clearSession(c);
|
|
8584
|
-
await ctx.emit("user.deleted", { user
|
|
8793
|
+
await ctx.emit("user.deleted", { user });
|
|
8585
8794
|
return c.body(null, 204);
|
|
8586
8795
|
});
|
|
8587
|
-
|
|
8588
|
-
const
|
|
8589
|
-
|
|
8590
|
-
|
|
8591
|
-
|
|
8592
|
-
|
|
8593
|
-
|
|
8594
|
-
|
|
8595
|
-
|
|
8596
|
-
|
|
8597
|
-
|
|
8598
|
-
|
|
8599
|
-
|
|
8600
|
-
|
|
8601
|
-
|
|
8602
|
-
|
|
8603
|
-
|
|
8604
|
-
|
|
8605
|
-
|
|
8606
|
-
|
|
8607
|
-
|
|
8608
|
-
|
|
8609
|
-
|
|
8610
|
-
|
|
8611
|
-
|
|
8612
|
-
|
|
8613
|
-
|
|
8614
|
-
|
|
8615
|
-
|
|
8616
|
-
|
|
8617
|
-
|
|
8618
|
-
|
|
8619
|
-
|
|
8620
|
-
|
|
8621
|
-
|
|
8622
|
-
|
|
8623
|
-
|
|
8624
|
-
|
|
8625
|
-
|
|
8626
|
-
|
|
8627
|
-
|
|
8628
|
-
|
|
8629
|
-
});
|
|
8630
|
-
|
|
8796
|
+
const admin = async (c) => {
|
|
8797
|
+
const user = await currentSession(c);
|
|
8798
|
+
if (!user) return { answer: c.json({ error: "no_session" }, 401) };
|
|
8799
|
+
const email = user.email ? normalizeEmail(user.email) : void 0;
|
|
8800
|
+
if (!email || !isAdmin(user)) {
|
|
8801
|
+
const message2 = `Only users with the role "${ADMIN_ROLE}" can manage claims and settings.`;
|
|
8802
|
+
return { answer: c.json({ error: "forbidden", message: message2 }, 403) };
|
|
8803
|
+
}
|
|
8804
|
+
return { email };
|
|
8805
|
+
};
|
|
8806
|
+
const body = async (c) => {
|
|
8807
|
+
const data = await c.req.json().catch(() => null);
|
|
8808
|
+
return data && typeof data === "object" ? data : {};
|
|
8809
|
+
};
|
|
8810
|
+
const invalidEmail = (c) => c.json({ error: "invalid_email", message: "This is not a valid e-mail address." }, 400);
|
|
8811
|
+
const noUser = (c) => c.json({ error: "user_not_found", message: "No user has this e-mail address." }, 404);
|
|
8812
|
+
app2.post("/auth/admin/claims/get", async (c) => {
|
|
8813
|
+
const caller = await admin(c);
|
|
8814
|
+
if ("answer" in caller) return caller.answer;
|
|
8815
|
+
const data = await body(c);
|
|
8816
|
+
if (!isValidEmail(data.email)) return invalidEmail(c);
|
|
8817
|
+
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
8818
|
+
if (!user) return noUser(c);
|
|
8819
|
+
return c.json({ email: user.emailNormalized, userId: user.id, claims: claimsFromJson(user.claims) });
|
|
8820
|
+
});
|
|
8821
|
+
app2.post("/auth/admin/claims/set", async (c) => {
|
|
8822
|
+
const caller = await admin(c);
|
|
8823
|
+
if ("answer" in caller) return caller.answer;
|
|
8824
|
+
const data = await body(c);
|
|
8825
|
+
if (!isValidEmail(data.email)) return invalidEmail(c);
|
|
8826
|
+
const claims = parseClaims(data.claims);
|
|
8827
|
+
if (!claims) {
|
|
8828
|
+
return c.json(
|
|
8829
|
+
{
|
|
8830
|
+
error: "invalid_claims",
|
|
8831
|
+
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 "_".'
|
|
8832
|
+
},
|
|
8833
|
+
400
|
|
8834
|
+
);
|
|
8835
|
+
}
|
|
8836
|
+
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
8837
|
+
if (!user) return noUser(c);
|
|
8838
|
+
await users.updateUser(user.id, { claims: Object.keys(claims).length ? JSON.stringify(claims) : null });
|
|
8839
|
+
const email = user.emailNormalized;
|
|
8840
|
+
await ctx.emit("user.claims_changed", { userId: user.id, email, claims, by: caller.email });
|
|
8841
|
+
return c.json({ email, userId: user.id, claims });
|
|
8842
|
+
});
|
|
8843
|
+
const settingsAnswer = (methods) => ({
|
|
8844
|
+
methods: Object.fromEntries(
|
|
8845
|
+
SIGN_IN_METHODS.map((method) => [method, { available: available(method), enabled: isOn(method, available, methods) }])
|
|
8846
|
+
)
|
|
8847
|
+
});
|
|
8848
|
+
app2.post("/auth/admin/settings/get", async (c) => {
|
|
8849
|
+
const caller = await admin(c);
|
|
8850
|
+
if ("answer" in caller) return caller.answer;
|
|
8851
|
+
return c.json(settingsAnswer(await settings.methods()));
|
|
8852
|
+
});
|
|
8853
|
+
app2.post("/auth/admin/settings/set", async (c) => {
|
|
8854
|
+
const caller = await admin(c);
|
|
8855
|
+
if ("answer" in caller) return caller.answer;
|
|
8856
|
+
const data = await body(c);
|
|
8857
|
+
const invalid2 = (message2) => c.json({ error: "invalid_settings", message: message2 }, 400);
|
|
8858
|
+
const changes = parseMethodSettings(data.methods);
|
|
8859
|
+
if (!changes) return invalid2(`methods must be an object with true or false for ${SIGN_IN_METHODS.join(" and/or ")}.`);
|
|
8860
|
+
const methods = await settings.changeMethods((current) => {
|
|
8861
|
+
const next = { ...current, ...changes };
|
|
8862
|
+
return anyOn(available, next) ? next : null;
|
|
8863
|
+
}, caller.email);
|
|
8864
|
+
if (!methods) return invalid2("At least one sign-in method must stay on.");
|
|
8865
|
+
return c.json(settingsAnswer(methods));
|
|
8866
|
+
});
|
|
8631
8867
|
return app2;
|
|
8632
8868
|
}
|
|
8633
8869
|
|
|
@@ -8692,19 +8928,18 @@ MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
|
|
|
8692
8928
|
if (clientId && !clientId.endsWith(".apps.googleusercontent.com")) {
|
|
8693
8929
|
throw new ConfigError(`GOOGLE_CLIENT_ID must end with ".apps.googleusercontent.com" but is "${clientId}".`);
|
|
8694
8930
|
}
|
|
8695
|
-
const
|
|
8696
|
-
const
|
|
8697
|
-
|
|
8931
|
+
const webhook = webhookFromEnv(read("WEBHOOK_URL"), read("WEBHOOK_SECRET"), read("WEBHOOK_EVENTS"));
|
|
8932
|
+
const sendsEmails = !!webhook && REQUIRED_EMAIL_TYPES.every((type) => webhook.events.has(type));
|
|
8933
|
+
const password = sendsEmails ? { minLength: passwordMinLength(read("PASSWORD_MIN_LENGTH")) } : void 0;
|
|
8934
|
+
if (!clientId && !password) {
|
|
8698
8935
|
throw new ConfigError(
|
|
8699
|
-
`
|
|
8936
|
+
`No sign-in method is configured. Set GOOGLE_CLIENT_ID for Google sign-in, and/or a webhook that sends the e-mails for e-mail & password sign-in: for development, run the receiver at ${DEV_WEBHOOK_RECEIVER_URL} and set WEBHOOK_URL=http://localhost:8790/webhook and WEBHOOK_EVENTS=${REQUIRED_EMAIL_TYPES.join(",")},email.already_registered. See docs/server.md.`
|
|
8700
8937
|
);
|
|
8701
8938
|
}
|
|
8702
8939
|
const store = overrides.store ?? await storeFromDatabaseUrl(read("DATABASE_URL"));
|
|
8703
|
-
|
|
8704
|
-
if (store) password = { minLength: passwordMinLength(read("PASSWORD_MIN_LENGTH")), store };
|
|
8705
|
-
if (!clientId && !password) {
|
|
8940
|
+
if (!store) {
|
|
8706
8941
|
throw new ConfigError(
|
|
8707
|
-
"
|
|
8942
|
+
"DATABASE_URL is not set. madAuth stores every user, whichever way they sign in: set e.g. DATABASE_URL=sqlite:./madauth.db or DATABASE_URL=dynamodb:<table>. See docs/server.md."
|
|
8708
8943
|
);
|
|
8709
8944
|
}
|
|
8710
8945
|
const ttlValue = read("SESSION_TTL");
|
|
@@ -8727,6 +8962,7 @@ MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
|
|
|
8727
8962
|
renewalTtlSeconds,
|
|
8728
8963
|
cookieDomain: read("COOKIE_DOMAIN"),
|
|
8729
8964
|
google: clientId ? { clientId, clientSecret: read("GOOGLE_CLIENT_SECRET") } : void 0,
|
|
8965
|
+
store,
|
|
8730
8966
|
password,
|
|
8731
8967
|
webhook
|
|
8732
8968
|
};
|
|
@@ -8772,8 +9008,9 @@ async function dynamoDbStore(tableName2) {
|
|
|
8772
9008
|
const { createDynamoDbAdapter: createDynamoDbAdapter2 } = await Promise.resolve().then(() => (init_dynamodb(), dynamodb_exports));
|
|
8773
9009
|
return createDynamoDbAdapter2({ tableName: tableName2 });
|
|
8774
9010
|
}
|
|
9011
|
+
var RENAMED_TYPES = { "password.reset": "email.password_reset", "roles.changed": "user.claims_changed" };
|
|
8775
9012
|
var LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]", "host.docker.internal"];
|
|
8776
|
-
function webhookFromEnv(url, secret, events
|
|
9013
|
+
function webhookFromEnv(url, secret, events) {
|
|
8777
9014
|
if (!url) {
|
|
8778
9015
|
if (secret || events) throw new ConfigError("WEBHOOK_SECRET and WEBHOOK_EVENTS need WEBHOOK_URL.");
|
|
8779
9016
|
return void 0;
|
|
@@ -8803,24 +9040,22 @@ WEBHOOK_SECRET=${generateWebhookSecret()}
|
|
|
8803
9040
|
const allTypes = `All types: ${WEBHOOK_TYPES.join(", ")}. See "Webhooks" in docs/server.md.`;
|
|
8804
9041
|
if (!selected.size) {
|
|
8805
9042
|
throw new ConfigError(
|
|
8806
|
-
|
|
9043
|
+
`WEBHOOK_EVENTS is not set. List the types your webhook receiver handles; only these are sent. E-mail & password sign-in is on when they include ${REQUIRED_EMAIL_TYPES.join(" and ")}. For a receiver that sends the e-mails:
|
|
8807
9044
|
|
|
8808
9045
|
WEBHOOK_EVENTS=email.verify,email.reset,email.already_registered
|
|
8809
9046
|
|
|
8810
|
-
|
|
9047
|
+
For one that only wants to know who signed in:
|
|
9048
|
+
|
|
9049
|
+
WEBHOOK_EVENTS=user.signed_in
|
|
9050
|
+
|
|
9051
|
+
` + allTypes
|
|
8811
9052
|
);
|
|
8812
9053
|
}
|
|
8813
9054
|
const unknown = [...selected].filter((e) => !WEBHOOK_TYPES.includes(e));
|
|
8814
9055
|
if (unknown.length) {
|
|
8815
|
-
|
|
8816
|
-
}
|
|
8817
|
-
const missing = sendsEmails ? REQUIRED_EMAIL_TYPES.filter((type) => !selected.has(type)) : [];
|
|
8818
|
-
if (missing.length) {
|
|
9056
|
+
const renamed = unknown.filter((e) => e in RENAMED_TYPES).map((e) => `${e} is now ${RENAMED_TYPES[e]}`);
|
|
8819
9057
|
throw new ConfigError(
|
|
8820
|
-
`WEBHOOK_EVENTS
|
|
8821
|
-
|
|
8822
|
-
WEBHOOK_EVENTS=${[...selected, ...missing].join(",")}
|
|
8823
|
-
`
|
|
9058
|
+
`WEBHOOK_EVENTS: unknown type ${unknown.join(", ")}. ${renamed.length ? `${renamed.join("; ")}. ` : ""}Known types: ${WEBHOOK_TYPES.join(", ")}.`
|
|
8824
9059
|
);
|
|
8825
9060
|
}
|
|
8826
9061
|
return { url: parsed.href, secret, events: selected };
|