@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
|
@@ -15,8 +15,9 @@ var init_schema = __esm({
|
|
|
15
15
|
"src/store/schema.ts"() {
|
|
16
16
|
"use strict";
|
|
17
17
|
madauthSchema = {
|
|
18
|
-
version:
|
|
18
|
+
version: 4,
|
|
19
19
|
models: {
|
|
20
|
+
/** Someone who signed in, whichever way: the e-mail address is the identity shared by the sign-in methods. */
|
|
20
21
|
user: {
|
|
21
22
|
fields: {
|
|
22
23
|
id: { type: "string", primaryKey: true },
|
|
@@ -28,10 +29,12 @@ var init_schema = __esm({
|
|
|
28
29
|
lastMailAt: { type: "number" },
|
|
29
30
|
/** Wrong e-mail codes in a row, counted across e-mails (since version 2). */
|
|
30
31
|
wrongCodes: { type: "number" },
|
|
32
|
+
/** What admins attached to the user, as JSON, e.g. `{"roles":["admin"]}` (since version 4). */
|
|
33
|
+
claims: { type: "string", nullable: true },
|
|
31
34
|
createdAt: { type: "number" }
|
|
32
35
|
}
|
|
33
36
|
},
|
|
34
|
-
/** How a user signs in. `key` is
|
|
37
|
+
/** How a user signs in. `key` is `password:<userId>` or `google:<sub>`; later e.g. `totp:<userId>`. */
|
|
35
38
|
account: {
|
|
36
39
|
fields: {
|
|
37
40
|
id: { type: "string", primaryKey: true },
|
|
@@ -40,6 +43,8 @@ var init_schema = __esm({
|
|
|
40
43
|
secret: { type: "string", nullable: true },
|
|
41
44
|
failedAttempts: { type: "number" },
|
|
42
45
|
lockedUntil: { type: "number" },
|
|
46
|
+
/** The address the provider reported at the last sign-in, e.g. Google's current primary address (since version 4). */
|
|
47
|
+
email: { type: "string", nullable: true },
|
|
43
48
|
createdAt: { type: "number" }
|
|
44
49
|
}
|
|
45
50
|
},
|
|
@@ -54,16 +59,13 @@ var init_schema = __esm({
|
|
|
54
59
|
expiresAt: { type: "number" }
|
|
55
60
|
}
|
|
56
61
|
},
|
|
57
|
-
/**
|
|
58
|
-
|
|
59
|
-
* apply whichever way its owner signs in. `roles` holds the names separated by spaces.
|
|
60
|
-
*/
|
|
61
|
-
role: {
|
|
62
|
+
/** A setting admins change at runtime, as JSON (since version 4), e.g. `methods`: which sign-in methods are on. */
|
|
63
|
+
setting: {
|
|
62
64
|
fields: {
|
|
63
65
|
id: { type: "string", primaryKey: true },
|
|
64
|
-
|
|
66
|
+
value: { type: "string" },
|
|
65
67
|
updatedAt: { type: "number" },
|
|
66
|
-
/** The address of the admin who set
|
|
68
|
+
/** The address of the admin who set it; empty when set from the command line. */
|
|
67
69
|
updatedBy: { type: "string", nullable: true }
|
|
68
70
|
}
|
|
69
71
|
}
|
|
@@ -118,8 +120,30 @@ var init_sql = __esm({
|
|
|
118
120
|
sql: (dialect) => `ALTER TABLE ${tableName("user")} ADD COLUMN ${columnName("wrongCodes")} ${columnTypes[dialect].number} NOT NULL DEFAULT 0;`
|
|
119
121
|
},
|
|
120
122
|
{
|
|
123
|
+
// Roles were per e-mail address in their own table; since version 4 they are one of the user's claims.
|
|
121
124
|
version: 3,
|
|
122
|
-
sql: (dialect) => createTablesSql(dialect, {
|
|
125
|
+
sql: (dialect) => createTablesSql(dialect, {
|
|
126
|
+
version: 3,
|
|
127
|
+
models: {
|
|
128
|
+
role: {
|
|
129
|
+
fields: {
|
|
130
|
+
id: { type: "string", primaryKey: true },
|
|
131
|
+
roles: { type: "string" },
|
|
132
|
+
updatedAt: { type: "number" },
|
|
133
|
+
updatedBy: { type: "string", nullable: true }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
version: 4,
|
|
141
|
+
sql: (dialect) => [
|
|
142
|
+
`ALTER TABLE ${tableName("user")} ADD COLUMN ${columnName("claims")} ${columnTypes[dialect].string};`,
|
|
143
|
+
`ALTER TABLE ${tableName("account")} ADD COLUMN ${columnName("email")} ${columnTypes[dialect].string};`,
|
|
144
|
+
`DROP TABLE ${tableName("role")};`,
|
|
145
|
+
createTablesSql(dialect, { version: 4, models: { setting: madauthSchema.models.setting } })
|
|
146
|
+
].join("\n")
|
|
123
147
|
}
|
|
124
148
|
];
|
|
125
149
|
}
|
|
@@ -2683,6 +2707,63 @@ var cors = (options) => {
|
|
|
2683
2707
|
};
|
|
2684
2708
|
};
|
|
2685
2709
|
|
|
2710
|
+
// src/claims.ts
|
|
2711
|
+
var ADMIN_ROLE = "admin";
|
|
2712
|
+
var MAX_ROLES = 20;
|
|
2713
|
+
var MAX_CLAIMS_LENGTH = 2048;
|
|
2714
|
+
var ROLE_NAME = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
2715
|
+
var CLAIM_NAME = /^[a-zA-Z][a-zA-Z0-9_]{0,63}$/;
|
|
2716
|
+
function parseRoles(input) {
|
|
2717
|
+
if (!Array.isArray(input)) return null;
|
|
2718
|
+
if (!input.every((role) => typeof role === "string" && ROLE_NAME.test(role))) return null;
|
|
2719
|
+
const roles = [...new Set(input)].sort();
|
|
2720
|
+
return roles.length <= MAX_ROLES ? roles : null;
|
|
2721
|
+
}
|
|
2722
|
+
function parseClaims(input) {
|
|
2723
|
+
if (!isJsonObject(input)) return null;
|
|
2724
|
+
const claims = {};
|
|
2725
|
+
for (const key of Object.keys(input).sort()) {
|
|
2726
|
+
const value = input[key];
|
|
2727
|
+
if (!CLAIM_NAME.test(key) || !isJsonValue(value)) return null;
|
|
2728
|
+
if (key === "roles") {
|
|
2729
|
+
const roles = parseRoles(value);
|
|
2730
|
+
if (!roles) return null;
|
|
2731
|
+
claims[key] = roles;
|
|
2732
|
+
} else {
|
|
2733
|
+
claims[key] = value;
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
return JSON.stringify(claims).length <= MAX_CLAIMS_LENGTH ? claims : null;
|
|
2737
|
+
}
|
|
2738
|
+
function isJsonObject(value) {
|
|
2739
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
2740
|
+
}
|
|
2741
|
+
function isJsonValue(value) {
|
|
2742
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
|
|
2743
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
2744
|
+
if (Array.isArray(value)) return value.every(isJsonValue);
|
|
2745
|
+
return isJsonObject(value) && Object.values(value).every(isJsonValue);
|
|
2746
|
+
}
|
|
2747
|
+
function sameClaims(a, b) {
|
|
2748
|
+
return JSON.stringify(a ?? {}) === JSON.stringify(b ?? {});
|
|
2749
|
+
}
|
|
2750
|
+
function claimsFromJson(json) {
|
|
2751
|
+
if (!json) return {};
|
|
2752
|
+
try {
|
|
2753
|
+
const parsed = JSON.parse(json);
|
|
2754
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
2755
|
+
} catch {
|
|
2756
|
+
return {};
|
|
2757
|
+
}
|
|
2758
|
+
}
|
|
2759
|
+
function rolesOf(user) {
|
|
2760
|
+
const roles = user.claims?.roles;
|
|
2761
|
+
return Array.isArray(roles) ? roles.filter((role) => typeof role === "string") : [];
|
|
2762
|
+
}
|
|
2763
|
+
function isAdmin(user) {
|
|
2764
|
+
return rolesOf(user).includes(ADMIN_ROLE);
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2686
2767
|
// ../../node_modules/jose/dist/webapi/lib/buffer_utils.js
|
|
2687
2768
|
var encoder = new TextEncoder();
|
|
2688
2769
|
var decoder = new TextDecoder();
|
|
@@ -3889,45 +3970,6 @@ function safeEqual(a, b) {
|
|
|
3889
3970
|
return x.length === y.length && timingSafeEqual(x, y);
|
|
3890
3971
|
}
|
|
3891
3972
|
|
|
3892
|
-
// src/roles.ts
|
|
3893
|
-
var ADMIN_ROLE = "admin";
|
|
3894
|
-
var MAX_ROLES = 20;
|
|
3895
|
-
var ROLE_NAME = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
3896
|
-
function parseRoles(input) {
|
|
3897
|
-
if (!Array.isArray(input)) return null;
|
|
3898
|
-
if (!input.every((role) => typeof role === "string" && ROLE_NAME.test(role))) return null;
|
|
3899
|
-
const roles = [...new Set(input)].sort();
|
|
3900
|
-
return roles.length <= MAX_ROLES ? roles : null;
|
|
3901
|
-
}
|
|
3902
|
-
function sameRoles(a, b) {
|
|
3903
|
-
return a.length === b.length && a.every((role, i) => role === b[i]);
|
|
3904
|
-
}
|
|
3905
|
-
var Roles = class {
|
|
3906
|
-
constructor(store) {
|
|
3907
|
-
this.store = store;
|
|
3908
|
-
}
|
|
3909
|
-
/** The address's roles, sorted; empty if it has none. */
|
|
3910
|
-
async get(emailNormalized) {
|
|
3911
|
-
const record = await this.store.findOne("role", { id: emailNormalized });
|
|
3912
|
-
return typeof record?.roles === "string" && record.roles ? record.roles.split(" ").filter(Boolean) : [];
|
|
3913
|
-
}
|
|
3914
|
-
/** Replaces the address's roles. `updatedBy` is the address of the admin, or null for the command line. */
|
|
3915
|
-
async set(emailNormalized, roles, updatedBy) {
|
|
3916
|
-
if (!roles.length) {
|
|
3917
|
-
await this.remove(emailNormalized);
|
|
3918
|
-
return;
|
|
3919
|
-
}
|
|
3920
|
-
const data = { roles: roles.join(" "), updatedAt: Date.now(), updatedBy };
|
|
3921
|
-
if (await this.store.update("role", { id: emailNormalized }, data) === 1) return;
|
|
3922
|
-
if (!await this.store.create("role", { id: emailNormalized, ...data })) {
|
|
3923
|
-
await this.store.update("role", { id: emailNormalized }, data);
|
|
3924
|
-
}
|
|
3925
|
-
}
|
|
3926
|
-
async remove(emailNormalized) {
|
|
3927
|
-
await this.store.delete("role", { id: emailNormalized });
|
|
3928
|
-
}
|
|
3929
|
-
};
|
|
3930
|
-
|
|
3931
3973
|
// src/google.ts
|
|
3932
3974
|
var GOOGLE_ISSUERS = ["https://accounts.google.com", "accounts.google.com"];
|
|
3933
3975
|
var googleJwks;
|
|
@@ -3952,14 +3994,13 @@ async function verifyGoogleIdToken(idToken, opts) {
|
|
|
3952
3994
|
if (payload.nonce !== opts.nonce) {
|
|
3953
3995
|
return { ok: false, error: "verification_failed", reason: "nonce mismatch" };
|
|
3954
3996
|
}
|
|
3955
|
-
if (payload.email_verified !== true) {
|
|
3997
|
+
if (typeof payload.email !== "string" || !payload.email || payload.email_verified !== true) {
|
|
3956
3998
|
return { ok: false, error: "email_unverified", reason: "e-mail address not verified" };
|
|
3957
3999
|
}
|
|
3958
|
-
const
|
|
3959
|
-
if (typeof payload.
|
|
3960
|
-
if (typeof payload.
|
|
3961
|
-
|
|
3962
|
-
return { ok: true, user };
|
|
4000
|
+
const profile = { sub: payload.sub, email: payload.email };
|
|
4001
|
+
if (typeof payload.name === "string") profile.name = payload.name;
|
|
4002
|
+
if (typeof payload.picture === "string") profile.picture = payload.picture;
|
|
4003
|
+
return { ok: true, profile };
|
|
3963
4004
|
}
|
|
3964
4005
|
|
|
3965
4006
|
// src/user.ts
|
|
@@ -3993,7 +4034,7 @@ function signSession(keys, issuer, ttlSeconds, user, amr, extra = {}) {
|
|
|
3993
4034
|
email_verified: user.email ? true : void 0,
|
|
3994
4035
|
name: user.name,
|
|
3995
4036
|
picture: user.picture,
|
|
3996
|
-
|
|
4037
|
+
claims: user.claims && Object.keys(user.claims).length ? user.claims : void 0,
|
|
3997
4038
|
amr
|
|
3998
4039
|
};
|
|
3999
4040
|
return signToken(keys, issuer, SESSION_TYP, claims, ttlSeconds);
|
|
@@ -4015,194 +4056,518 @@ function userFromClaims(claims) {
|
|
|
4015
4056
|
if (claims.email) user.email = claims.email;
|
|
4016
4057
|
if (claims.name) user.name = claims.name;
|
|
4017
4058
|
if (claims.picture) user.picture = claims.picture;
|
|
4018
|
-
if (
|
|
4059
|
+
if (claims.claims && typeof claims.claims === "object" && Object.keys(claims.claims).length) user.claims = claims.claims;
|
|
4019
4060
|
return user;
|
|
4020
4061
|
}
|
|
4021
4062
|
|
|
4022
|
-
// src/
|
|
4023
|
-
|
|
4024
|
-
var
|
|
4025
|
-
var
|
|
4026
|
-
var
|
|
4027
|
-
|
|
4028
|
-
|
|
4029
|
-
var GOOGLE_AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
4030
|
-
var GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
|
|
4031
|
-
var REDIRECT_ERROR_PARAM = "madauth_error";
|
|
4032
|
-
async function pkceChallenge(verifier) {
|
|
4033
|
-
const digest2 = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
4034
|
-
return base64url_exports.encode(new Uint8Array(digest2));
|
|
4063
|
+
// src/users.ts
|
|
4064
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
4065
|
+
var MAX_CODE_ATTEMPTS = 5;
|
|
4066
|
+
var MAX_WRONG_CODES = 10;
|
|
4067
|
+
var newId = (prefix) => `${prefix}_${randomBytes2(16).toString("base64url")}`;
|
|
4068
|
+
function passwordAccountKey(userId) {
|
|
4069
|
+
return `password:${userId}`;
|
|
4035
4070
|
}
|
|
4036
|
-
function
|
|
4037
|
-
|
|
4038
|
-
const { issuer } = config;
|
|
4039
|
-
const callbackUrl = `${issuer}/auth/google/callback`;
|
|
4040
|
-
app.post("/auth/google/nonce", async (c) => {
|
|
4041
|
-
const nonce = randomString();
|
|
4042
|
-
const token = await signToken(await keys, issuer, NONCE_TYP, { nonce }, NONCE_TTL);
|
|
4043
|
-
setCookie(c, NONCE_COOKIE, token, {
|
|
4044
|
-
path: "/auth/google",
|
|
4045
|
-
httpOnly: true,
|
|
4046
|
-
secure,
|
|
4047
|
-
sameSite: "Strict",
|
|
4048
|
-
maxAge: NONCE_TTL
|
|
4049
|
-
});
|
|
4050
|
-
return c.json({ nonce });
|
|
4051
|
-
});
|
|
4052
|
-
app.post("/auth/google/verify", async (c) => {
|
|
4053
|
-
const body = await c.req.json().catch(() => ({}));
|
|
4054
|
-
if (typeof body.credential !== "string" || !body.credential) {
|
|
4055
|
-
return c.json({ error: "verification_failed", message: "credential is missing" }, 400);
|
|
4056
|
-
}
|
|
4057
|
-
const nonceClaims = await readToken(await keys, issuer, NONCE_TYP, getCookie(c, NONCE_COOKIE));
|
|
4058
|
-
if (!nonceClaims) {
|
|
4059
|
-
return c.json({ error: "verification_failed", message: "nonce cookie missing or expired" }, 401);
|
|
4060
|
-
}
|
|
4061
|
-
const result = await verifyGoogleIdToken(body.credential, {
|
|
4062
|
-
clientId: google.clientId,
|
|
4063
|
-
nonce: nonceClaims.nonce,
|
|
4064
|
-
keys: config.jwksResolver
|
|
4065
|
-
});
|
|
4066
|
-
if (!result.ok) {
|
|
4067
|
-
return c.json({ error: result.error, message: result.reason }, result.error === "email_unverified" ? 403 : 401);
|
|
4068
|
-
}
|
|
4069
|
-
deleteCookie(c, NONCE_COOKIE, { path: "/auth/google", secure });
|
|
4070
|
-
const user = await ctx.startSession(c, result.user, ["google"]);
|
|
4071
|
-
await ctx.emit("user.signed_in", { user, method: "google" });
|
|
4072
|
-
return c.json({ user });
|
|
4073
|
-
});
|
|
4074
|
-
const requireCodeFlow = async (c, next) => {
|
|
4075
|
-
if (!google.clientSecret) return c.json({ error: "not_found" }, 404);
|
|
4076
|
-
await next();
|
|
4077
|
-
};
|
|
4078
|
-
app.get("/auth/google/start", requireCodeFlow, async (c) => {
|
|
4079
|
-
const returnUrl = ctx.allowedUrl(c.req.query("return_to"));
|
|
4080
|
-
if (!returnUrl) return c.text("return_to must be an absolute URL whose origin is in ALLOWED_ORIGINS", 400);
|
|
4081
|
-
const oauth = {
|
|
4082
|
-
state: randomString(),
|
|
4083
|
-
nonce: randomString(),
|
|
4084
|
-
verifier: randomString(),
|
|
4085
|
-
returnTo: returnUrl.href
|
|
4086
|
-
};
|
|
4087
|
-
const token = await signToken(await keys, issuer, OAUTH_TYP, { ...oauth }, OAUTH_TTL);
|
|
4088
|
-
setCookie(c, OAUTH_COOKIE, token, {
|
|
4089
|
-
path: "/auth/google",
|
|
4090
|
-
httpOnly: true,
|
|
4091
|
-
secure,
|
|
4092
|
-
sameSite: "Lax",
|
|
4093
|
-
maxAge: OAUTH_TTL
|
|
4094
|
-
});
|
|
4095
|
-
const url = new URL(GOOGLE_AUTH_ENDPOINT);
|
|
4096
|
-
url.search = new URLSearchParams({
|
|
4097
|
-
client_id: google.clientId,
|
|
4098
|
-
redirect_uri: callbackUrl,
|
|
4099
|
-
response_type: "code",
|
|
4100
|
-
scope: "openid email profile",
|
|
4101
|
-
state: oauth.state,
|
|
4102
|
-
nonce: oauth.nonce,
|
|
4103
|
-
code_challenge: await pkceChallenge(oauth.verifier),
|
|
4104
|
-
code_challenge_method: "S256"
|
|
4105
|
-
}).toString();
|
|
4106
|
-
return c.redirect(url.href, 302);
|
|
4107
|
-
});
|
|
4108
|
-
app.get("/auth/google/callback", requireCodeFlow, async (c) => {
|
|
4109
|
-
const oauth = await readToken(await keys, issuer, OAUTH_TYP, getCookie(c, OAUTH_COOKIE));
|
|
4110
|
-
if (!oauth) return c.text("Sign-in expired or was started in another browser. Please try again.", 400);
|
|
4111
|
-
deleteCookie(c, OAUTH_COOKIE, { path: "/auth/google", secure });
|
|
4112
|
-
const back = (error) => {
|
|
4113
|
-
const url = new URL(oauth.returnTo);
|
|
4114
|
-
if (error) url.hash = `${REDIRECT_ERROR_PARAM}=${error}`;
|
|
4115
|
-
return c.redirect(url.href, 302);
|
|
4116
|
-
};
|
|
4117
|
-
if (c.req.query("error")) return back(c.req.query("error") === "access_denied" ? "cancelled" : "verification_failed");
|
|
4118
|
-
const code = c.req.query("code");
|
|
4119
|
-
if (!code || c.req.query("state") !== oauth.state) return back("verification_failed");
|
|
4120
|
-
let idToken;
|
|
4121
|
-
try {
|
|
4122
|
-
const res = await fetch(GOOGLE_TOKEN_ENDPOINT, {
|
|
4123
|
-
method: "POST",
|
|
4124
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
4125
|
-
body: new URLSearchParams({
|
|
4126
|
-
code,
|
|
4127
|
-
client_id: google.clientId,
|
|
4128
|
-
client_secret: google.clientSecret,
|
|
4129
|
-
redirect_uri: callbackUrl,
|
|
4130
|
-
grant_type: "authorization_code",
|
|
4131
|
-
code_verifier: oauth.verifier
|
|
4132
|
-
})
|
|
4133
|
-
});
|
|
4134
|
-
if (!res.ok) return back("verification_failed");
|
|
4135
|
-
({ id_token: idToken } = await res.json());
|
|
4136
|
-
} catch {
|
|
4137
|
-
return back("verification_failed");
|
|
4138
|
-
}
|
|
4139
|
-
if (typeof idToken !== "string") return back("verification_failed");
|
|
4140
|
-
const result = await verifyGoogleIdToken(idToken, {
|
|
4141
|
-
clientId: google.clientId,
|
|
4142
|
-
nonce: oauth.nonce,
|
|
4143
|
-
keys: config.jwksResolver
|
|
4144
|
-
});
|
|
4145
|
-
if (!result.ok) return back(result.error);
|
|
4146
|
-
const user = await ctx.startSession(c, result.user, ["google"]);
|
|
4147
|
-
await ctx.emit("user.signed_in", { user, method: "google" });
|
|
4148
|
-
return back();
|
|
4149
|
-
});
|
|
4071
|
+
function googleAccountKey(sub) {
|
|
4072
|
+
return `google:${sub}`;
|
|
4150
4073
|
}
|
|
4151
|
-
|
|
4152
|
-
// src/routes/password.ts
|
|
4153
|
-
var FREE_ATTEMPTS = 5;
|
|
4154
|
-
var MAX_LOCK_MS = 15 * 60 * 1e3;
|
|
4155
|
-
var MAIL_INTERVAL_MS = 60 * 1e3;
|
|
4156
|
-
var VERIFY_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
4157
|
-
var RESET_TTL_MS = 30 * 60 * 1e3;
|
|
4158
|
-
var WEBHOOK_TIMEOUT_MS = 1e4;
|
|
4159
|
-
var VERIFY_LINK_PARAM = "madauth_verify";
|
|
4160
|
-
var RESET_LINK_PARAM = "madauth_reset";
|
|
4161
|
-
var MAX_NAME_LENGTH = 100;
|
|
4162
|
-
var MAX_LOCALE_LENGTH = 35;
|
|
4163
|
-
function toMadauthUser(user) {
|
|
4074
|
+
function toMadauthUser(user, profile = {}) {
|
|
4164
4075
|
const result = { id: user.id, email: user.email };
|
|
4165
|
-
|
|
4076
|
+
const name = user.name ?? profile.name;
|
|
4077
|
+
if (name) result.name = name;
|
|
4078
|
+
if (profile.picture) result.picture = profile.picture;
|
|
4079
|
+
const claims = claimsFromJson(user.claims);
|
|
4080
|
+
if (Object.keys(claims).length) result.claims = claims;
|
|
4166
4081
|
return result;
|
|
4167
4082
|
}
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
}
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
return
|
|
4180
|
-
}
|
|
4181
|
-
|
|
4182
|
-
|
|
4183
|
-
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
4083
|
+
var Users = class {
|
|
4084
|
+
/** `codeKey` keys the e-mail codes (see deriveCodeKey); only needed to issue and check them. */
|
|
4085
|
+
constructor(store, codeKey) {
|
|
4086
|
+
this.store = store;
|
|
4087
|
+
this.codeKey = codeKey;
|
|
4088
|
+
}
|
|
4089
|
+
get key() {
|
|
4090
|
+
if (!this.codeKey) throw new Error("Users needs a code key to issue or check e-mail codes.");
|
|
4091
|
+
return this.codeKey;
|
|
4092
|
+
}
|
|
4093
|
+
async findByEmail(emailNormalized) {
|
|
4094
|
+
return await this.store.findOne("user", { emailNormalized });
|
|
4095
|
+
}
|
|
4096
|
+
async findById(id) {
|
|
4097
|
+
return await this.store.findOne("user", { id });
|
|
4098
|
+
}
|
|
4099
|
+
async passwordAccount(userId) {
|
|
4100
|
+
return this.findAccountByKey(passwordAccountKey(userId));
|
|
4101
|
+
}
|
|
4102
|
+
async findAccountByKey(key) {
|
|
4103
|
+
return await this.store.findOne("account", { key });
|
|
4104
|
+
}
|
|
4105
|
+
/**
|
|
4106
|
+
* Creates a user with their first account. Resolves to null if the e-mail address or the account key is
|
|
4107
|
+
* taken. `account.key` is `password:<userId>` for a password (its hash as `secret`), or e.g. `google:<sub>`.
|
|
4108
|
+
*/
|
|
4109
|
+
async createUser(data) {
|
|
4110
|
+
const user = {
|
|
4111
|
+
id: newId("usr"),
|
|
4112
|
+
email: data.email,
|
|
4113
|
+
emailNormalized: data.emailNormalized,
|
|
4114
|
+
emailVerified: data.emailVerified,
|
|
4115
|
+
name: data.name,
|
|
4116
|
+
sessionVersion: 0,
|
|
4117
|
+
lastMailAt: 0,
|
|
4118
|
+
wrongCodes: 0,
|
|
4119
|
+
claims: null,
|
|
4120
|
+
createdAt: Date.now()
|
|
4121
|
+
};
|
|
4122
|
+
if (!await this.store.create("user", user)) return null;
|
|
4123
|
+
const account = typeof data.account === "function" ? data.account(user.id) : data.account;
|
|
4124
|
+
if (!await this.linkAccount(user.id, account)) {
|
|
4125
|
+
await this.store.delete("user", { id: user.id });
|
|
4126
|
+
return null;
|
|
4187
4127
|
}
|
|
4188
|
-
|
|
4189
|
-
|
|
4190
|
-
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4128
|
+
return user;
|
|
4129
|
+
}
|
|
4130
|
+
/**
|
|
4131
|
+
* A provider verified the user's address: an unconfirmed password sign-up with it, whose password nobody
|
|
4132
|
+
* has proven, is dropped (the account and its pending links and codes), and the user counts as verified.
|
|
4133
|
+
*/
|
|
4134
|
+
async verifyByProvider(user) {
|
|
4135
|
+
if (user.emailVerified) return;
|
|
4136
|
+
await this.store.delete("account", { key: passwordAccountKey(user.id) });
|
|
4137
|
+
await this.clearVerifications(user.id);
|
|
4138
|
+
await this.updateUser(user.id, { emailVerified: true, wrongCodes: 0 });
|
|
4139
|
+
}
|
|
4140
|
+
/** Removes an account record, e.g. one left behind by a deletion that did not finish. */
|
|
4141
|
+
async deleteAccount(id) {
|
|
4142
|
+
await this.store.delete("account", { id });
|
|
4143
|
+
}
|
|
4144
|
+
/** Adds a sign-in method to a user. Resolves to null if the account key is taken. */
|
|
4145
|
+
async linkAccount(userId, account) {
|
|
4146
|
+
const record = {
|
|
4147
|
+
id: newId("acc"),
|
|
4148
|
+
userId,
|
|
4149
|
+
key: account.key,
|
|
4150
|
+
secret: account.secret ?? null,
|
|
4151
|
+
failedAttempts: 0,
|
|
4152
|
+
lockedUntil: 0,
|
|
4153
|
+
email: account.email ?? null,
|
|
4154
|
+
createdAt: Date.now()
|
|
4155
|
+
};
|
|
4156
|
+
return await this.store.create("account", record) ? record : null;
|
|
4157
|
+
}
|
|
4158
|
+
/**
|
|
4159
|
+
* Deletes the user and everything stored for them. Resolves to false if there is no such user (any more).
|
|
4160
|
+
*/
|
|
4161
|
+
async deleteById(id) {
|
|
4162
|
+
const user = await this.findById(id);
|
|
4163
|
+
if (!user) return false;
|
|
4164
|
+
if (await this.store.delete("user", { id: user.id }) !== 1) return false;
|
|
4165
|
+
try {
|
|
4166
|
+
await this.store.delete("account", { userId: user.id });
|
|
4167
|
+
await this.store.delete("verification", { userId: user.id });
|
|
4168
|
+
} catch (e) {
|
|
4169
|
+
console.error(`[madauth] Deleted user ${user.id}, but not all of their records: ${e.message}`);
|
|
4170
|
+
}
|
|
4171
|
+
return true;
|
|
4172
|
+
}
|
|
4173
|
+
async updateUser(id, patch) {
|
|
4174
|
+
await this.store.update("user", { id }, patch);
|
|
4175
|
+
}
|
|
4176
|
+
async updateAccount(id, patch) {
|
|
4177
|
+
await this.store.update("account", { id }, patch);
|
|
4178
|
+
}
|
|
4179
|
+
/**
|
|
4180
|
+
* Counts a sign-in attempt: writes `patch` only if the account's `failedAttempts` is still the value that
|
|
4181
|
+
* was read. Resolves to false if another request counted an attempt meanwhile.
|
|
4182
|
+
*/
|
|
4183
|
+
async countAttempt(account, patch) {
|
|
4184
|
+
const changed = await this.store.update("account", { id: account.id, failedAttempts: account.failedAttempts }, patch);
|
|
4185
|
+
return changed === 1;
|
|
4186
|
+
}
|
|
4187
|
+
/** Deletes the user's pending verifications and password resets; their links and codes stop working. */
|
|
4188
|
+
async clearVerifications(userId) {
|
|
4189
|
+
await this.store.delete("verification", { userId });
|
|
4190
|
+
}
|
|
4191
|
+
/**
|
|
4192
|
+
* Starts a verification or password reset: replaces the user's earlier one for the same purpose and
|
|
4193
|
+
* returns the link token and the code for the e-mail. Only their hashes are stored.
|
|
4194
|
+
*/
|
|
4195
|
+
async issueVerification(userId, purpose, ttlMs) {
|
|
4196
|
+
await this.store.delete("verification", { userId, purpose });
|
|
4197
|
+
const token = generateLinkToken();
|
|
4198
|
+
const code = generateCode();
|
|
4199
|
+
const record = {
|
|
4200
|
+
id: hashLinkToken(token),
|
|
4201
|
+
userId,
|
|
4202
|
+
purpose,
|
|
4203
|
+
codeHash: hashCode(this.key, code),
|
|
4204
|
+
attempts: 0,
|
|
4205
|
+
expiresAt: Date.now() + ttlMs
|
|
4206
|
+
};
|
|
4207
|
+
await this.store.create("verification", record);
|
|
4208
|
+
return { token, code };
|
|
4209
|
+
}
|
|
4210
|
+
/** Uses up the verification a link token belongs to. Resolves to its user ID, or null if invalid, expired or used. */
|
|
4211
|
+
async consumeLinkToken(token, purpose) {
|
|
4212
|
+
const id = hashLinkToken(token);
|
|
4213
|
+
const record = await this.store.findOne("verification", { id, purpose });
|
|
4214
|
+
return record ? this.consume(record) : null;
|
|
4215
|
+
}
|
|
4216
|
+
/**
|
|
4217
|
+
* Uses up the user's verification if `code` matches. Too many wrong codes delete it, and after
|
|
4218
|
+
* MAX_WRONG_CODES wrong codes in a row the user's codes stop working until an e-mail link is used.
|
|
4219
|
+
*/
|
|
4220
|
+
async consumeCode(user, purpose, code) {
|
|
4221
|
+
if ((user.wrongCodes ?? 0) >= MAX_WRONG_CODES) return { userId: null, locked: true };
|
|
4222
|
+
const record = await this.store.findOne("verification", { userId: user.id, purpose });
|
|
4223
|
+
if (!record) return { userId: null, locked: false };
|
|
4224
|
+
const attempts = record.attempts + 1;
|
|
4225
|
+
if (await this.store.update("verification", { id: record.id, attempts: record.attempts }, { attempts }) !== 1) {
|
|
4226
|
+
return { userId: null, locked: false };
|
|
4227
|
+
}
|
|
4228
|
+
const wrongCodes = await this.countWrongCode(user);
|
|
4229
|
+
if (wrongCodes === null) return { userId: null, locked: false };
|
|
4230
|
+
if (wrongCodes > MAX_WRONG_CODES) return { userId: null, locked: true };
|
|
4231
|
+
if (!safeEqual(hashCode(this.key, code), record.codeHash)) {
|
|
4232
|
+
if (attempts >= MAX_CODE_ATTEMPTS) await this.store.delete("verification", { id: record.id });
|
|
4233
|
+
return { userId: null, locked: wrongCodes >= MAX_WRONG_CODES };
|
|
4234
|
+
}
|
|
4235
|
+
return { userId: await this.consume(record), locked: false };
|
|
4236
|
+
}
|
|
4237
|
+
/** Counts a code for the user and resolves to the new count; null if other requests kept changing it. */
|
|
4238
|
+
async countWrongCode(user) {
|
|
4239
|
+
let current = user;
|
|
4240
|
+
for (let attempt = 0; attempt < 3 && current; attempt++) {
|
|
4241
|
+
const wrongCodes = (current.wrongCodes ?? 0) + 1;
|
|
4242
|
+
if (await this.store.update("user", { id: user.id, wrongCodes: current.wrongCodes ?? null }, { wrongCodes }) === 1) {
|
|
4243
|
+
return wrongCodes;
|
|
4244
|
+
}
|
|
4245
|
+
current = await this.findById(user.id);
|
|
4246
|
+
}
|
|
4247
|
+
return null;
|
|
4248
|
+
}
|
|
4249
|
+
async consume(record) {
|
|
4250
|
+
const deleted = await this.store.delete("verification", { id: record.id });
|
|
4251
|
+
if (deleted !== 1 || record.expiresAt <= Date.now()) return null;
|
|
4252
|
+
return record.userId;
|
|
4253
|
+
}
|
|
4254
|
+
};
|
|
4255
|
+
|
|
4256
|
+
// src/webhooks.ts
|
|
4257
|
+
import { createHmac as createHmac2, randomBytes as randomBytes3, randomUUID, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
4258
|
+
var WEBHOOK_TYPES = [
|
|
4259
|
+
"email.verify",
|
|
4260
|
+
"email.reset",
|
|
4261
|
+
"email.already_registered",
|
|
4262
|
+
"signup.before",
|
|
4263
|
+
"user.created",
|
|
4264
|
+
"email.verified",
|
|
4265
|
+
"email.password_reset",
|
|
4266
|
+
"user.signed_in",
|
|
4267
|
+
"user.deleted",
|
|
4268
|
+
"user.claims_changed"
|
|
4269
|
+
];
|
|
4270
|
+
var WEBHOOK_TIMEOUT_MS = 1e4;
|
|
4271
|
+
var MAX_LOCALE_LENGTH = 35;
|
|
4272
|
+
function localeOf(value) {
|
|
4273
|
+
return typeof value === "string" && value.length <= MAX_LOCALE_LENGTH ? value : void 0;
|
|
4274
|
+
}
|
|
4275
|
+
var REQUIRED_EMAIL_TYPES = ["email.verify", "email.reset"];
|
|
4276
|
+
var SECRET_PREFIX = "whsec_";
|
|
4277
|
+
var DEV_WEBHOOK_RECEIVER_URL = "https://github.com/inouiw/madAuth-webhooks/tree/main/dev-receiver";
|
|
4278
|
+
var WEBHOOK_TOLERANCE_SECONDS = 5 * 60;
|
|
4279
|
+
function generateWebhookSecret() {
|
|
4280
|
+
return `${SECRET_PREFIX}${randomBytes3(32).toString("base64")}`;
|
|
4281
|
+
}
|
|
4282
|
+
function checkWebhookSecret(secret) {
|
|
4283
|
+
if (!secret.startsWith(SECRET_PREFIX)) return `must start with "${SECRET_PREFIX}"`;
|
|
4284
|
+
const key = Buffer.from(secret.slice(SECRET_PREFIX.length), "base64");
|
|
4285
|
+
if (key.length < 24 || key.length > 64) return "must contain 24 to 64 bytes in base64";
|
|
4286
|
+
return null;
|
|
4287
|
+
}
|
|
4288
|
+
function hmac2(secret, content) {
|
|
4289
|
+
const key = Buffer.from(secret.slice(SECRET_PREFIX.length), "base64");
|
|
4290
|
+
return createHmac2("sha256", key).update(content).digest("base64");
|
|
4291
|
+
}
|
|
4292
|
+
function signWebhook(secret, id, timestamp, body) {
|
|
4293
|
+
return `v1,${hmac2(secret, `${id}.${timestamp}.${body}`)}`;
|
|
4294
|
+
}
|
|
4295
|
+
function createWebhookClient(settings, fetchImpl = fetch) {
|
|
4296
|
+
const wants = (type) => settings.events.has(type);
|
|
4297
|
+
return {
|
|
4298
|
+
wants,
|
|
4299
|
+
async call(type, data, timeoutMs) {
|
|
4300
|
+
if (!wants(type)) return { ok: true, body: void 0 };
|
|
4301
|
+
const id = `msg_${randomUUID()}`;
|
|
4302
|
+
const timestamp = Math.floor(Date.now() / 1e3);
|
|
4303
|
+
const body = JSON.stringify({ type, data });
|
|
4304
|
+
let response;
|
|
4305
|
+
try {
|
|
4306
|
+
response = await fetchImpl(settings.url, {
|
|
4307
|
+
method: "POST",
|
|
4308
|
+
headers: {
|
|
4309
|
+
"Content-Type": "application/json",
|
|
4310
|
+
"webhook-id": id,
|
|
4311
|
+
"webhook-timestamp": String(timestamp),
|
|
4312
|
+
"webhook-signature": signWebhook(settings.secret, id, timestamp, body)
|
|
4313
|
+
},
|
|
4314
|
+
body,
|
|
4315
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
4316
|
+
});
|
|
4317
|
+
} catch (e) {
|
|
4318
|
+
const reason = e.name === "TimeoutError" ? `no answer within ${timeoutMs} ms` : e.message;
|
|
4319
|
+
return { ok: false, reason };
|
|
4320
|
+
}
|
|
4321
|
+
const text = await response.text().catch(() => "");
|
|
4322
|
+
if (!response.ok) return { ok: false, reason: `HTTP ${response.status}` };
|
|
4323
|
+
let parsed;
|
|
4324
|
+
try {
|
|
4325
|
+
parsed = text ? JSON.parse(text) : void 0;
|
|
4326
|
+
} catch {
|
|
4327
|
+
parsed = void 0;
|
|
4328
|
+
}
|
|
4329
|
+
return { ok: true, body: parsed };
|
|
4330
|
+
}
|
|
4331
|
+
};
|
|
4332
|
+
}
|
|
4333
|
+
|
|
4334
|
+
// src/routes/google.ts
|
|
4335
|
+
var NONCE_COOKIE = "madauth_nonce";
|
|
4336
|
+
var NONCE_TYP = "madauth-nonce+jwt";
|
|
4337
|
+
var NONCE_TTL = 5 * 60;
|
|
4338
|
+
var OAUTH_COOKIE = "madauth_oauth";
|
|
4339
|
+
var OAUTH_TYP = "madauth-oauth-state+jwt";
|
|
4340
|
+
var OAUTH_TTL = 10 * 60;
|
|
4341
|
+
var GOOGLE_AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
4342
|
+
var GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
|
|
4343
|
+
var REDIRECT_ERROR_PARAM = "madauth_error";
|
|
4344
|
+
async function pkceChallenge(verifier) {
|
|
4345
|
+
const digest2 = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
4346
|
+
return base64url_exports.encode(new Uint8Array(digest2));
|
|
4347
|
+
}
|
|
4348
|
+
function googleRoutes(app, ctx, google) {
|
|
4349
|
+
const { config, keys, secure, users } = ctx;
|
|
4350
|
+
const { issuer } = config;
|
|
4351
|
+
const callbackUrl = `${issuer}/auth/google/callback`;
|
|
4352
|
+
const resolveUser = async (profile, locale) => {
|
|
4353
|
+
const key = googleAccountKey(profile.sub);
|
|
4354
|
+
const emailNormalized = normalizeEmail(profile.email);
|
|
4355
|
+
const signedIn = (user) => ({ ok: true, user: toMadauthUser(user, profile), sv: user.sessionVersion });
|
|
4356
|
+
const account = await users.findAccountByKey(key);
|
|
4357
|
+
if (account) {
|
|
4358
|
+
const user = await users.findById(account.userId);
|
|
4359
|
+
if (user) {
|
|
4360
|
+
if (account.email !== profile.email) await users.updateAccount(account.id, { email: profile.email });
|
|
4361
|
+
return signedIn(user);
|
|
4362
|
+
}
|
|
4363
|
+
await users.deleteAccount(account.id);
|
|
4364
|
+
}
|
|
4365
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
4366
|
+
const existing = await users.findByEmail(emailNormalized);
|
|
4367
|
+
if (existing) {
|
|
4368
|
+
await users.verifyByProvider(existing);
|
|
4369
|
+
if (!await users.linkAccount(existing.id, { key, email: profile.email })) {
|
|
4370
|
+
const linked = await users.findAccountByKey(key);
|
|
4371
|
+
const owner = linked && await users.findById(linked.userId);
|
|
4372
|
+
if (!owner) return { ok: false, error: "temporarily_unavailable", message: "Please try again." };
|
|
4373
|
+
return signedIn(owner);
|
|
4374
|
+
}
|
|
4375
|
+
return signedIn(existing);
|
|
4376
|
+
}
|
|
4377
|
+
if (attempt === 0) {
|
|
4378
|
+
const check2 = await ctx.checkSignup({ email: profile.email, name: profile.name, locale, method: "google" });
|
|
4379
|
+
if (!check2.ok) {
|
|
4380
|
+
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." };
|
|
4381
|
+
}
|
|
4382
|
+
}
|
|
4383
|
+
const user = await users.createUser({
|
|
4384
|
+
email: profile.email,
|
|
4385
|
+
emailNormalized,
|
|
4386
|
+
name: profile.name ?? null,
|
|
4387
|
+
emailVerified: true,
|
|
4388
|
+
account: { key, email: profile.email }
|
|
4389
|
+
});
|
|
4390
|
+
if (user) {
|
|
4391
|
+
await ctx.emit("user.created", { user: toMadauthUser(user), method: "google" });
|
|
4392
|
+
return signedIn(user);
|
|
4393
|
+
}
|
|
4394
|
+
}
|
|
4395
|
+
return { ok: false, error: "temporarily_unavailable", message: "Please try again." };
|
|
4396
|
+
};
|
|
4397
|
+
const disabled = (c) => c.json({ error: "method_disabled", message: "Google sign-in is switched off." }, 403);
|
|
4398
|
+
app.post("/auth/google/nonce", async (c) => {
|
|
4399
|
+
if (!await ctx.enabled("google")) return disabled(c);
|
|
4400
|
+
const nonce = randomString();
|
|
4401
|
+
const token = await signToken(await keys, issuer, NONCE_TYP, { nonce }, NONCE_TTL);
|
|
4402
|
+
setCookie(c, NONCE_COOKIE, token, {
|
|
4403
|
+
path: "/auth/google",
|
|
4404
|
+
httpOnly: true,
|
|
4405
|
+
secure,
|
|
4406
|
+
sameSite: "Strict",
|
|
4407
|
+
maxAge: NONCE_TTL
|
|
4408
|
+
});
|
|
4409
|
+
return c.json({ nonce });
|
|
4410
|
+
});
|
|
4411
|
+
app.post("/auth/google/verify", async (c) => {
|
|
4412
|
+
if (!await ctx.enabled("google")) return disabled(c);
|
|
4413
|
+
const body = await c.req.json().catch(() => ({}));
|
|
4414
|
+
if (typeof body.credential !== "string" || !body.credential) {
|
|
4415
|
+
return c.json({ error: "verification_failed", message: "credential is missing" }, 400);
|
|
4416
|
+
}
|
|
4417
|
+
const nonceClaims = await readToken(await keys, issuer, NONCE_TYP, getCookie(c, NONCE_COOKIE));
|
|
4418
|
+
if (!nonceClaims) {
|
|
4419
|
+
return c.json({ error: "verification_failed", message: "nonce cookie missing or expired" }, 401);
|
|
4420
|
+
}
|
|
4421
|
+
const result = await verifyGoogleIdToken(body.credential, {
|
|
4422
|
+
clientId: google.clientId,
|
|
4423
|
+
nonce: nonceClaims.nonce,
|
|
4424
|
+
keys: config.jwksResolver
|
|
4425
|
+
});
|
|
4426
|
+
if (!result.ok) {
|
|
4427
|
+
return c.json({ error: result.error, message: result.reason }, result.error === "email_unverified" ? 403 : 401);
|
|
4428
|
+
}
|
|
4429
|
+
deleteCookie(c, NONCE_COOKIE, { path: "/auth/google", secure });
|
|
4430
|
+
const resolved = await resolveUser(result.profile, localeOf(body.locale));
|
|
4431
|
+
if (!resolved.ok) {
|
|
4432
|
+
return c.json({ error: resolved.error, message: resolved.message }, resolved.error === "signup_rejected" ? 403 : 503);
|
|
4433
|
+
}
|
|
4434
|
+
const user = await ctx.startSession(c, resolved.user, ["google"], { sv: resolved.sv });
|
|
4435
|
+
await ctx.emit("user.signed_in", { user, method: "google" });
|
|
4436
|
+
return c.json({ user });
|
|
4437
|
+
});
|
|
4438
|
+
const requireCodeFlow = async (c, next) => {
|
|
4439
|
+
if (!google.clientSecret) return c.json({ error: "not_found" }, 404);
|
|
4440
|
+
if (!await ctx.enabled("google")) return disabled(c);
|
|
4441
|
+
await next();
|
|
4442
|
+
};
|
|
4443
|
+
app.get("/auth/google/start", requireCodeFlow, async (c) => {
|
|
4444
|
+
const returnUrl = ctx.allowedUrl(c.req.query("return_to"));
|
|
4445
|
+
if (!returnUrl) return c.text("return_to must be an absolute URL whose origin is in ALLOWED_ORIGINS", 400);
|
|
4446
|
+
const oauth = {
|
|
4447
|
+
state: randomString(),
|
|
4448
|
+
nonce: randomString(),
|
|
4449
|
+
verifier: randomString(),
|
|
4450
|
+
returnTo: returnUrl.href,
|
|
4451
|
+
locale: localeOf(c.req.query("locale"))
|
|
4452
|
+
};
|
|
4453
|
+
const token = await signToken(await keys, issuer, OAUTH_TYP, { ...oauth }, OAUTH_TTL);
|
|
4454
|
+
setCookie(c, OAUTH_COOKIE, token, {
|
|
4455
|
+
path: "/auth/google",
|
|
4456
|
+
httpOnly: true,
|
|
4457
|
+
secure,
|
|
4458
|
+
sameSite: "Lax",
|
|
4459
|
+
maxAge: OAUTH_TTL
|
|
4460
|
+
});
|
|
4461
|
+
const url = new URL(GOOGLE_AUTH_ENDPOINT);
|
|
4462
|
+
url.search = new URLSearchParams({
|
|
4463
|
+
client_id: google.clientId,
|
|
4464
|
+
redirect_uri: callbackUrl,
|
|
4465
|
+
response_type: "code",
|
|
4466
|
+
scope: "openid email profile",
|
|
4467
|
+
state: oauth.state,
|
|
4468
|
+
nonce: oauth.nonce,
|
|
4469
|
+
code_challenge: await pkceChallenge(oauth.verifier),
|
|
4470
|
+
code_challenge_method: "S256"
|
|
4471
|
+
}).toString();
|
|
4472
|
+
return c.redirect(url.href, 302);
|
|
4473
|
+
});
|
|
4474
|
+
app.get("/auth/google/callback", requireCodeFlow, async (c) => {
|
|
4475
|
+
const oauth = await readToken(await keys, issuer, OAUTH_TYP, getCookie(c, OAUTH_COOKIE));
|
|
4476
|
+
if (!oauth) return c.text("Sign-in expired or was started in another browser. Please try again.", 400);
|
|
4477
|
+
deleteCookie(c, OAUTH_COOKIE, { path: "/auth/google", secure });
|
|
4478
|
+
const back = (error) => {
|
|
4479
|
+
const url = new URL(oauth.returnTo);
|
|
4480
|
+
if (error) url.hash = `${REDIRECT_ERROR_PARAM}=${error}`;
|
|
4481
|
+
return c.redirect(url.href, 302);
|
|
4482
|
+
};
|
|
4483
|
+
if (c.req.query("error")) return back(c.req.query("error") === "access_denied" ? "cancelled" : "verification_failed");
|
|
4484
|
+
const code = c.req.query("code");
|
|
4485
|
+
if (!code || c.req.query("state") !== oauth.state) return back("verification_failed");
|
|
4486
|
+
let idToken;
|
|
4487
|
+
try {
|
|
4488
|
+
const res = await fetch(GOOGLE_TOKEN_ENDPOINT, {
|
|
4489
|
+
method: "POST",
|
|
4490
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
4491
|
+
body: new URLSearchParams({
|
|
4492
|
+
code,
|
|
4493
|
+
client_id: google.clientId,
|
|
4494
|
+
client_secret: google.clientSecret,
|
|
4495
|
+
redirect_uri: callbackUrl,
|
|
4496
|
+
grant_type: "authorization_code",
|
|
4497
|
+
code_verifier: oauth.verifier
|
|
4498
|
+
})
|
|
4499
|
+
});
|
|
4500
|
+
if (!res.ok) return back("verification_failed");
|
|
4501
|
+
({ id_token: idToken } = await res.json());
|
|
4502
|
+
} catch {
|
|
4503
|
+
return back("verification_failed");
|
|
4504
|
+
}
|
|
4505
|
+
if (typeof idToken !== "string") return back("verification_failed");
|
|
4506
|
+
const result = await verifyGoogleIdToken(idToken, {
|
|
4507
|
+
clientId: google.clientId,
|
|
4508
|
+
nonce: oauth.nonce,
|
|
4509
|
+
keys: config.jwksResolver
|
|
4510
|
+
});
|
|
4511
|
+
if (!result.ok) return back(result.error);
|
|
4512
|
+
const resolved = await resolveUser(result.profile, typeof oauth.locale === "string" ? oauth.locale : void 0);
|
|
4513
|
+
if (!resolved.ok) return back(resolved.error);
|
|
4514
|
+
const user = await ctx.startSession(c, resolved.user, ["google"], { sv: resolved.sv });
|
|
4515
|
+
await ctx.emit("user.signed_in", { user, method: "google" });
|
|
4516
|
+
return back();
|
|
4517
|
+
});
|
|
4518
|
+
}
|
|
4519
|
+
|
|
4520
|
+
// src/routes/password.ts
|
|
4521
|
+
var FREE_ATTEMPTS = 5;
|
|
4522
|
+
var MAX_LOCK_MS = 15 * 60 * 1e3;
|
|
4523
|
+
var MAIL_INTERVAL_MS = 60 * 1e3;
|
|
4524
|
+
var VERIFY_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
4525
|
+
var RESET_TTL_MS = 30 * 60 * 1e3;
|
|
4526
|
+
var VERIFY_LINK_PARAM = "madauth_verify";
|
|
4527
|
+
var RESET_LINK_PARAM = "madauth_reset";
|
|
4528
|
+
var MAX_NAME_LENGTH = 100;
|
|
4529
|
+
function passwordRoutes(app, ctx, password, users, webhook) {
|
|
4530
|
+
const { minLength } = password;
|
|
4531
|
+
const disabled = (c) => c.json({ error: "method_disabled", message: "E-mail & password sign-in is switched off." }, 403);
|
|
4532
|
+
app.use("/auth/password/*", async (c, next) => {
|
|
4533
|
+
if (!await ctx.enabled("password")) return disabled(c);
|
|
4534
|
+
await next();
|
|
4535
|
+
});
|
|
4536
|
+
const body = async (c) => {
|
|
4537
|
+
const data = await c.req.json().catch(() => null);
|
|
4538
|
+
return data && typeof data === "object" ? data : {};
|
|
4539
|
+
};
|
|
4540
|
+
const error = (c, status, code, message2) => c.json({ error: code, message: message2 }, status);
|
|
4541
|
+
const unavailable = (c) => error(c, 503, "temporarily_unavailable", "E-mails can not be sent right now. Please try again later.");
|
|
4542
|
+
const accepted = (c) => c.json({}, 202);
|
|
4543
|
+
const redirectTarget = (c, value) => {
|
|
4544
|
+
const url = ctx.allowedUrl(value);
|
|
4545
|
+
return url ?? error(c, 400, "invalid_options", "redirectTo must be an absolute URL whose origin is in ALLOWED_ORIGINS.");
|
|
4546
|
+
};
|
|
4547
|
+
const sendEmail = async (user, type, data) => {
|
|
4548
|
+
if (Date.now() - user.lastMailAt < MAIL_INTERVAL_MS) return true;
|
|
4549
|
+
const result = await webhook.call(type, { to: user.email, user: { id: user.id, name: user.name ?? void 0 }, ...data }, WEBHOOK_TIMEOUT_MS);
|
|
4550
|
+
if (!result.ok) {
|
|
4551
|
+
console.error(`[madauth] Webhook "${type}" for ${user.email} failed: ${result.reason}`);
|
|
4552
|
+
return false;
|
|
4553
|
+
}
|
|
4554
|
+
await users.updateUser(user.id, { lastMailAt: Date.now() });
|
|
4555
|
+
return true;
|
|
4556
|
+
};
|
|
4557
|
+
const sendLinkEmail = async (user, purpose, target, locale) => {
|
|
4558
|
+
if (Date.now() - user.lastMailAt < MAIL_INTERVAL_MS) return true;
|
|
4559
|
+
const ttl = purpose === "verify" ? VERIFY_TTL_MS : RESET_TTL_MS;
|
|
4560
|
+
const { token, code } = await users.issueVerification(user.id, purpose, ttl);
|
|
4561
|
+
const link = new URL(target);
|
|
4562
|
+
link.hash = `${purpose === "verify" ? VERIFY_LINK_PARAM : RESET_LINK_PARAM}=${token}`;
|
|
4563
|
+
return sendEmail(user, purpose === "verify" ? "email.verify" : "email.reset", {
|
|
4564
|
+
link: link.href,
|
|
4199
4565
|
code,
|
|
4200
4566
|
expiresAt: Date.now() + ttl,
|
|
4201
4567
|
site: target.host,
|
|
4202
4568
|
locale
|
|
4203
4569
|
});
|
|
4204
4570
|
};
|
|
4205
|
-
const localeOf = (data) => typeof data.locale === "string" && data.locale.length <= MAX_LOCALE_LENGTH ? data.locale : void 0;
|
|
4206
4571
|
const consume = async (data, purpose) => {
|
|
4207
4572
|
if (typeof data.token === "string" && data.token) {
|
|
4208
4573
|
return { userId: await users.consumeLinkToken(data.token, purpose), via: "link" };
|
|
@@ -4258,25 +4623,15 @@ function passwordRoutes(app, ctx, password, users, webhook) {
|
|
|
4258
4623
|
app.post("/auth/password/signup", async (c) => {
|
|
4259
4624
|
const data = await body(c);
|
|
4260
4625
|
const target = redirectTarget(c, data.redirectTo);
|
|
4261
|
-
if (target instanceof Response) return target;
|
|
4262
|
-
if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
|
|
4263
|
-
const policy = checkPasswordPolicy(data.password, minLength);
|
|
4264
|
-
if (policy) return error(c, 400, "weak_password", policy);
|
|
4265
|
-
const name = typeof data.name === "string" && data.name.trim() ? data.name.trim().slice(0, MAX_NAME_LENGTH) : null;
|
|
4266
|
-
const email = data.email.trim();
|
|
4267
|
-
const locale = localeOf(data);
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
if (!check2.ok) {
|
|
4271
|
-
console.error(`[madauth] Webhook "signup.before" failed: ${check2.reason}`);
|
|
4272
|
-
return unavailable(c);
|
|
4273
|
-
}
|
|
4274
|
-
const answer = check2.body;
|
|
4275
|
-
if (answer?.allow === false) {
|
|
4276
|
-
const message2 = typeof answer.message === "string" && answer.message ? answer.message : "Sign-up is not possible with this e-mail address.";
|
|
4277
|
-
return error(c, 403, "signup_rejected", message2);
|
|
4278
|
-
}
|
|
4279
|
-
}
|
|
4626
|
+
if (target instanceof Response) return target;
|
|
4627
|
+
if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
|
|
4628
|
+
const policy = checkPasswordPolicy(data.password, minLength);
|
|
4629
|
+
if (policy) return error(c, 400, "weak_password", policy);
|
|
4630
|
+
const name = typeof data.name === "string" && data.name.trim() ? data.name.trim().slice(0, MAX_NAME_LENGTH) : null;
|
|
4631
|
+
const email = data.email.trim();
|
|
4632
|
+
const locale = localeOf(data.locale);
|
|
4633
|
+
const check2 = await ctx.checkSignup({ email, name: name ?? void 0, locale, method: "password" });
|
|
4634
|
+
if (!check2.ok) return check2.reason === "rejected" ? error(c, 403, "signup_rejected", check2.message) : unavailable(c);
|
|
4280
4635
|
const passwordHash = await hashPassword(data.password);
|
|
4281
4636
|
const existing = await users.findByEmail(normalizeEmail(email));
|
|
4282
4637
|
if (existing?.emailVerified) {
|
|
@@ -4288,18 +4643,19 @@ function passwordRoutes(app, ctx, password, users, webhook) {
|
|
|
4288
4643
|
if (Date.now() - existing.lastMailAt < MAIL_INTERVAL_MS) return accepted(c);
|
|
4289
4644
|
const account = await users.passwordAccount(existing.id);
|
|
4290
4645
|
if (account) await users.updateAccount(account.id, { secret: passwordHash, failedAttempts: 0, lockedUntil: 0 });
|
|
4646
|
+
else await users.linkAccount(existing.id, { key: passwordAccountKey(existing.id), secret: passwordHash });
|
|
4291
4647
|
await users.updateUser(existing.id, { name });
|
|
4292
4648
|
return await sendLinkEmail({ ...existing, name }, "verify", target, locale) ? accepted(c) : unavailable(c);
|
|
4293
4649
|
}
|
|
4294
|
-
const user = await users.
|
|
4650
|
+
const user = await users.createUser({
|
|
4295
4651
|
email,
|
|
4296
4652
|
emailNormalized: normalizeEmail(email),
|
|
4297
4653
|
name,
|
|
4298
|
-
|
|
4299
|
-
|
|
4654
|
+
emailVerified: false,
|
|
4655
|
+
account: (userId) => ({ key: passwordAccountKey(userId), secret: passwordHash })
|
|
4300
4656
|
});
|
|
4301
4657
|
if (!user) return accepted(c);
|
|
4302
|
-
await ctx.emit("user.created", { user: toMadauthUser(user) });
|
|
4658
|
+
await ctx.emit("user.created", { user: toMadauthUser(user), method: "password" });
|
|
4303
4659
|
return await sendLinkEmail(user, "verify", target, locale) ? accepted(c) : unavailable(c);
|
|
4304
4660
|
});
|
|
4305
4661
|
app.post("/auth/password/send-verification", async (c) => {
|
|
@@ -4309,7 +4665,7 @@ function passwordRoutes(app, ctx, password, users, webhook) {
|
|
|
4309
4665
|
if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
|
|
4310
4666
|
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
4311
4667
|
if (user && !user.emailVerified && await users.passwordAccount(user.id)) {
|
|
4312
|
-
if (!await sendLinkEmail(user, "verify", target, localeOf(data))) return unavailable(c);
|
|
4668
|
+
if (!await sendLinkEmail(user, "verify", target, localeOf(data.locale))) return unavailable(c);
|
|
4313
4669
|
}
|
|
4314
4670
|
return accepted(c);
|
|
4315
4671
|
});
|
|
@@ -4330,8 +4686,8 @@ function passwordRoutes(app, ctx, password, users, webhook) {
|
|
|
4330
4686
|
if (target instanceof Response) return target;
|
|
4331
4687
|
if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
|
|
4332
4688
|
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
4333
|
-
if (user && await users.passwordAccount(user.id)) {
|
|
4334
|
-
if (!await sendLinkEmail(user, "reset", target, localeOf(data))) return unavailable(c);
|
|
4689
|
+
if (user && (user.emailVerified || await users.passwordAccount(user.id))) {
|
|
4690
|
+
if (!await sendLinkEmail(user, "reset", target, localeOf(data.locale))) return unavailable(c);
|
|
4335
4691
|
}
|
|
4336
4692
|
return accepted(c);
|
|
4337
4693
|
});
|
|
@@ -4341,246 +4697,87 @@ function passwordRoutes(app, ctx, password, users, webhook) {
|
|
|
4341
4697
|
if (policy) return error(c, 400, "weak_password", policy);
|
|
4342
4698
|
const consumed = await consume(data, "reset");
|
|
4343
4699
|
const user = consumed.userId ? await users.findById(consumed.userId) : null;
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
|
|
4700
|
+
if (!user) return invalidVerification(c, consumed);
|
|
4701
|
+
const secret = await hashPassword(data.password);
|
|
4702
|
+
const account = await users.passwordAccount(user.id);
|
|
4703
|
+
if (account) await users.updateAccount(account.id, { secret, failedAttempts: 0, lockedUntil: 0 });
|
|
4704
|
+
else await users.linkAccount(user.id, { key: passwordAccountKey(user.id), secret });
|
|
4347
4705
|
const sessionVersion = user.sessionVersion + 1;
|
|
4348
4706
|
await users.updateUser(user.id, { emailVerified: true, sessionVersion, wrongCodes: 0 });
|
|
4349
4707
|
await users.clearVerifications(user.id);
|
|
4350
4708
|
const result = await ctx.startSession(c, toMadauthUser(user), ["pwd"], { sv: sessionVersion });
|
|
4351
|
-
await ctx.emit("
|
|
4709
|
+
await ctx.emit("email.password_reset", { user: result });
|
|
4352
4710
|
await ctx.emit("user.signed_in", { user: result, method: "password" });
|
|
4353
4711
|
return c.json({ user: result });
|
|
4354
4712
|
});
|
|
4355
4713
|
}
|
|
4356
4714
|
|
|
4357
|
-
// src/
|
|
4358
|
-
|
|
4359
|
-
var
|
|
4360
|
-
|
|
4361
|
-
|
|
4362
|
-
|
|
4363
|
-
|
|
4364
|
-
|
|
4365
|
-
|
|
4366
|
-
|
|
4367
|
-
|
|
4715
|
+
// src/settings.ts
|
|
4716
|
+
var SIGN_IN_METHODS = ["google", "password"];
|
|
4717
|
+
var METHODS_SETTING = "methods";
|
|
4718
|
+
function parseMethodSettings(input) {
|
|
4719
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) return null;
|
|
4720
|
+
const settings = {};
|
|
4721
|
+
for (const [key, value] of Object.entries(input)) {
|
|
4722
|
+
if (!SIGN_IN_METHODS.includes(key) || typeof value !== "boolean") return null;
|
|
4723
|
+
settings[key] = value;
|
|
4724
|
+
}
|
|
4725
|
+
return settings;
|
|
4726
|
+
}
|
|
4727
|
+
var MAX_TRIES = 3;
|
|
4728
|
+
var Settings = class {
|
|
4729
|
+
constructor(store) {
|
|
4368
4730
|
this.store = store;
|
|
4369
|
-
this.codeKey = codeKey;
|
|
4370
|
-
}
|
|
4371
|
-
get key() {
|
|
4372
|
-
if (!this.codeKey) throw new Error("Users needs a code key to issue or check e-mail codes.");
|
|
4373
|
-
return this.codeKey;
|
|
4374
|
-
}
|
|
4375
|
-
async findByEmail(emailNormalized) {
|
|
4376
|
-
return await this.store.findOne("user", { emailNormalized });
|
|
4377
|
-
}
|
|
4378
|
-
async findById(id) {
|
|
4379
|
-
return await this.store.findOne("user", { id });
|
|
4380
|
-
}
|
|
4381
|
-
async passwordAccount(userId) {
|
|
4382
|
-
return await this.store.findOne("account", { key: passwordAccountKey(userId) });
|
|
4383
4731
|
}
|
|
4384
|
-
/**
|
|
4385
|
-
async
|
|
4386
|
-
|
|
4387
|
-
const user = {
|
|
4388
|
-
id: newId("usr"),
|
|
4389
|
-
email: data.email,
|
|
4390
|
-
emailNormalized: data.emailNormalized,
|
|
4391
|
-
emailVerified: data.emailVerified,
|
|
4392
|
-
name: data.name,
|
|
4393
|
-
sessionVersion: 0,
|
|
4394
|
-
lastMailAt: 0,
|
|
4395
|
-
wrongCodes: 0,
|
|
4396
|
-
createdAt: now
|
|
4397
|
-
};
|
|
4398
|
-
if (!await this.store.create("user", user)) return null;
|
|
4399
|
-
const account = {
|
|
4400
|
-
id: newId("acc"),
|
|
4401
|
-
userId: user.id,
|
|
4402
|
-
key: passwordAccountKey(user.id),
|
|
4403
|
-
secret: data.passwordHash,
|
|
4404
|
-
failedAttempts: 0,
|
|
4405
|
-
lockedUntil: 0,
|
|
4406
|
-
createdAt: now
|
|
4407
|
-
};
|
|
4408
|
-
if (!await this.store.create("account", account)) {
|
|
4409
|
-
await this.store.delete("user", { id: user.id });
|
|
4410
|
-
return null;
|
|
4411
|
-
}
|
|
4412
|
-
return user;
|
|
4732
|
+
/** The setting's value, or undefined if it was never set. */
|
|
4733
|
+
async get(id) {
|
|
4734
|
+
return (await this.read(id)).value;
|
|
4413
4735
|
}
|
|
4414
|
-
/**
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
|
|
4418
|
-
async deleteByEmail(emailNormalized) {
|
|
4419
|
-
const user = await this.findByEmail(emailNormalized);
|
|
4420
|
-
if (!user) return null;
|
|
4421
|
-
if (await this.store.delete("user", { id: user.id }) !== 1) return null;
|
|
4736
|
+
/** The stored JSON and what it holds; both undefined if the setting was never set. */
|
|
4737
|
+
async read(id) {
|
|
4738
|
+
const record = await this.store.findOne("setting", { id });
|
|
4739
|
+
if (typeof record?.value !== "string") return {};
|
|
4422
4740
|
try {
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
|
|
4426
|
-
console.error(`[madauth] Deleted user ${user.id}, but not all of their records: ${e.message}`);
|
|
4741
|
+
return { json: record.value, value: JSON.parse(record.value) };
|
|
4742
|
+
} catch {
|
|
4743
|
+
return { json: record.value };
|
|
4427
4744
|
}
|
|
4428
|
-
return user.id;
|
|
4429
|
-
}
|
|
4430
|
-
async updateUser(id, patch) {
|
|
4431
|
-
await this.store.update("user", { id }, patch);
|
|
4432
|
-
}
|
|
4433
|
-
async updateAccount(id, patch) {
|
|
4434
|
-
await this.store.update("account", { id }, patch);
|
|
4435
|
-
}
|
|
4436
|
-
/**
|
|
4437
|
-
* Counts a sign-in attempt: writes `patch` only if the account's `failedAttempts` is still the value that
|
|
4438
|
-
* was read. Resolves to false if another request counted an attempt meanwhile.
|
|
4439
|
-
*/
|
|
4440
|
-
async countAttempt(account, patch) {
|
|
4441
|
-
const changed = await this.store.update("account", { id: account.id, failedAttempts: account.failedAttempts }, patch);
|
|
4442
|
-
return changed === 1;
|
|
4443
|
-
}
|
|
4444
|
-
/** Deletes the user's pending verifications and password resets; their links and codes stop working. */
|
|
4445
|
-
async clearVerifications(userId) {
|
|
4446
|
-
await this.store.delete("verification", { userId });
|
|
4447
|
-
}
|
|
4448
|
-
/**
|
|
4449
|
-
* Starts a verification or password reset: replaces the user's earlier one for the same purpose and
|
|
4450
|
-
* returns the link token and the code for the e-mail. Only their hashes are stored.
|
|
4451
|
-
*/
|
|
4452
|
-
async issueVerification(userId, purpose, ttlMs) {
|
|
4453
|
-
await this.store.delete("verification", { userId, purpose });
|
|
4454
|
-
const token = generateLinkToken();
|
|
4455
|
-
const code = generateCode();
|
|
4456
|
-
const record = {
|
|
4457
|
-
id: hashLinkToken(token),
|
|
4458
|
-
userId,
|
|
4459
|
-
purpose,
|
|
4460
|
-
codeHash: hashCode(this.key, code),
|
|
4461
|
-
attempts: 0,
|
|
4462
|
-
expiresAt: Date.now() + ttlMs
|
|
4463
|
-
};
|
|
4464
|
-
await this.store.create("verification", record);
|
|
4465
|
-
return { token, code };
|
|
4466
|
-
}
|
|
4467
|
-
/** Uses up the verification a link token belongs to. Resolves to its user ID, or null if invalid, expired or used. */
|
|
4468
|
-
async consumeLinkToken(token, purpose) {
|
|
4469
|
-
const id = hashLinkToken(token);
|
|
4470
|
-
const record = await this.store.findOne("verification", { id, purpose });
|
|
4471
|
-
return record ? this.consume(record) : null;
|
|
4472
4745
|
}
|
|
4473
4746
|
/**
|
|
4474
|
-
*
|
|
4475
|
-
*
|
|
4747
|
+
* Changes the setting: `apply` gets the current value and returns the new one, or null to refuse the
|
|
4748
|
+
* change. The write only lands if nobody changed the setting since `apply` read it; otherwise `apply` runs
|
|
4749
|
+
* again with the newer value. Resolves to the new value, or null if `apply` refused.
|
|
4750
|
+
* `updatedBy` is the address of the admin, or null for the command line.
|
|
4476
4751
|
*/
|
|
4477
|
-
async
|
|
4478
|
-
|
|
4479
|
-
|
|
4480
|
-
|
|
4481
|
-
|
|
4482
|
-
|
|
4483
|
-
|
|
4752
|
+
async change(id, apply, updatedBy) {
|
|
4753
|
+
for (let attempt = 0; attempt < MAX_TRIES; attempt++) {
|
|
4754
|
+
const { json, value } = await this.read(id);
|
|
4755
|
+
const next = apply(value);
|
|
4756
|
+
if (next === null) return null;
|
|
4757
|
+
const patch = { value: JSON.stringify(next), updatedAt: Date.now(), updatedBy };
|
|
4758
|
+
const written = json === void 0 ? await this.store.create("setting", { id, ...patch }) : await this.store.update("setting", { id, value: json }, patch) === 1;
|
|
4759
|
+
if (written) return next;
|
|
4484
4760
|
}
|
|
4485
|
-
|
|
4486
|
-
if (wrongCodes === null) return { userId: null, locked: false };
|
|
4487
|
-
if (wrongCodes > MAX_WRONG_CODES) return { userId: null, locked: true };
|
|
4488
|
-
if (!safeEqual(hashCode(this.key, code), record.codeHash)) {
|
|
4489
|
-
if (attempts >= MAX_CODE_ATTEMPTS) await this.store.delete("verification", { id: record.id });
|
|
4490
|
-
return { userId: null, locked: wrongCodes >= MAX_WRONG_CODES };
|
|
4491
|
-
}
|
|
4492
|
-
return { userId: await this.consume(record), locked: false };
|
|
4761
|
+
throw new Error(`Setting "${id}" kept changing; try again.`);
|
|
4493
4762
|
}
|
|
4494
|
-
/**
|
|
4495
|
-
async
|
|
4496
|
-
|
|
4497
|
-
for (let attempt = 0; attempt < 3 && current; attempt++) {
|
|
4498
|
-
const wrongCodes = (current.wrongCodes ?? 0) + 1;
|
|
4499
|
-
if (await this.store.update("user", { id: user.id, wrongCodes: current.wrongCodes ?? null }, { wrongCodes }) === 1) {
|
|
4500
|
-
return wrongCodes;
|
|
4501
|
-
}
|
|
4502
|
-
current = await this.findById(user.id);
|
|
4503
|
-
}
|
|
4504
|
-
return null;
|
|
4763
|
+
/** Replaces the setting. */
|
|
4764
|
+
async set(id, value, updatedBy) {
|
|
4765
|
+
await this.change(id, () => value, updatedBy);
|
|
4505
4766
|
}
|
|
4506
|
-
|
|
4507
|
-
|
|
4508
|
-
|
|
4509
|
-
|
|
4767
|
+
/** Which sign-in methods are on; a method that was never switched off is on. */
|
|
4768
|
+
async methods() {
|
|
4769
|
+
return parseMethodSettings(await this.get(METHODS_SETTING)) ?? {};
|
|
4770
|
+
}
|
|
4771
|
+
/** Changes which methods are on, see {@link change}; `apply` gets the methods as they are now. */
|
|
4772
|
+
async changeMethods(apply, updatedBy) {
|
|
4773
|
+
return this.change(METHODS_SETTING, (current) => apply(parseMethodSettings(current) ?? {}), updatedBy);
|
|
4510
4774
|
}
|
|
4511
4775
|
};
|
|
4512
|
-
|
|
4513
|
-
|
|
4514
|
-
import { createHmac as createHmac2, randomBytes as randomBytes3, randomUUID, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
4515
|
-
var WEBHOOK_TYPES = [
|
|
4516
|
-
"email.verify",
|
|
4517
|
-
"email.reset",
|
|
4518
|
-
"email.already_registered",
|
|
4519
|
-
"signup.before",
|
|
4520
|
-
"user.created",
|
|
4521
|
-
"email.verified",
|
|
4522
|
-
"password.reset",
|
|
4523
|
-
"user.signed_in",
|
|
4524
|
-
"user.deleted",
|
|
4525
|
-
"roles.changed"
|
|
4526
|
-
];
|
|
4527
|
-
var REQUIRED_EMAIL_TYPES = ["email.verify", "email.reset"];
|
|
4528
|
-
var SECRET_PREFIX = "whsec_";
|
|
4529
|
-
var DEV_WEBHOOK_RECEIVER_URL = "https://github.com/inouiw/madAuth-webhooks/tree/main/dev-receiver";
|
|
4530
|
-
var WEBHOOK_TOLERANCE_SECONDS = 5 * 60;
|
|
4531
|
-
function generateWebhookSecret() {
|
|
4532
|
-
return `${SECRET_PREFIX}${randomBytes3(32).toString("base64")}`;
|
|
4533
|
-
}
|
|
4534
|
-
function checkWebhookSecret(secret) {
|
|
4535
|
-
if (!secret.startsWith(SECRET_PREFIX)) return `must start with "${SECRET_PREFIX}"`;
|
|
4536
|
-
const key = Buffer.from(secret.slice(SECRET_PREFIX.length), "base64");
|
|
4537
|
-
if (key.length < 24 || key.length > 64) return "must contain 24 to 64 bytes in base64";
|
|
4538
|
-
return null;
|
|
4539
|
-
}
|
|
4540
|
-
function hmac2(secret, content) {
|
|
4541
|
-
const key = Buffer.from(secret.slice(SECRET_PREFIX.length), "base64");
|
|
4542
|
-
return createHmac2("sha256", key).update(content).digest("base64");
|
|
4543
|
-
}
|
|
4544
|
-
function signWebhook(secret, id, timestamp, body) {
|
|
4545
|
-
return `v1,${hmac2(secret, `${id}.${timestamp}.${body}`)}`;
|
|
4776
|
+
function isOn(method, available, methods) {
|
|
4777
|
+
return available(method) && methods[method] !== false;
|
|
4546
4778
|
}
|
|
4547
|
-
function
|
|
4548
|
-
|
|
4549
|
-
return {
|
|
4550
|
-
wants,
|
|
4551
|
-
async call(type, data, timeoutMs) {
|
|
4552
|
-
if (!wants(type)) return { ok: true, body: void 0 };
|
|
4553
|
-
const id = `msg_${randomUUID()}`;
|
|
4554
|
-
const timestamp = Math.floor(Date.now() / 1e3);
|
|
4555
|
-
const body = JSON.stringify({ type, data });
|
|
4556
|
-
let response;
|
|
4557
|
-
try {
|
|
4558
|
-
response = await fetchImpl(settings.url, {
|
|
4559
|
-
method: "POST",
|
|
4560
|
-
headers: {
|
|
4561
|
-
"Content-Type": "application/json",
|
|
4562
|
-
"webhook-id": id,
|
|
4563
|
-
"webhook-timestamp": String(timestamp),
|
|
4564
|
-
"webhook-signature": signWebhook(settings.secret, id, timestamp, body)
|
|
4565
|
-
},
|
|
4566
|
-
body,
|
|
4567
|
-
signal: AbortSignal.timeout(timeoutMs)
|
|
4568
|
-
});
|
|
4569
|
-
} catch (e) {
|
|
4570
|
-
const reason = e.name === "TimeoutError" ? `no answer within ${timeoutMs} ms` : e.message;
|
|
4571
|
-
return { ok: false, reason };
|
|
4572
|
-
}
|
|
4573
|
-
const text = await response.text().catch(() => "");
|
|
4574
|
-
if (!response.ok) return { ok: false, reason: `HTTP ${response.status}` };
|
|
4575
|
-
let parsed;
|
|
4576
|
-
try {
|
|
4577
|
-
parsed = text ? JSON.parse(text) : void 0;
|
|
4578
|
-
} catch {
|
|
4579
|
-
parsed = void 0;
|
|
4580
|
-
}
|
|
4581
|
-
return { ok: true, body: parsed };
|
|
4582
|
-
}
|
|
4583
|
-
};
|
|
4779
|
+
function anyOn(available, methods) {
|
|
4780
|
+
return SIGN_IN_METHODS.some((method) => isOn(method, available, methods));
|
|
4584
4781
|
}
|
|
4585
4782
|
|
|
4586
4783
|
// src/app.ts
|
|
@@ -4590,12 +4787,9 @@ function createApp(config) {
|
|
|
4590
4787
|
const keys = importSigningKeys(config.signingKey);
|
|
4591
4788
|
const secure = issuer.startsWith("https:");
|
|
4592
4789
|
const isAllowedOrigin = (origin) => !!origin && allowedOrigins.includes(origin);
|
|
4593
|
-
const
|
|
4594
|
-
const
|
|
4595
|
-
|
|
4596
|
-
const current = roles && user.email ? await roles.get(normalizeEmail(user.email)) : [];
|
|
4597
|
-
return current.length ? { ...rest, roles: current } : rest;
|
|
4598
|
-
};
|
|
4790
|
+
const users = new Users(config.store, deriveCodeKey(config.signingKey.d));
|
|
4791
|
+
const settings = new Settings(config.store);
|
|
4792
|
+
const available = (method) => method === "google" ? !!config.google : !!config.password;
|
|
4599
4793
|
const issueSession = async (c, user, amr, extra) => {
|
|
4600
4794
|
const signingKeys = await keys;
|
|
4601
4795
|
const [sessionToken, renewalToken] = await Promise.all([
|
|
@@ -4647,12 +4841,29 @@ function createApp(config) {
|
|
|
4647
4841
|
return url;
|
|
4648
4842
|
},
|
|
4649
4843
|
async startSession(c, user, amr, extra) {
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
return signedIn;
|
|
4844
|
+
await issueSession(c, user, amr, extra);
|
|
4845
|
+
return user;
|
|
4653
4846
|
},
|
|
4654
|
-
users
|
|
4847
|
+
users,
|
|
4848
|
+
settings,
|
|
4655
4849
|
webhook: config.webhook ? createWebhookClient(config.webhook, config.webhookFetch) : void 0,
|
|
4850
|
+
async enabled(method) {
|
|
4851
|
+
return isOn(method, available, await settings.methods());
|
|
4852
|
+
},
|
|
4853
|
+
async checkSignup(data) {
|
|
4854
|
+
if (!this.webhook?.wants("signup.before")) return { ok: true };
|
|
4855
|
+
const check2 = await this.webhook.call("signup.before", data, WEBHOOK_TIMEOUT_MS);
|
|
4856
|
+
if (!check2.ok) {
|
|
4857
|
+
console.error(`[madauth] Webhook "signup.before" failed: ${check2.reason}`);
|
|
4858
|
+
return { ok: false, reason: "unavailable" };
|
|
4859
|
+
}
|
|
4860
|
+
const answer = check2.body;
|
|
4861
|
+
if (answer?.allow === false) {
|
|
4862
|
+
const message2 = typeof answer.message === "string" && answer.message ? answer.message : "Sign-up is not possible with this e-mail address.";
|
|
4863
|
+
return { ok: false, reason: "rejected", message: message2 };
|
|
4864
|
+
}
|
|
4865
|
+
return { ok: true };
|
|
4866
|
+
},
|
|
4656
4867
|
async emit(type, data) {
|
|
4657
4868
|
if (!this.webhook?.wants(type)) return;
|
|
4658
4869
|
const result = await this.webhook.call(type, data, EVENT_TIMEOUT_MS);
|
|
@@ -4682,29 +4893,28 @@ function createApp(config) {
|
|
|
4682
4893
|
c.header("Cache-Control", "public, max-age=3600");
|
|
4683
4894
|
return c.json({ keys: [publicJwk] });
|
|
4684
4895
|
});
|
|
4685
|
-
app.get(
|
|
4686
|
-
|
|
4687
|
-
(
|
|
4688
|
-
|
|
4689
|
-
|
|
4690
|
-
|
|
4691
|
-
|
|
4896
|
+
app.get("/auth/config", async (c) => {
|
|
4897
|
+
const methods = await settings.methods();
|
|
4898
|
+
const on = (method) => isOn(method, available, methods);
|
|
4899
|
+
return c.json({
|
|
4900
|
+
google: on("google") ? { clientId: config.google.clientId, codeFlow: !!config.google.clientSecret } : null,
|
|
4901
|
+
password: on("password") ? { minLength: config.password.minLength } : null
|
|
4902
|
+
});
|
|
4903
|
+
});
|
|
4692
4904
|
if (config.google) googleRoutes(app, ctx, config.google);
|
|
4693
|
-
if (config.password && ctx.
|
|
4905
|
+
if (config.password && ctx.webhook) passwordRoutes(app, ctx, config.password, users, ctx.webhook);
|
|
4694
4906
|
const tokenClaims = async (c, cookie, typ) => readToken(await keys, issuer, typ, getCookie(c, cookie));
|
|
4695
|
-
const hasEnded = async (claims) => {
|
|
4696
|
-
if (!claims.sub.startsWith("usr_")) return false;
|
|
4697
|
-
const stored = await ctx.users?.findById(claims.sub);
|
|
4698
|
-
return !stored || stored.sessionVersion !== claims.sv;
|
|
4699
|
-
};
|
|
4700
4907
|
const checked = async (claims) => {
|
|
4701
|
-
const
|
|
4702
|
-
|
|
4908
|
+
const stored = await users.findById(claims.sub);
|
|
4909
|
+
if (!stored || stored.sessionVersion !== claims.sv) return null;
|
|
4910
|
+
const { claims: _, ...user } = userFromClaims(claims);
|
|
4911
|
+
const current = claimsFromJson(stored.claims);
|
|
4912
|
+
return Object.keys(current).length ? { ...user, claims: current } : user;
|
|
4703
4913
|
};
|
|
4704
4914
|
const currentSession = async (c) => {
|
|
4705
4915
|
const session = await tokenClaims(c, SESSION_COOKIE, SESSION_TYP);
|
|
4706
4916
|
const user = session ? await checked(session) : null;
|
|
4707
|
-
const upToDate = session && user &&
|
|
4917
|
+
const upToDate = session && user && sameClaims(user.claims, session.claims) && Date.now() / 1e3 - session.iat <= sessionTtlSeconds / 2;
|
|
4708
4918
|
if (upToDate) return user;
|
|
4709
4919
|
const renewal = await tokenClaims(c, RENEWAL_COOKIE, RENEWAL_TYP);
|
|
4710
4920
|
const sameSignIn = session && user && renewal && renewal.sub === session.sub && renewal.sv === session.sv && renewal.email === session.email;
|
|
@@ -4730,56 +4940,82 @@ function createApp(config) {
|
|
|
4730
4940
|
app.post("/auth/account/delete", async (c) => {
|
|
4731
4941
|
const user = await currentSession(c);
|
|
4732
4942
|
if (!user) return c.json({ error: "no_session" }, 401);
|
|
4733
|
-
|
|
4734
|
-
const passwordUserId = ctx.users && user.email ? await ctx.users.deleteByEmail(normalizeEmail(user.email)) : null;
|
|
4943
|
+
await users.deleteById(user.id);
|
|
4735
4944
|
clearSession(c);
|
|
4736
|
-
await ctx.emit("user.deleted", { user
|
|
4945
|
+
await ctx.emit("user.deleted", { user });
|
|
4737
4946
|
return c.body(null, 204);
|
|
4738
4947
|
});
|
|
4739
|
-
|
|
4740
|
-
const
|
|
4741
|
-
|
|
4742
|
-
|
|
4743
|
-
|
|
4744
|
-
|
|
4745
|
-
|
|
4746
|
-
|
|
4747
|
-
|
|
4748
|
-
|
|
4749
|
-
|
|
4750
|
-
|
|
4751
|
-
|
|
4752
|
-
|
|
4753
|
-
|
|
4754
|
-
|
|
4755
|
-
|
|
4756
|
-
|
|
4757
|
-
|
|
4758
|
-
|
|
4759
|
-
|
|
4760
|
-
|
|
4761
|
-
|
|
4762
|
-
|
|
4763
|
-
|
|
4764
|
-
|
|
4765
|
-
|
|
4766
|
-
|
|
4767
|
-
|
|
4768
|
-
|
|
4769
|
-
|
|
4770
|
-
|
|
4771
|
-
|
|
4772
|
-
|
|
4773
|
-
|
|
4774
|
-
|
|
4775
|
-
|
|
4776
|
-
|
|
4777
|
-
|
|
4778
|
-
|
|
4779
|
-
|
|
4780
|
-
|
|
4781
|
-
});
|
|
4782
|
-
|
|
4948
|
+
const admin = async (c) => {
|
|
4949
|
+
const user = await currentSession(c);
|
|
4950
|
+
if (!user) return { answer: c.json({ error: "no_session" }, 401) };
|
|
4951
|
+
const email = user.email ? normalizeEmail(user.email) : void 0;
|
|
4952
|
+
if (!email || !isAdmin(user)) {
|
|
4953
|
+
const message2 = `Only users with the role "${ADMIN_ROLE}" can manage claims and settings.`;
|
|
4954
|
+
return { answer: c.json({ error: "forbidden", message: message2 }, 403) };
|
|
4955
|
+
}
|
|
4956
|
+
return { email };
|
|
4957
|
+
};
|
|
4958
|
+
const body = async (c) => {
|
|
4959
|
+
const data = await c.req.json().catch(() => null);
|
|
4960
|
+
return data && typeof data === "object" ? data : {};
|
|
4961
|
+
};
|
|
4962
|
+
const invalidEmail = (c) => c.json({ error: "invalid_email", message: "This is not a valid e-mail address." }, 400);
|
|
4963
|
+
const noUser = (c) => c.json({ error: "user_not_found", message: "No user has this e-mail address." }, 404);
|
|
4964
|
+
app.post("/auth/admin/claims/get", async (c) => {
|
|
4965
|
+
const caller = await admin(c);
|
|
4966
|
+
if ("answer" in caller) return caller.answer;
|
|
4967
|
+
const data = await body(c);
|
|
4968
|
+
if (!isValidEmail(data.email)) return invalidEmail(c);
|
|
4969
|
+
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
4970
|
+
if (!user) return noUser(c);
|
|
4971
|
+
return c.json({ email: user.emailNormalized, userId: user.id, claims: claimsFromJson(user.claims) });
|
|
4972
|
+
});
|
|
4973
|
+
app.post("/auth/admin/claims/set", async (c) => {
|
|
4974
|
+
const caller = await admin(c);
|
|
4975
|
+
if ("answer" in caller) return caller.answer;
|
|
4976
|
+
const data = await body(c);
|
|
4977
|
+
if (!isValidEmail(data.email)) return invalidEmail(c);
|
|
4978
|
+
const claims = parseClaims(data.claims);
|
|
4979
|
+
if (!claims) {
|
|
4980
|
+
return c.json(
|
|
4981
|
+
{
|
|
4982
|
+
error: "invalid_claims",
|
|
4983
|
+
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 "_".'
|
|
4984
|
+
},
|
|
4985
|
+
400
|
|
4986
|
+
);
|
|
4987
|
+
}
|
|
4988
|
+
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
4989
|
+
if (!user) return noUser(c);
|
|
4990
|
+
await users.updateUser(user.id, { claims: Object.keys(claims).length ? JSON.stringify(claims) : null });
|
|
4991
|
+
const email = user.emailNormalized;
|
|
4992
|
+
await ctx.emit("user.claims_changed", { userId: user.id, email, claims, by: caller.email });
|
|
4993
|
+
return c.json({ email, userId: user.id, claims });
|
|
4994
|
+
});
|
|
4995
|
+
const settingsAnswer = (methods) => ({
|
|
4996
|
+
methods: Object.fromEntries(
|
|
4997
|
+
SIGN_IN_METHODS.map((method) => [method, { available: available(method), enabled: isOn(method, available, methods) }])
|
|
4998
|
+
)
|
|
4999
|
+
});
|
|
5000
|
+
app.post("/auth/admin/settings/get", async (c) => {
|
|
5001
|
+
const caller = await admin(c);
|
|
5002
|
+
if ("answer" in caller) return caller.answer;
|
|
5003
|
+
return c.json(settingsAnswer(await settings.methods()));
|
|
5004
|
+
});
|
|
5005
|
+
app.post("/auth/admin/settings/set", async (c) => {
|
|
5006
|
+
const caller = await admin(c);
|
|
5007
|
+
if ("answer" in caller) return caller.answer;
|
|
5008
|
+
const data = await body(c);
|
|
5009
|
+
const invalid2 = (message2) => c.json({ error: "invalid_settings", message: message2 }, 400);
|
|
5010
|
+
const changes = parseMethodSettings(data.methods);
|
|
5011
|
+
if (!changes) return invalid2(`methods must be an object with true or false for ${SIGN_IN_METHODS.join(" and/or ")}.`);
|
|
5012
|
+
const methods = await settings.changeMethods((current) => {
|
|
5013
|
+
const next = { ...current, ...changes };
|
|
5014
|
+
return anyOn(available, next) ? next : null;
|
|
5015
|
+
}, caller.email);
|
|
5016
|
+
if (!methods) return invalid2("At least one sign-in method must stay on.");
|
|
5017
|
+
return c.json(settingsAnswer(methods));
|
|
5018
|
+
});
|
|
4783
5019
|
return app;
|
|
4784
5020
|
}
|
|
4785
5021
|
|
|
@@ -4844,19 +5080,18 @@ MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
|
|
|
4844
5080
|
if (clientId && !clientId.endsWith(".apps.googleusercontent.com")) {
|
|
4845
5081
|
throw new ConfigError(`GOOGLE_CLIENT_ID must end with ".apps.googleusercontent.com" but is "${clientId}".`);
|
|
4846
5082
|
}
|
|
4847
|
-
const
|
|
4848
|
-
const
|
|
4849
|
-
|
|
5083
|
+
const webhook = webhookFromEnv(read("WEBHOOK_URL"), read("WEBHOOK_SECRET"), read("WEBHOOK_EVENTS"));
|
|
5084
|
+
const sendsEmails = !!webhook && REQUIRED_EMAIL_TYPES.every((type) => webhook.events.has(type));
|
|
5085
|
+
const password = sendsEmails ? { minLength: passwordMinLength(read("PASSWORD_MIN_LENGTH")) } : void 0;
|
|
5086
|
+
if (!clientId && !password) {
|
|
4850
5087
|
throw new ConfigError(
|
|
4851
|
-
`
|
|
5088
|
+
`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.`
|
|
4852
5089
|
);
|
|
4853
5090
|
}
|
|
4854
5091
|
const store = overrides.store ?? await storeFromDatabaseUrl(read("DATABASE_URL"));
|
|
4855
|
-
|
|
4856
|
-
if (store) password = { minLength: passwordMinLength(read("PASSWORD_MIN_LENGTH")), store };
|
|
4857
|
-
if (!clientId && !password) {
|
|
5092
|
+
if (!store) {
|
|
4858
5093
|
throw new ConfigError(
|
|
4859
|
-
"
|
|
5094
|
+
"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."
|
|
4860
5095
|
);
|
|
4861
5096
|
}
|
|
4862
5097
|
const ttlValue = read("SESSION_TTL");
|
|
@@ -4879,6 +5114,7 @@ MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
|
|
|
4879
5114
|
renewalTtlSeconds,
|
|
4880
5115
|
cookieDomain: read("COOKIE_DOMAIN"),
|
|
4881
5116
|
google: clientId ? { clientId, clientSecret: read("GOOGLE_CLIENT_SECRET") } : void 0,
|
|
5117
|
+
store,
|
|
4882
5118
|
password,
|
|
4883
5119
|
webhook
|
|
4884
5120
|
};
|
|
@@ -4924,8 +5160,9 @@ async function dynamoDbStore(tableName2) {
|
|
|
4924
5160
|
const { createDynamoDbAdapter: createDynamoDbAdapter2 } = await Promise.resolve().then(() => (init_dynamodb(), dynamodb_exports));
|
|
4925
5161
|
return createDynamoDbAdapter2({ tableName: tableName2 });
|
|
4926
5162
|
}
|
|
5163
|
+
var RENAMED_TYPES = { "password.reset": "email.password_reset", "roles.changed": "user.claims_changed" };
|
|
4927
5164
|
var LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]", "host.docker.internal"];
|
|
4928
|
-
function webhookFromEnv(url, secret, events
|
|
5165
|
+
function webhookFromEnv(url, secret, events) {
|
|
4929
5166
|
if (!url) {
|
|
4930
5167
|
if (secret || events) throw new ConfigError("WEBHOOK_SECRET and WEBHOOK_EVENTS need WEBHOOK_URL.");
|
|
4931
5168
|
return void 0;
|
|
@@ -4955,24 +5192,22 @@ WEBHOOK_SECRET=${generateWebhookSecret()}
|
|
|
4955
5192
|
const allTypes = `All types: ${WEBHOOK_TYPES.join(", ")}. See "Webhooks" in docs/server.md.`;
|
|
4956
5193
|
if (!selected.size) {
|
|
4957
5194
|
throw new ConfigError(
|
|
4958
|
-
|
|
5195
|
+
`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:
|
|
4959
5196
|
|
|
4960
5197
|
WEBHOOK_EVENTS=email.verify,email.reset,email.already_registered
|
|
4961
5198
|
|
|
4962
|
-
|
|
5199
|
+
For one that only wants to know who signed in:
|
|
5200
|
+
|
|
5201
|
+
WEBHOOK_EVENTS=user.signed_in
|
|
5202
|
+
|
|
5203
|
+
` + allTypes
|
|
4963
5204
|
);
|
|
4964
5205
|
}
|
|
4965
5206
|
const unknown = [...selected].filter((e) => !WEBHOOK_TYPES.includes(e));
|
|
4966
5207
|
if (unknown.length) {
|
|
4967
|
-
|
|
4968
|
-
}
|
|
4969
|
-
const missing = sendsEmails ? REQUIRED_EMAIL_TYPES.filter((type) => !selected.has(type)) : [];
|
|
4970
|
-
if (missing.length) {
|
|
5208
|
+
const renamed = unknown.filter((e) => e in RENAMED_TYPES).map((e) => `${e} is now ${RENAMED_TYPES[e]}`);
|
|
4971
5209
|
throw new ConfigError(
|
|
4972
|
-
`WEBHOOK_EVENTS
|
|
4973
|
-
|
|
4974
|
-
WEBHOOK_EVENTS=${[...selected, ...missing].join(",")}
|
|
4975
|
-
`
|
|
5210
|
+
`WEBHOOK_EVENTS: unknown type ${unknown.join(", ")}. ${renamed.length ? `${renamed.join("; ")}. ` : ""}Known types: ${WEBHOOK_TYPES.join(", ")}.`
|
|
4976
5211
|
);
|
|
4977
5212
|
}
|
|
4978
5213
|
return { url: parsed.href, secret, events: selected };
|