@madauth/server 0.4.0-beta.1 → 0.5.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/bin.js +134 -44
- package/dist/{chunk-3PACOYTG.js → chunk-6SF2WNOC.js} +908 -377
- package/dist/{chunk-GA4TF7EI.js → chunk-NP75Y3IV.js} +27 -4
- package/dist/{chunk-ISJGOCF3.js → chunk-NYIEFI4B.js} +268 -55
- package/dist/{chunk-OMIX3ERC.js → chunk-O2URPWA6.js} +27 -3
- package/dist/{chunk-Y7ZSUROX.js → chunk-SISM7YUZ.js} +14 -12
- package/dist/{config-DkE_2D3-.d.ts → config-CcGlHVAN.d.ts} +16 -8
- package/dist/dynamodb.js +1 -1
- package/dist/entry/azure.d.ts +3 -3
- package/dist/entry/azure.js +3 -3
- package/dist/entry/lambda.d.ts +3 -3
- package/dist/entry/lambda.js +3 -3
- package/dist/entry/node.d.ts +3 -3
- package/dist/entry/node.js +3 -3
- package/dist/index.d.ts +22 -15
- package/dist/index.js +7 -5
- package/dist/{schema-DTeiSuQB.d.ts → schema-BTgDj1Xn.d.ts} +43 -4
- package/dist/sqlite.js +2 -2
- package/dist/standalone/azure.mjs +4384 -425
- package/dist/standalone/lambda.mjs +4383 -424
- package/dist/standalone/main.mjs +4321 -362
- 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 +19 -0
- package/dist/{user-BZs84sui.d.ts → user-yoGjrw4k.d.ts} +2 -2
- package/dist/verify.d.ts +1 -1
- package/dist/{webhook-B6P50ocX.d.ts → webhook-DlmfG_kS.d.ts} +5 -2
- package/dist/webhook.d.ts +1 -1
- package/dist/webhook.js +1 -1
- package/package.json +3 -1
|
@@ -13,7 +13,10 @@ import {
|
|
|
13
13
|
} from "./chunk-A7UYZRBL.js";
|
|
14
14
|
import {
|
|
15
15
|
ADMIN_ROLE,
|
|
16
|
+
DEFAULT_PASSWORD_RULES,
|
|
17
|
+
MAX_MIN_LENGTH,
|
|
16
18
|
MAX_PASSWORD_LENGTH,
|
|
19
|
+
METHODS,
|
|
17
20
|
METHODS_WITH_SECOND_FACTOR,
|
|
18
21
|
SIGN_IN_METHODS,
|
|
19
22
|
Settings,
|
|
@@ -24,7 +27,10 @@ import {
|
|
|
24
27
|
checkPasswordPolicy,
|
|
25
28
|
claimsFromJson,
|
|
26
29
|
deriveCodeKey,
|
|
30
|
+
emailLinkAccountKey,
|
|
31
|
+
githubAccountKey,
|
|
27
32
|
googleAccountKey,
|
|
33
|
+
hasSecondFactor,
|
|
28
34
|
hashCode,
|
|
29
35
|
hashPassword,
|
|
30
36
|
initialMethods,
|
|
@@ -37,16 +43,18 @@ import {
|
|
|
37
43
|
normalizeEmail,
|
|
38
44
|
parseClaims,
|
|
39
45
|
parseMethodSettings,
|
|
46
|
+
parsePasswordRules,
|
|
40
47
|
passwordAccountKey,
|
|
41
48
|
policyOf,
|
|
42
49
|
safeEqual,
|
|
43
50
|
sameClaims,
|
|
44
51
|
sendsVerificationEmails,
|
|
52
|
+
smsAccountKey,
|
|
45
53
|
toMadauthUser,
|
|
46
54
|
totpAccountKey,
|
|
47
55
|
verifyAgainstDummy,
|
|
48
56
|
verifyPassword
|
|
49
|
-
} from "./chunk-
|
|
57
|
+
} from "./chunk-NYIEFI4B.js";
|
|
50
58
|
import {
|
|
51
59
|
importSigningKeys
|
|
52
60
|
} from "./chunk-JDQLSL36.js";
|
|
@@ -54,11 +62,503 @@ import {
|
|
|
54
62
|
WEBHOOK_TIMEOUT_MS,
|
|
55
63
|
createWebhookClient,
|
|
56
64
|
localeOf
|
|
57
|
-
} from "./chunk-
|
|
65
|
+
} from "./chunk-SISM7YUZ.js";
|
|
58
66
|
|
|
59
|
-
// src/routes/
|
|
67
|
+
// src/routes/oauth.ts
|
|
60
68
|
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
|
61
69
|
import { base64url } from "jose";
|
|
70
|
+
var REDIRECT_ERROR_PARAM = "madauth_error";
|
|
71
|
+
var REDIRECT_NEXT_PARAM = "madauth_next";
|
|
72
|
+
var REDIRECT_FACTORS_PARAM = "madauth_factors";
|
|
73
|
+
var REDIRECT_METHOD_PARAM = "madauth_method";
|
|
74
|
+
var OAUTH_COOKIE = "madauth_oauth";
|
|
75
|
+
var OAUTH_TYP = "madauth-oauth-state+jwt";
|
|
76
|
+
var OAUTH_TTL = 10 * 60;
|
|
77
|
+
async function pkceChallenge(verifier) {
|
|
78
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
79
|
+
return base64url.encode(new Uint8Array(digest));
|
|
80
|
+
}
|
|
81
|
+
async function startOAuth(c, ctx, cookiePath, returnTo) {
|
|
82
|
+
const oauth = {
|
|
83
|
+
state: randomString(),
|
|
84
|
+
nonce: randomString(),
|
|
85
|
+
verifier: randomString(),
|
|
86
|
+
returnTo: returnTo.href,
|
|
87
|
+
locale: localeOf(c.req.query("locale"))
|
|
88
|
+
};
|
|
89
|
+
const token = await signToken(await ctx.keys, ctx.config.issuer, OAUTH_TYP, { ...oauth }, OAUTH_TTL);
|
|
90
|
+
setCookie(c, OAUTH_COOKIE, token, {
|
|
91
|
+
path: cookiePath,
|
|
92
|
+
httpOnly: true,
|
|
93
|
+
secure: ctx.secure,
|
|
94
|
+
sameSite: "Lax",
|
|
95
|
+
maxAge: OAUTH_TTL
|
|
96
|
+
});
|
|
97
|
+
return oauth;
|
|
98
|
+
}
|
|
99
|
+
async function takeOAuthState(c, ctx, cookiePath) {
|
|
100
|
+
const oauth = await readToken(await ctx.keys, ctx.config.issuer, OAUTH_TYP, getCookie(c, OAUTH_COOKIE));
|
|
101
|
+
if (!oauth) return null;
|
|
102
|
+
deleteCookie(c, OAUTH_COOKIE, { path: cookiePath, secure: ctx.secure });
|
|
103
|
+
return {
|
|
104
|
+
state: oauth.state,
|
|
105
|
+
nonce: oauth.nonce,
|
|
106
|
+
verifier: oauth.verifier,
|
|
107
|
+
returnTo: oauth.returnTo,
|
|
108
|
+
...typeof oauth.locale === "string" ? { locale: oauth.locale } : {}
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function redirectBack(c, returnTo, method) {
|
|
112
|
+
return (error2, next, factors = []) => {
|
|
113
|
+
const url = new URL(returnTo);
|
|
114
|
+
if (error2) url.hash = `${REDIRECT_ERROR_PARAM}=${error2}&${REDIRECT_METHOD_PARAM}=${method}`;
|
|
115
|
+
if (next) url.hash = `${REDIRECT_NEXT_PARAM}=${next}${factors.length ? `&${REDIRECT_FACTORS_PARAM}=${factors.join(",")}` : ""}&${REDIRECT_METHOD_PARAM}=${method}`;
|
|
116
|
+
return c.redirect(url.href, 302);
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
var METHOD_NAMES = {
|
|
120
|
+
google: "Google",
|
|
121
|
+
github: "GitHub",
|
|
122
|
+
password: "a password",
|
|
123
|
+
totp: "an authenticator app",
|
|
124
|
+
"email-link": "an e-mail link"
|
|
125
|
+
};
|
|
126
|
+
async function resolveProviderUser(ctx, provider, profile, locale) {
|
|
127
|
+
const { users } = ctx;
|
|
128
|
+
const { key } = profile;
|
|
129
|
+
const emailNormalized = normalizeEmail(profile.email);
|
|
130
|
+
const signedIn = (user) => ({ ok: true, user });
|
|
131
|
+
const account = await users.findAccountByKey(key);
|
|
132
|
+
if (account) {
|
|
133
|
+
const user = await users.findById(account.userId);
|
|
134
|
+
if (user) {
|
|
135
|
+
const patch = {
|
|
136
|
+
...account.email !== profile.email ? { email: profile.email } : {},
|
|
137
|
+
...profile.secret ? { secret: profile.secret } : {}
|
|
138
|
+
};
|
|
139
|
+
if (Object.keys(patch).length) await users.updateAccount(account.id, patch);
|
|
140
|
+
return signedIn(user);
|
|
141
|
+
}
|
|
142
|
+
await users.deleteAccount(account.id);
|
|
143
|
+
}
|
|
144
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
145
|
+
const existing = await users.findByEmail(emailNormalized);
|
|
146
|
+
if (existing) {
|
|
147
|
+
if (existing.emailVerified && await users.hasSignInMethod(existing.id)) {
|
|
148
|
+
const methods = await users.signInMethods(existing.id, await ctx.settings.methods());
|
|
149
|
+
const how = methods.map((method) => METHOD_NAMES[method]).join(" or ");
|
|
150
|
+
return {
|
|
151
|
+
ok: false,
|
|
152
|
+
error: "other_method",
|
|
153
|
+
message: `This e-mail address signs in with ${how || "another method"}. Use that instead of ${provider.name}.`,
|
|
154
|
+
methods
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
const taken = await users.reopenSignUp(existing);
|
|
158
|
+
await users.verifyByProvider(taken);
|
|
159
|
+
if (!await users.linkAccount(taken.id, { key, email: profile.email, secret: profile.secret })) {
|
|
160
|
+
const linked = await users.findAccountByKey(key);
|
|
161
|
+
const owner = linked && await users.findById(linked.userId);
|
|
162
|
+
if (!owner) return { ok: false, error: "temporarily_unavailable", message: "Please try again." };
|
|
163
|
+
return signedIn(owner);
|
|
164
|
+
}
|
|
165
|
+
return signedIn({ ...taken, emailVerified: true });
|
|
166
|
+
}
|
|
167
|
+
if (attempt === 0) {
|
|
168
|
+
const check = await ctx.checkSignup({ email: profile.email, name: profile.name, locale, method: provider.method });
|
|
169
|
+
if (!check.ok) {
|
|
170
|
+
return check.reason === "rejected" ? { ok: false, error: "signup_rejected", message: check.message } : { ok: false, error: "temporarily_unavailable", message: "Signing up is not possible right now. Please try again later." };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const user = await users.createUser({
|
|
174
|
+
email: profile.email,
|
|
175
|
+
emailNormalized,
|
|
176
|
+
name: profile.name ?? null,
|
|
177
|
+
emailVerified: true,
|
|
178
|
+
account: { key, email: profile.email, secret: profile.secret }
|
|
179
|
+
});
|
|
180
|
+
if (user) return signedIn(user);
|
|
181
|
+
}
|
|
182
|
+
return { ok: false, error: "temporarily_unavailable", message: "Please try again." };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// src/app.ts
|
|
186
|
+
import { Hono } from "hono";
|
|
187
|
+
import { deleteCookie as deleteCookie6, getCookie as getCookie6, setCookie as setCookie6 } from "hono/cookie";
|
|
188
|
+
import { cors } from "hono/cors";
|
|
189
|
+
|
|
190
|
+
// src/routes/helpers.ts
|
|
191
|
+
async function body(c) {
|
|
192
|
+
const data = await c.req.json().catch(() => null);
|
|
193
|
+
return data && typeof data === "object" ? data : {};
|
|
194
|
+
}
|
|
195
|
+
function error(c, status, code, message) {
|
|
196
|
+
return c.json({ error: code, message }, status);
|
|
197
|
+
}
|
|
198
|
+
var unavailable = (c) => error(c, 503, "temporarily_unavailable", "E-mails can not be sent right now. Please try again later.");
|
|
199
|
+
var smsUnavailable = (c) => error(c, 503, "temporarily_unavailable", "The SMS can not be sent right now. Please try again later.");
|
|
200
|
+
function originHost(ctx, c) {
|
|
201
|
+
const origin = c.req.header("origin");
|
|
202
|
+
return URL.canParse(origin ?? "") ? new URL(origin).hostname : new URL(ctx.config.issuer).hostname;
|
|
203
|
+
}
|
|
204
|
+
var accepted = (c) => c.json({}, 202);
|
|
205
|
+
var noSession = (c) => error(c, 401, "no_session", "Please sign in first.");
|
|
206
|
+
function redirectTarget(ctx, c, value) {
|
|
207
|
+
const url = ctx.allowedUrl(value);
|
|
208
|
+
return url ?? error(c, 400, "invalid_options", "redirectTo must be an absolute URL whose origin is in ALLOWED_ORIGINS.");
|
|
209
|
+
}
|
|
210
|
+
var MAX_NAME_LENGTH = 100;
|
|
211
|
+
function nameOf(value) {
|
|
212
|
+
return typeof value === "string" && value.trim() ? value.trim().slice(0, MAX_NAME_LENGTH) : null;
|
|
213
|
+
}
|
|
214
|
+
function signInAnswer(c, step) {
|
|
215
|
+
if ("disabled" in step) return error(c, 403, "method_disabled", "This way of signing in is switched off.");
|
|
216
|
+
if ("next" in step) {
|
|
217
|
+
const rest = { method: step.method, factors: step.factors };
|
|
218
|
+
if (step.next === "totp") return c.json({ error: "totp_required", message: "Enter the code from your authenticator app to finish signing in.", ...rest }, 401);
|
|
219
|
+
if (step.next === "sms") return c.json({ error: "sms_required", message: "Enter the code we sent to your phone to finish signing in.", ...rest }, 401);
|
|
220
|
+
return c.json({ error: "totp_setup_required", message: "Set up an authenticator app to finish signing in.", ...rest }, 401);
|
|
221
|
+
}
|
|
222
|
+
return c.json({ user: step.user });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// src/routes/emails.ts
|
|
226
|
+
var MAIL_INTERVAL_MS = 60 * 1e3;
|
|
227
|
+
var VERIFY_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
228
|
+
var RESET_TTL_MS = 30 * 60 * 1e3;
|
|
229
|
+
var SIGNIN_TTL_MS = 15 * 60 * 1e3;
|
|
230
|
+
var VERIFY_LINK_PARAM = "madauth_verify";
|
|
231
|
+
var RESET_LINK_PARAM = "madauth_reset";
|
|
232
|
+
var SIGNIN_LINK_PARAM = "madauth_signin";
|
|
233
|
+
var LINKS = {
|
|
234
|
+
verify: { ttl: VERIFY_TTL_MS, param: VERIFY_LINK_PARAM, type: "email.send_verification" },
|
|
235
|
+
reset: { ttl: RESET_TTL_MS, param: RESET_LINK_PARAM, type: "email.send_password_reset" },
|
|
236
|
+
signin: { ttl: SIGNIN_TTL_MS, param: SIGNIN_LINK_PARAM, type: "email.send_signin_link" }
|
|
237
|
+
};
|
|
238
|
+
function createEmails(ctx, users, webhook) {
|
|
239
|
+
const sendEmail = async (user, type, data) => {
|
|
240
|
+
if (Date.now() - user.lastMailAt < MAIL_INTERVAL_MS) return true;
|
|
241
|
+
const result = await webhook.call(type, { to: user.email, user: { id: user.id, name: user.name ?? void 0 }, ...data }, WEBHOOK_TIMEOUT_MS);
|
|
242
|
+
if (!result.ok) {
|
|
243
|
+
console.error(`[madauth] Webhook "${type}" for ${user.email} failed: ${result.reason}`);
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
await users.updateUser(user.id, { lastMailAt: Date.now() });
|
|
247
|
+
return true;
|
|
248
|
+
};
|
|
249
|
+
return {
|
|
250
|
+
sendEmail,
|
|
251
|
+
async sendLinkEmail(user, purpose, target, locale) {
|
|
252
|
+
if (Date.now() - user.lastMailAt < MAIL_INTERVAL_MS) return true;
|
|
253
|
+
const { ttl, param, type } = LINKS[purpose];
|
|
254
|
+
const { token, code } = await users.issueVerification(user.id, purpose, ttl);
|
|
255
|
+
const link = new URL(target);
|
|
256
|
+
link.hash = `${param}=${token}`;
|
|
257
|
+
return sendEmail(user, type, { link: link.href, code, expiresAt: Date.now() + ttl, site: target.host, locale });
|
|
258
|
+
},
|
|
259
|
+
async sendAccountEmail(user, type, target, locale) {
|
|
260
|
+
if (!webhook.wants(type)) return true;
|
|
261
|
+
const methods = await users.signInMethods(user.id, await ctx.settings.methods());
|
|
262
|
+
return sendEmail(user, type, { link: target.href, site: target.host, locale, methods });
|
|
263
|
+
},
|
|
264
|
+
async consume(data, purpose) {
|
|
265
|
+
if (typeof data.token === "string" && data.token) {
|
|
266
|
+
return { userId: await users.consumeLinkToken(data.token, purpose), via: "link" };
|
|
267
|
+
}
|
|
268
|
+
if (isValidEmail(data.email) && typeof data.code === "string") {
|
|
269
|
+
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
270
|
+
if (!user) return { userId: null, via: "code" };
|
|
271
|
+
return { ...await users.consumeCode(user, purpose, data.code.replace(/\s/g, "")), via: "code" };
|
|
272
|
+
}
|
|
273
|
+
return { userId: null, via: "code" };
|
|
274
|
+
},
|
|
275
|
+
invalidVerification(c, { via, locked }) {
|
|
276
|
+
if (locked) {
|
|
277
|
+
return error(c, 429, "codes_locked", "Too many wrong codes. Please ask for a new e-mail and use the link in it.");
|
|
278
|
+
}
|
|
279
|
+
return via === "link" ? error(c, 400, "link_invalid", "The link is invalid, expired or was already used.") : error(c, 400, "code_invalid", "The code is wrong or expired.");
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// src/routes/email.ts
|
|
285
|
+
function emailRoutes(app, ctx, users, webhook) {
|
|
286
|
+
const emails = createEmails(ctx, users, webhook);
|
|
287
|
+
const signUpMethod = async (userId) => await users.passwordAccount(userId) ? "password" : "totp";
|
|
288
|
+
app.post("/auth/email/send-verification", async (c) => {
|
|
289
|
+
const data = await body(c);
|
|
290
|
+
const target = redirectTarget(ctx, c, data.redirectTo);
|
|
291
|
+
if (target instanceof Response) return target;
|
|
292
|
+
if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
|
|
293
|
+
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
294
|
+
if (user && !user.emailVerified && await ctx.enabled(c, await signUpMethod(user.id))) {
|
|
295
|
+
if (!await emails.sendLinkEmail(user, "verify", target, localeOf(data.locale))) return unavailable(c);
|
|
296
|
+
}
|
|
297
|
+
return accepted(c);
|
|
298
|
+
});
|
|
299
|
+
const disabled = (c) => error(c, 403, "method_disabled", "This way of signing in is switched off.");
|
|
300
|
+
app.post("/auth/email/verify", async (c) => {
|
|
301
|
+
const data = await body(c);
|
|
302
|
+
const waiting = typeof data.token === "string" && data.token ? await users.peekLinkToken(data.token, "verify") : isValidEmail(data.email) ? (await users.findByEmail(normalizeEmail(data.email)))?.id ?? null : null;
|
|
303
|
+
if (waiting && !await ctx.enabled(c, await signUpMethod(waiting))) return disabled(c);
|
|
304
|
+
const consumed = await emails.consume(data, "verify");
|
|
305
|
+
const { userId, via } = consumed;
|
|
306
|
+
const user = userId ? await users.findById(userId) : null;
|
|
307
|
+
if (!user) return emails.invalidVerification(c, consumed);
|
|
308
|
+
const method = await signUpMethod(user.id);
|
|
309
|
+
if (!await ctx.enabled(c, method)) return disabled(c);
|
|
310
|
+
if (!user.emailVerified) await users.dropUnprovenAccounts(user.id, method === "password" ? "password" : void 0);
|
|
311
|
+
await users.updateUser(user.id, { emailVerified: true, wrongCodes: 0 });
|
|
312
|
+
const verified = { ...user.emailVerified ? user : await users.reopenSignUp(user), emailVerified: true, wrongCodes: 0 };
|
|
313
|
+
await ctx.emit("user.email_verified", { user: toMadauthUser(verified), method, via });
|
|
314
|
+
return signInAnswer(c, await ctx.secondStep(c, verified, method === "password" ? ["pwd"] : [], method));
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// src/routes/email-link.ts
|
|
319
|
+
function emailLinkRoutes(app, ctx, users, webhook) {
|
|
320
|
+
const emails = createEmails(ctx, users, webhook);
|
|
321
|
+
app.use("/auth/email-link/*", async (c, next) => {
|
|
322
|
+
if (!await ctx.enabled(c, "email-link")) {
|
|
323
|
+
return error(c, 403, "method_disabled", "Signing in with an e-mail link is switched off.");
|
|
324
|
+
}
|
|
325
|
+
await next();
|
|
326
|
+
});
|
|
327
|
+
app.post("/auth/email-link/send", async (c) => {
|
|
328
|
+
const data = await body(c);
|
|
329
|
+
const target = redirectTarget(ctx, c, data.redirectTo);
|
|
330
|
+
if (target instanceof Response) return target;
|
|
331
|
+
if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
|
|
332
|
+
const email = data.email.trim();
|
|
333
|
+
const locale = localeOf(data.locale);
|
|
334
|
+
const send = async (user2) => await emails.sendLinkEmail(user2, "signin", target, locale) ? accepted(c) : unavailable(c);
|
|
335
|
+
const existing = await users.findByEmail(normalizeEmail(email));
|
|
336
|
+
if (existing && await users.emailLinkAccount(existing.id)) return send(existing);
|
|
337
|
+
if (existing?.emailVerified && await users.hasSignInMethod(existing.id)) {
|
|
338
|
+
return await emails.sendAccountEmail(existing, "email.send_already_registered", target, locale) ? accepted(c) : unavailable(c);
|
|
339
|
+
}
|
|
340
|
+
const check = await ctx.checkSignup({ email, locale, method: "email-link" });
|
|
341
|
+
if (!check.ok) return check.reason === "rejected" ? error(c, 403, "signup_rejected", check.message) : unavailable(c);
|
|
342
|
+
if (existing) {
|
|
343
|
+
if (Date.now() - existing.lastMailAt < MAIL_INTERVAL_MS) return accepted(c);
|
|
344
|
+
if (!existing.emailVerified) await users.dropUnprovenAccounts(existing.id, "email-link");
|
|
345
|
+
if (!await users.linkAccount(existing.id, { key: emailLinkAccountKey(existing.id) })) return accepted(c);
|
|
346
|
+
return send(await users.reopenSignUp(existing));
|
|
347
|
+
}
|
|
348
|
+
const user = await users.createUser({
|
|
349
|
+
email,
|
|
350
|
+
emailNormalized: normalizeEmail(email),
|
|
351
|
+
name: null,
|
|
352
|
+
emailVerified: false,
|
|
353
|
+
account: (userId) => ({ key: emailLinkAccountKey(userId) })
|
|
354
|
+
});
|
|
355
|
+
if (!user) return accepted(c);
|
|
356
|
+
return send(user);
|
|
357
|
+
});
|
|
358
|
+
app.post("/auth/email-link/verify", async (c) => {
|
|
359
|
+
const consumed = await emails.consume(await body(c), "signin");
|
|
360
|
+
const found = consumed.userId ? await users.findById(consumed.userId) : null;
|
|
361
|
+
if (!found || !await users.emailLinkAccount(found.id)) return emails.invalidVerification(c, consumed);
|
|
362
|
+
const user = found.emailVerified ? found : await users.reopenSignUp(found);
|
|
363
|
+
if (!user.emailVerified) {
|
|
364
|
+
await users.verifyByProvider(user, "email-link");
|
|
365
|
+
await ctx.emit("user.email_verified", { user: toMadauthUser({ ...user, emailVerified: true }), method: "email-link", via: consumed.via });
|
|
366
|
+
} else {
|
|
367
|
+
await users.updateUser(user.id, { wrongCodes: 0 });
|
|
368
|
+
}
|
|
369
|
+
const verified = { ...user, emailVerified: true, wrongCodes: 0 };
|
|
370
|
+
return signInAnswer(c, await ctx.secondStep(c, verified, ["email"], "email-link"));
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// src/crypto.ts
|
|
375
|
+
import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "crypto";
|
|
376
|
+
var ENCRYPTION_VERSION = "v1";
|
|
377
|
+
var IV_BYTES = 12;
|
|
378
|
+
var TAG_BYTES = 16;
|
|
379
|
+
function deriveKey(signingKeyD, info) {
|
|
380
|
+
return Buffer.from(hkdfSync("sha256", Buffer.from(signingKeyD, "base64url"), "", info, 32));
|
|
381
|
+
}
|
|
382
|
+
function encrypt(key, secret) {
|
|
383
|
+
const iv = randomBytes(IV_BYTES);
|
|
384
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
385
|
+
const encrypted = Buffer.concat([cipher.update(secret), cipher.final()]);
|
|
386
|
+
const parts = [iv, encrypted, cipher.getAuthTag()].map((part) => part.toString("base64url"));
|
|
387
|
+
return [ENCRYPTION_VERSION, ...parts].join(".");
|
|
388
|
+
}
|
|
389
|
+
function decrypt(key, stored) {
|
|
390
|
+
const [version, iv, encrypted, tag, ...rest] = stored.split(".");
|
|
391
|
+
if (version !== ENCRYPTION_VERSION || !iv || !encrypted || !tag || rest.length) return null;
|
|
392
|
+
const ivBytes = Buffer.from(iv, "base64url");
|
|
393
|
+
const tagBytes = Buffer.from(tag, "base64url");
|
|
394
|
+
if (ivBytes.length !== IV_BYTES || tagBytes.length !== TAG_BYTES) return null;
|
|
395
|
+
try {
|
|
396
|
+
const decipher = createDecipheriv("aes-256-gcm", key, ivBytes);
|
|
397
|
+
decipher.setAuthTag(tagBytes);
|
|
398
|
+
return Buffer.concat([decipher.update(Buffer.from(encrypted, "base64url")), decipher.final()]);
|
|
399
|
+
} catch {
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// src/github.ts
|
|
405
|
+
var TOKEN_ENDPOINT = "https://github.com/login/oauth/access_token";
|
|
406
|
+
var API = "https://api.github.com";
|
|
407
|
+
var API_VERSION = "2022-11-28";
|
|
408
|
+
var USER_AGENT = "madauth";
|
|
409
|
+
var NOREPLY_DOMAIN = "@users.noreply.github.com";
|
|
410
|
+
var TIMEOUT_MS = 1e4;
|
|
411
|
+
var REVOKE_TIMEOUT_MS = 5e3;
|
|
412
|
+
function deriveTokenKey(signingKeyD) {
|
|
413
|
+
return deriveKey(signingKeyD, "madauth provider token");
|
|
414
|
+
}
|
|
415
|
+
var apiHeaders = (token) => ({
|
|
416
|
+
Authorization: `Bearer ${token}`,
|
|
417
|
+
Accept: "application/vnd.github+json",
|
|
418
|
+
"X-GitHub-Api-Version": API_VERSION,
|
|
419
|
+
"User-Agent": USER_AGENT
|
|
420
|
+
});
|
|
421
|
+
async function exchangeGitHubCode(app, code, redirectUri, verifier) {
|
|
422
|
+
try {
|
|
423
|
+
const res = await fetch(TOKEN_ENDPOINT, {
|
|
424
|
+
method: "POST",
|
|
425
|
+
headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded", "User-Agent": USER_AGENT },
|
|
426
|
+
body: new URLSearchParams({
|
|
427
|
+
client_id: app.clientId,
|
|
428
|
+
client_secret: app.clientSecret,
|
|
429
|
+
code,
|
|
430
|
+
redirect_uri: redirectUri,
|
|
431
|
+
code_verifier: verifier
|
|
432
|
+
}),
|
|
433
|
+
signal: AbortSignal.timeout(TIMEOUT_MS)
|
|
434
|
+
});
|
|
435
|
+
if (!res.ok) {
|
|
436
|
+
await res.body?.cancel();
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
const body2 = await res.json();
|
|
440
|
+
return typeof body2.access_token === "string" && body2.access_token ? body2.access_token : null;
|
|
441
|
+
} catch {
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
async function fetchGitHubAccount(token) {
|
|
446
|
+
const failed = (reason) => ({ ok: false, error: "verification_failed", reason });
|
|
447
|
+
let user;
|
|
448
|
+
let emails;
|
|
449
|
+
try {
|
|
450
|
+
const [userRes, emailsRes] = await Promise.all([
|
|
451
|
+
fetch(`${API}/user`, { headers: apiHeaders(token), signal: AbortSignal.timeout(TIMEOUT_MS) }),
|
|
452
|
+
fetch(`${API}/user/emails`, { headers: apiHeaders(token), signal: AbortSignal.timeout(TIMEOUT_MS) })
|
|
453
|
+
]);
|
|
454
|
+
if (!userRes.ok || !emailsRes.ok) {
|
|
455
|
+
await Promise.all([userRes.body?.cancel(), emailsRes.body?.cancel()]);
|
|
456
|
+
return failed(!userRes.ok ? `GET /user answered ${userRes.status}` : `GET /user/emails answered ${emailsRes.status}`);
|
|
457
|
+
}
|
|
458
|
+
user = await userRes.json();
|
|
459
|
+
emails = await emailsRes.json();
|
|
460
|
+
} catch (e) {
|
|
461
|
+
return failed(e.message);
|
|
462
|
+
}
|
|
463
|
+
const id = typeof user.id === "number" ? String(user.id) : typeof user.id === "string" ? user.id : "";
|
|
464
|
+
if (!id || typeof user.login !== "string") return failed("GET /user did not return an account");
|
|
465
|
+
if (!Array.isArray(emails)) return failed("GET /user/emails did not return a list");
|
|
466
|
+
const verified = emails.filter((e) => typeof e.email === "string" && e.email && e.verified === true && !e.email.endsWith(NOREPLY_DOMAIN));
|
|
467
|
+
const address = verified.find((e) => e.primary === true) ?? verified[0];
|
|
468
|
+
if (!address) return { ok: false, error: "email_unverified", reason: "the GitHub account has no verified e-mail address" };
|
|
469
|
+
const account = {
|
|
470
|
+
id,
|
|
471
|
+
email: address.email,
|
|
472
|
+
name: typeof user.name === "string" && user.name.trim() ? user.name.trim() : user.login
|
|
473
|
+
};
|
|
474
|
+
if (typeof user.avatar_url === "string" && user.avatar_url) account.picture = user.avatar_url;
|
|
475
|
+
return { ok: true, account };
|
|
476
|
+
}
|
|
477
|
+
async function revokeGitHubGrant(app, token) {
|
|
478
|
+
try {
|
|
479
|
+
const res = await fetch(`${API}/applications/${encodeURIComponent(app.clientId)}/grant`, {
|
|
480
|
+
method: "DELETE",
|
|
481
|
+
headers: {
|
|
482
|
+
Authorization: `Basic ${Buffer.from(`${app.clientId}:${app.clientSecret}`).toString("base64")}`,
|
|
483
|
+
Accept: "application/vnd.github+json",
|
|
484
|
+
"Content-Type": "application/json",
|
|
485
|
+
"X-GitHub-Api-Version": API_VERSION,
|
|
486
|
+
"User-Agent": USER_AGENT
|
|
487
|
+
},
|
|
488
|
+
body: JSON.stringify({ access_token: token }),
|
|
489
|
+
signal: AbortSignal.timeout(REVOKE_TIMEOUT_MS)
|
|
490
|
+
});
|
|
491
|
+
await res.body?.cancel();
|
|
492
|
+
return res.status;
|
|
493
|
+
} catch {
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// src/routes/github.ts
|
|
499
|
+
var GITHUB_AUTH_ENDPOINT = "https://github.com/login/oauth/authorize";
|
|
500
|
+
var COOKIE_PATH = "/auth/github";
|
|
501
|
+
var GITHUB = { method: "github", name: "GitHub" };
|
|
502
|
+
function githubRoutes(app, ctx, github, tokenKey) {
|
|
503
|
+
const callbackUrl = `${ctx.config.issuer}/auth/github/callback`;
|
|
504
|
+
app.get("/auth/github/start", async (c) => {
|
|
505
|
+
const returnUrl = ctx.allowedUrl(c.req.query("return_to"));
|
|
506
|
+
if (!returnUrl) return c.text("return_to must be an absolute URL whose origin is in ALLOWED_ORIGINS", 400);
|
|
507
|
+
if (!await ctx.enabled(c, "github")) return redirectBack(c, returnUrl.href, "github")("method_disabled");
|
|
508
|
+
const oauth = await startOAuth(c, ctx, COOKIE_PATH, returnUrl);
|
|
509
|
+
const url = new URL(GITHUB_AUTH_ENDPOINT);
|
|
510
|
+
url.search = new URLSearchParams({
|
|
511
|
+
client_id: github.clientId,
|
|
512
|
+
redirect_uri: callbackUrl,
|
|
513
|
+
// Only the addresses: the profile's id, name and picture come with any token.
|
|
514
|
+
scope: "user:email",
|
|
515
|
+
state: oauth.state,
|
|
516
|
+
code_challenge: await pkceChallenge(oauth.verifier),
|
|
517
|
+
code_challenge_method: "S256"
|
|
518
|
+
}).toString();
|
|
519
|
+
return c.redirect(url.href, 302);
|
|
520
|
+
});
|
|
521
|
+
app.get("/auth/github/callback", async (c) => {
|
|
522
|
+
const oauth = await takeOAuthState(c, ctx, COOKIE_PATH);
|
|
523
|
+
if (!oauth) return c.text("Sign-in expired or was started in another browser. Please try again.", 400);
|
|
524
|
+
const back = redirectBack(c, oauth.returnTo, "github");
|
|
525
|
+
if (!await ctx.enabled(c, "github")) return back("method_disabled");
|
|
526
|
+
if (c.req.query("error")) return back(c.req.query("error") === "access_denied" ? "cancelled" : "verification_failed");
|
|
527
|
+
const code = c.req.query("code");
|
|
528
|
+
if (!code || c.req.query("state") !== oauth.state) return back("verification_failed");
|
|
529
|
+
const token = await exchangeGitHubCode(github, code, callbackUrl, oauth.verifier);
|
|
530
|
+
if (!token) return back("verification_failed");
|
|
531
|
+
const result = await fetchGitHubAccount(token);
|
|
532
|
+
if (!result.ok) return back(result.error);
|
|
533
|
+
const { account } = result;
|
|
534
|
+
const resolved = await resolveProviderUser(
|
|
535
|
+
ctx,
|
|
536
|
+
GITHUB,
|
|
537
|
+
{ key: githubAccountKey(account.id), email: account.email, name: account.name, secret: encrypt(tokenKey, Buffer.from(token)) },
|
|
538
|
+
oauth.locale
|
|
539
|
+
);
|
|
540
|
+
if (!resolved.ok) return back(resolved.error);
|
|
541
|
+
const step = await ctx.secondStep(c, resolved.user, ["github"], "github", { name: account.name, picture: account.picture });
|
|
542
|
+
if ("disabled" in step) return back("method_disabled");
|
|
543
|
+
if ("next" in step) return back(void 0, step.next, step.factors);
|
|
544
|
+
return back();
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
async function revokeGrant(github, tokenKey, account) {
|
|
548
|
+
if (!account) return;
|
|
549
|
+
const token = account.secret ? decrypt(tokenKey, account.secret) : null;
|
|
550
|
+
if (!token) {
|
|
551
|
+
console.error(`[madauth] The GitHub token of user ${account.userId} can't be read (was MADAUTH_SIGNING_KEY changed?): the authorization stays at GitHub until the user removes it.`);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
const status = await revokeGitHubGrant(github, token.toString());
|
|
555
|
+
if (status !== 204 && status !== 404) {
|
|
556
|
+
console.error(`[madauth] GitHub did not revoke the authorization of user ${account.userId}${status ? ` (HTTP ${status})` : ""}: it stays until the user removes it.`);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// src/routes/google.ts
|
|
561
|
+
import { deleteCookie as deleteCookie2, getCookie as getCookie2, setCookie as setCookie2 } from "hono/cookie";
|
|
62
562
|
|
|
63
563
|
// src/google.ts
|
|
64
564
|
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
@@ -94,109 +594,26 @@ async function verifyGoogleIdToken(idToken, opts) {
|
|
|
94
594
|
return { ok: true, profile };
|
|
95
595
|
}
|
|
96
596
|
|
|
97
|
-
// src/routes/helpers.ts
|
|
98
|
-
async function body(c) {
|
|
99
|
-
const data = await c.req.json().catch(() => null);
|
|
100
|
-
return data && typeof data === "object" ? data : {};
|
|
101
|
-
}
|
|
102
|
-
function error(c, status, code, message) {
|
|
103
|
-
return c.json({ error: code, message }, status);
|
|
104
|
-
}
|
|
105
|
-
var unavailable = (c) => error(c, 503, "temporarily_unavailable", "E-mails can not be sent right now. Please try again later.");
|
|
106
|
-
var accepted = (c) => c.json({}, 202);
|
|
107
|
-
var noSession = (c) => error(c, 401, "no_session", "Please sign in first.");
|
|
108
|
-
function redirectTarget(ctx, c, value) {
|
|
109
|
-
const url = ctx.allowedUrl(value);
|
|
110
|
-
return url ?? error(c, 400, "invalid_options", "redirectTo must be an absolute URL whose origin is in ALLOWED_ORIGINS.");
|
|
111
|
-
}
|
|
112
|
-
var MAX_NAME_LENGTH = 100;
|
|
113
|
-
function nameOf(value) {
|
|
114
|
-
return typeof value === "string" && value.trim() ? value.trim().slice(0, MAX_NAME_LENGTH) : null;
|
|
115
|
-
}
|
|
116
|
-
function signInAnswer(c, step) {
|
|
117
|
-
if ("disabled" in step) return error(c, 403, "method_disabled", "This way of signing in is switched off.");
|
|
118
|
-
if ("next" in step) {
|
|
119
|
-
return step.next === "totp" ? c.json({ error: "totp_required", message: "Enter the code from your authenticator app to finish signing in.", method: step.method }, 401) : c.json({ error: "totp_setup_required", message: "Set up an authenticator app to finish signing in.", method: step.method }, 401);
|
|
120
|
-
}
|
|
121
|
-
return c.json({ user: step.user });
|
|
122
|
-
}
|
|
123
|
-
|
|
124
597
|
// src/routes/google.ts
|
|
125
598
|
var NONCE_COOKIE = "madauth_nonce";
|
|
126
599
|
var NONCE_TYP = "madauth-nonce+jwt";
|
|
127
600
|
var NONCE_TTL = 5 * 60;
|
|
128
|
-
var
|
|
129
|
-
var OAUTH_TYP = "madauth-oauth-state+jwt";
|
|
130
|
-
var OAUTH_TTL = 10 * 60;
|
|
601
|
+
var COOKIE_PATH2 = "/auth/google";
|
|
131
602
|
var GOOGLE_AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
132
603
|
var GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
|
|
133
|
-
var
|
|
134
|
-
var
|
|
135
|
-
async function pkceChallenge(verifier) {
|
|
136
|
-
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
137
|
-
return base64url.encode(new Uint8Array(digest));
|
|
138
|
-
}
|
|
604
|
+
var GOOGLE = { method: "google", name: "Google" };
|
|
605
|
+
var toProfile = (profile) => ({ key: googleAccountKey(profile.sub), email: profile.email, name: profile.name });
|
|
139
606
|
function googleRoutes(app, ctx, google) {
|
|
140
|
-
const { config, keys, secure
|
|
607
|
+
const { config, keys, secure } = ctx;
|
|
141
608
|
const { issuer } = config;
|
|
142
609
|
const callbackUrl = `${issuer}/auth/google/callback`;
|
|
143
|
-
const resolveUser = async (profile, locale) => {
|
|
144
|
-
const key = googleAccountKey(profile.sub);
|
|
145
|
-
const emailNormalized = normalizeEmail(profile.email);
|
|
146
|
-
const signedIn = (user) => ({ ok: true, user });
|
|
147
|
-
const account = await users.findAccountByKey(key);
|
|
148
|
-
if (account) {
|
|
149
|
-
const user = await users.findById(account.userId);
|
|
150
|
-
if (user) {
|
|
151
|
-
if (account.email !== profile.email) await users.updateAccount(account.id, { email: profile.email });
|
|
152
|
-
return signedIn(user);
|
|
153
|
-
}
|
|
154
|
-
await users.deleteAccount(account.id);
|
|
155
|
-
}
|
|
156
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
157
|
-
const existing = await users.findByEmail(emailNormalized);
|
|
158
|
-
if (existing) {
|
|
159
|
-
if (existing.emailVerified && await users.hasAccounts(existing.id)) {
|
|
160
|
-
const methods = await users.signInMethods(existing.id, await ctx.settings.methods());
|
|
161
|
-
const how = methods.map((method) => ({ password: "a password", totp: "an authenticator app", google: "Google" })[method]).join(" or ");
|
|
162
|
-
return { ok: false, error: "other_method", message: `This e-mail address signs in with ${how || "another method"}. Use that instead of Google.`, methods };
|
|
163
|
-
}
|
|
164
|
-
await users.verifyByProvider(existing);
|
|
165
|
-
if (!await users.linkAccount(existing.id, { key, email: profile.email })) {
|
|
166
|
-
const linked = await users.findAccountByKey(key);
|
|
167
|
-
const owner = linked && await users.findById(linked.userId);
|
|
168
|
-
if (!owner) return { ok: false, error: "temporarily_unavailable", message: "Please try again." };
|
|
169
|
-
return signedIn(owner);
|
|
170
|
-
}
|
|
171
|
-
return signedIn(existing);
|
|
172
|
-
}
|
|
173
|
-
if (attempt === 0) {
|
|
174
|
-
const check = await ctx.checkSignup({ email: profile.email, name: profile.name, locale, method: "google" });
|
|
175
|
-
if (!check.ok) {
|
|
176
|
-
return check.reason === "rejected" ? { ok: false, error: "signup_rejected", message: check.message } : { ok: false, error: "temporarily_unavailable", message: "Signing up is not possible right now. Please try again later." };
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
const user = await users.createUser({
|
|
180
|
-
email: profile.email,
|
|
181
|
-
emailNormalized,
|
|
182
|
-
name: profile.name ?? null,
|
|
183
|
-
emailVerified: true,
|
|
184
|
-
account: { key, email: profile.email }
|
|
185
|
-
});
|
|
186
|
-
if (user) {
|
|
187
|
-
await ctx.emit("user.created", { user: toMadauthUser(user), method: "google" });
|
|
188
|
-
return signedIn(user);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
return { ok: false, error: "temporarily_unavailable", message: "Please try again." };
|
|
192
|
-
};
|
|
193
610
|
const disabled = (c) => c.json({ error: "method_disabled", message: "Google sign-in is switched off." }, 403);
|
|
194
611
|
app.post("/auth/google/nonce", async (c) => {
|
|
195
612
|
if (!await ctx.enabled(c, "google")) return disabled(c);
|
|
196
613
|
const nonce = randomString();
|
|
197
614
|
const token = await signToken(await keys, issuer, NONCE_TYP, { nonce }, NONCE_TTL);
|
|
198
|
-
|
|
199
|
-
path:
|
|
615
|
+
setCookie2(c, NONCE_COOKIE, token, {
|
|
616
|
+
path: COOKIE_PATH2,
|
|
200
617
|
httpOnly: true,
|
|
201
618
|
secure,
|
|
202
619
|
sameSite: "Strict",
|
|
@@ -210,7 +627,7 @@ function googleRoutes(app, ctx, google) {
|
|
|
210
627
|
if (typeof body2.credential !== "string" || !body2.credential) {
|
|
211
628
|
return c.json({ error: "verification_failed", message: "credential is missing" }, 400);
|
|
212
629
|
}
|
|
213
|
-
const nonceClaims = await readToken(await keys, issuer, NONCE_TYP,
|
|
630
|
+
const nonceClaims = await readToken(await keys, issuer, NONCE_TYP, getCookie2(c, NONCE_COOKIE));
|
|
214
631
|
if (!nonceClaims) {
|
|
215
632
|
return c.json({ error: "verification_failed", message: "nonce cookie missing or expired" }, 401);
|
|
216
633
|
}
|
|
@@ -222,8 +639,8 @@ function googleRoutes(app, ctx, google) {
|
|
|
222
639
|
if (!result.ok) {
|
|
223
640
|
return c.json({ error: result.error, message: result.reason }, result.error === "email_unverified" ? 403 : 401);
|
|
224
641
|
}
|
|
225
|
-
|
|
226
|
-
const resolved = await
|
|
642
|
+
deleteCookie2(c, NONCE_COOKIE, { path: COOKIE_PATH2, secure });
|
|
643
|
+
const resolved = await resolveProviderUser(ctx, GOOGLE, toProfile(result.profile), localeOf(body2.locale));
|
|
227
644
|
if (!resolved.ok) {
|
|
228
645
|
if (resolved.error === "other_method") return c.json({ error: resolved.error, message: resolved.message, methods: resolved.methods }, 403);
|
|
229
646
|
return c.json({ error: resolved.error, message: resolved.message }, resolved.error === "signup_rejected" ? 403 : 503);
|
|
@@ -232,27 +649,13 @@ function googleRoutes(app, ctx, google) {
|
|
|
232
649
|
});
|
|
233
650
|
const requireCodeFlow = async (c, next) => {
|
|
234
651
|
if (!google.clientSecret) return c.json({ error: "not_found" }, 404);
|
|
235
|
-
if (!await ctx.enabled(c, "google")) return disabled(c);
|
|
236
652
|
await next();
|
|
237
653
|
};
|
|
238
654
|
app.get("/auth/google/start", requireCodeFlow, async (c) => {
|
|
239
655
|
const returnUrl = ctx.allowedUrl(c.req.query("return_to"));
|
|
240
656
|
if (!returnUrl) return c.text("return_to must be an absolute URL whose origin is in ALLOWED_ORIGINS", 400);
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
nonce: randomString(),
|
|
244
|
-
verifier: randomString(),
|
|
245
|
-
returnTo: returnUrl.href,
|
|
246
|
-
locale: localeOf(c.req.query("locale"))
|
|
247
|
-
};
|
|
248
|
-
const token = await signToken(await keys, issuer, OAUTH_TYP, { ...oauth }, OAUTH_TTL);
|
|
249
|
-
setCookie(c, OAUTH_COOKIE, token, {
|
|
250
|
-
path: "/auth/google",
|
|
251
|
-
httpOnly: true,
|
|
252
|
-
secure,
|
|
253
|
-
sameSite: "Lax",
|
|
254
|
-
maxAge: OAUTH_TTL
|
|
255
|
-
});
|
|
657
|
+
if (!await ctx.enabled(c, "google")) return redirectBack(c, returnUrl.href, "google")("method_disabled");
|
|
658
|
+
const oauth = await startOAuth(c, ctx, COOKIE_PATH2, returnUrl);
|
|
256
659
|
const url = new URL(GOOGLE_AUTH_ENDPOINT);
|
|
257
660
|
url.search = new URLSearchParams({
|
|
258
661
|
client_id: google.clientId,
|
|
@@ -267,15 +670,10 @@ function googleRoutes(app, ctx, google) {
|
|
|
267
670
|
return c.redirect(url.href, 302);
|
|
268
671
|
});
|
|
269
672
|
app.get("/auth/google/callback", requireCodeFlow, async (c) => {
|
|
270
|
-
const oauth = await
|
|
673
|
+
const oauth = await takeOAuthState(c, ctx, COOKIE_PATH2);
|
|
271
674
|
if (!oauth) return c.text("Sign-in expired or was started in another browser. Please try again.", 400);
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
const url = new URL(oauth.returnTo);
|
|
275
|
-
if (error2) url.hash = `${REDIRECT_ERROR_PARAM}=${error2}`;
|
|
276
|
-
if (next) url.hash = `${REDIRECT_NEXT_PARAM}=${next}`;
|
|
277
|
-
return c.redirect(url.href, 302);
|
|
278
|
-
};
|
|
675
|
+
const back = redirectBack(c, oauth.returnTo, "google");
|
|
676
|
+
if (!await ctx.enabled(c, "google")) return back("method_disabled");
|
|
279
677
|
if (c.req.query("error")) return back(c.req.query("error") === "access_denied" ? "cancelled" : "verification_failed");
|
|
280
678
|
const code = c.req.query("code");
|
|
281
679
|
if (!code || c.req.query("state") !== oauth.state) return back("verification_failed");
|
|
@@ -305,111 +703,15 @@ function googleRoutes(app, ctx, google) {
|
|
|
305
703
|
keys: config.jwksResolver
|
|
306
704
|
});
|
|
307
705
|
if (!result.ok) return back(result.error);
|
|
308
|
-
const resolved = await
|
|
706
|
+
const resolved = await resolveProviderUser(ctx, GOOGLE, toProfile(result.profile), oauth.locale);
|
|
309
707
|
if (!resolved.ok) return back(resolved.error);
|
|
310
708
|
const step = await ctx.secondStep(c, resolved.user, ["google"], "google", result.profile);
|
|
311
709
|
if ("disabled" in step) return back("method_disabled");
|
|
312
|
-
if ("next" in step) return back(void 0, step.next);
|
|
710
|
+
if ("next" in step) return back(void 0, step.next, step.factors);
|
|
313
711
|
return back();
|
|
314
712
|
});
|
|
315
713
|
}
|
|
316
714
|
|
|
317
|
-
// src/app.ts
|
|
318
|
-
import { Hono } from "hono";
|
|
319
|
-
import { deleteCookie as deleteCookie3, getCookie as getCookie3, setCookie as setCookie3 } from "hono/cookie";
|
|
320
|
-
import { cors } from "hono/cors";
|
|
321
|
-
|
|
322
|
-
// src/routes/emails.ts
|
|
323
|
-
var MAIL_INTERVAL_MS = 60 * 1e3;
|
|
324
|
-
var VERIFY_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
325
|
-
var RESET_TTL_MS = 30 * 60 * 1e3;
|
|
326
|
-
var VERIFY_LINK_PARAM = "madauth_verify";
|
|
327
|
-
var RESET_LINK_PARAM = "madauth_reset";
|
|
328
|
-
function createEmails(ctx, users, webhook) {
|
|
329
|
-
const sendEmail = async (user, type, data) => {
|
|
330
|
-
if (Date.now() - user.lastMailAt < MAIL_INTERVAL_MS) return true;
|
|
331
|
-
const result = await webhook.call(type, { to: user.email, user: { id: user.id, name: user.name ?? void 0 }, ...data }, WEBHOOK_TIMEOUT_MS);
|
|
332
|
-
if (!result.ok) {
|
|
333
|
-
console.error(`[madauth] Webhook "${type}" for ${user.email} failed: ${result.reason}`);
|
|
334
|
-
return false;
|
|
335
|
-
}
|
|
336
|
-
await users.updateUser(user.id, { lastMailAt: Date.now() });
|
|
337
|
-
return true;
|
|
338
|
-
};
|
|
339
|
-
return {
|
|
340
|
-
sendEmail,
|
|
341
|
-
async sendLinkEmail(user, purpose, target, locale) {
|
|
342
|
-
if (Date.now() - user.lastMailAt < MAIL_INTERVAL_MS) return true;
|
|
343
|
-
const ttl = purpose === "verify" ? VERIFY_TTL_MS : RESET_TTL_MS;
|
|
344
|
-
const { token, code } = await users.issueVerification(user.id, purpose, ttl);
|
|
345
|
-
const link = new URL(target);
|
|
346
|
-
link.hash = `${purpose === "verify" ? VERIFY_LINK_PARAM : RESET_LINK_PARAM}=${token}`;
|
|
347
|
-
return sendEmail(user, purpose === "verify" ? "email.verify" : "email.reset", {
|
|
348
|
-
link: link.href,
|
|
349
|
-
code,
|
|
350
|
-
expiresAt: Date.now() + ttl,
|
|
351
|
-
site: target.host,
|
|
352
|
-
locale
|
|
353
|
-
});
|
|
354
|
-
},
|
|
355
|
-
async sendAccountEmail(user, type, target, locale) {
|
|
356
|
-
if (!webhook.wants(type)) return true;
|
|
357
|
-
const methods = await users.signInMethods(user.id, await ctx.settings.methods());
|
|
358
|
-
return sendEmail(user, type, { link: target.href, site: target.host, locale, methods });
|
|
359
|
-
},
|
|
360
|
-
async consume(data, purpose) {
|
|
361
|
-
if (typeof data.token === "string" && data.token) {
|
|
362
|
-
return { userId: await users.consumeLinkToken(data.token, purpose), via: "link" };
|
|
363
|
-
}
|
|
364
|
-
if (isValidEmail(data.email) && typeof data.code === "string") {
|
|
365
|
-
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
366
|
-
if (!user) return { userId: null, via: "code" };
|
|
367
|
-
return { ...await users.consumeCode(user, purpose, data.code.replace(/\s/g, "")), via: "code" };
|
|
368
|
-
}
|
|
369
|
-
return { userId: null, via: "code" };
|
|
370
|
-
},
|
|
371
|
-
invalidVerification(c, { via, locked }) {
|
|
372
|
-
if (locked) {
|
|
373
|
-
return error(c, 429, "codes_locked", "Too many wrong codes. Please ask for a new e-mail and use the link in it.");
|
|
374
|
-
}
|
|
375
|
-
return via === "link" ? error(c, 400, "link_invalid", "The link is invalid, expired or was already used.") : error(c, 400, "code_invalid", "The code is wrong or expired.");
|
|
376
|
-
}
|
|
377
|
-
};
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
// src/routes/email.ts
|
|
381
|
-
function emailRoutes(app, ctx, users, webhook) {
|
|
382
|
-
const emails = createEmails(ctx, users, webhook);
|
|
383
|
-
const signUpMethod = async (userId) => await users.passwordAccount(userId) ? "password" : "totp";
|
|
384
|
-
app.post("/auth/email/send-verification", async (c) => {
|
|
385
|
-
const data = await body(c);
|
|
386
|
-
const target = redirectTarget(ctx, c, data.redirectTo);
|
|
387
|
-
if (target instanceof Response) return target;
|
|
388
|
-
if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
|
|
389
|
-
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
390
|
-
if (user && !user.emailVerified && await ctx.enabled(c, await signUpMethod(user.id))) {
|
|
391
|
-
if (!await emails.sendLinkEmail(user, "verify", target, localeOf(data.locale))) return unavailable(c);
|
|
392
|
-
}
|
|
393
|
-
return accepted(c);
|
|
394
|
-
});
|
|
395
|
-
const disabled = (c) => error(c, 403, "method_disabled", "This way of signing in is switched off.");
|
|
396
|
-
app.post("/auth/email/verify", async (c) => {
|
|
397
|
-
const data = await body(c);
|
|
398
|
-
const waiting = typeof data.token === "string" && data.token ? await users.peekLinkToken(data.token, "verify") : isValidEmail(data.email) ? (await users.findByEmail(normalizeEmail(data.email)))?.id ?? null : null;
|
|
399
|
-
if (waiting && !await ctx.enabled(c, await signUpMethod(waiting))) return disabled(c);
|
|
400
|
-
const consumed = await emails.consume(data, "verify");
|
|
401
|
-
const { userId, via } = consumed;
|
|
402
|
-
const user = userId ? await users.findById(userId) : null;
|
|
403
|
-
if (!user) return emails.invalidVerification(c, consumed);
|
|
404
|
-
const method = await signUpMethod(user.id);
|
|
405
|
-
if (!await ctx.enabled(c, method)) return disabled(c);
|
|
406
|
-
await users.updateUser(user.id, { emailVerified: true, wrongCodes: 0 });
|
|
407
|
-
const verified = { ...user, emailVerified: true, wrongCodes: 0 };
|
|
408
|
-
await ctx.emit("email.verified", { user: toMadauthUser(verified), via });
|
|
409
|
-
return signInAnswer(c, await ctx.secondStep(c, verified, method === "password" ? ["pwd"] : [], method));
|
|
410
|
-
});
|
|
411
|
-
}
|
|
412
|
-
|
|
413
715
|
// src/routes/lockout.ts
|
|
414
716
|
var FREE_ATTEMPTS = 5;
|
|
415
717
|
var MAX_LOCK_MS = 15 * 60 * 1e3;
|
|
@@ -428,8 +730,16 @@ async function countSignInAttempt(c, users, account, now = Date.now()) {
|
|
|
428
730
|
}
|
|
429
731
|
|
|
430
732
|
// src/routes/password.ts
|
|
431
|
-
function
|
|
432
|
-
const {
|
|
733
|
+
function weakPassword(c, problem) {
|
|
734
|
+
const { message, ...details } = problem;
|
|
735
|
+
return c.json({ error: "weak_password", message, ...details }, 400);
|
|
736
|
+
}
|
|
737
|
+
function passwordRoutes(app, ctx, users, webhook) {
|
|
738
|
+
const refused = async (c, password, userInputs) => {
|
|
739
|
+
const words = userInputs.filter((word) => !!word);
|
|
740
|
+
const problem = await checkPasswordPolicy(password, await ctx.settings.passwordRules(), words);
|
|
741
|
+
return problem ? weakPassword(c, problem) : null;
|
|
742
|
+
};
|
|
433
743
|
const emails = createEmails(ctx, users, webhook);
|
|
434
744
|
app.use("/auth/password/*", async (c, next) => {
|
|
435
745
|
if (!await ctx.enabled(c, "password")) return error(c, 403, "method_disabled", "E-mail & password sign-in is switched off.");
|
|
@@ -446,8 +756,8 @@ function passwordRoutes(app, ctx, password, users, webhook) {
|
|
|
446
756
|
await verifyAgainstDummy(pw);
|
|
447
757
|
return wrong();
|
|
448
758
|
}
|
|
449
|
-
const
|
|
450
|
-
if (
|
|
759
|
+
const refused2 = await countSignInAttempt(c, users, account);
|
|
760
|
+
if (refused2) return refused2;
|
|
451
761
|
const { ok, needsRehash } = await verifyPassword(pw, account.secret);
|
|
452
762
|
if (!ok) return wrong();
|
|
453
763
|
await users.updateAccount(account.id, {
|
|
@@ -465,23 +775,29 @@ function passwordRoutes(app, ctx, password, users, webhook) {
|
|
|
465
775
|
const target = redirectTarget(ctx, c, data.redirectTo);
|
|
466
776
|
if (target instanceof Response) return target;
|
|
467
777
|
if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
|
|
468
|
-
const policy = checkPasswordPolicy(data.password, minLength);
|
|
469
|
-
if (policy) return error(c, 400, "weak_password", policy);
|
|
470
778
|
const name = nameOf(data.name);
|
|
471
779
|
const email = data.email.trim();
|
|
780
|
+
const weak = await refused(c, data.password, [email, name]);
|
|
781
|
+
if (weak) return weak;
|
|
472
782
|
const locale = localeOf(data.locale);
|
|
473
783
|
const check = await ctx.checkSignup({ email, name: name ?? void 0, locale, method: "password" });
|
|
474
784
|
if (!check.ok) return check.reason === "rejected" ? error(c, 403, "signup_rejected", check.message) : unavailable(c);
|
|
475
785
|
const passwordHash = await hashPassword(data.password);
|
|
476
786
|
const existing = await users.findByEmail(normalizeEmail(email));
|
|
477
|
-
if (existing?.emailVerified && await users.
|
|
478
|
-
return await emails.sendAccountEmail(existing, "email.
|
|
787
|
+
if (existing?.emailVerified && await users.hasSignInMethod(existing.id)) {
|
|
788
|
+
return await emails.sendAccountEmail(existing, "email.send_already_registered", target, locale) ? accepted(c) : unavailable(c);
|
|
479
789
|
}
|
|
480
790
|
if (existing) {
|
|
481
791
|
if (Date.now() - existing.lastMailAt < MAIL_INTERVAL_MS) return accepted(c);
|
|
792
|
+
if (!existing.emailVerified) await users.dropUnprovenAccounts(existing.id, "password");
|
|
793
|
+
if (existing.emailVerified) await users.updateUser(existing.id, { emailVerified: false });
|
|
482
794
|
const account = await users.passwordAccount(existing.id);
|
|
483
795
|
if (account) await users.updateAccount(account.id, { secret: passwordHash, failedAttempts: 0, lockedUntil: 0 });
|
|
484
|
-
else await users.linkAccount(existing.id, { key: passwordAccountKey(existing.id), secret: passwordHash })
|
|
796
|
+
else if (!await users.linkAccount(existing.id, { key: passwordAccountKey(existing.id), secret: passwordHash })) {
|
|
797
|
+
if (existing.emailVerified && !await users.passwordAccount(existing.id)) await users.updateUser(existing.id, { emailVerified: true });
|
|
798
|
+
return accepted(c);
|
|
799
|
+
}
|
|
800
|
+
await users.reopenSignUp(existing);
|
|
485
801
|
await users.updateUser(existing.id, { name });
|
|
486
802
|
return await emails.sendLinkEmail({ ...existing, name }, "verify", target, locale) ? accepted(c) : unavailable(c);
|
|
487
803
|
}
|
|
@@ -493,7 +809,6 @@ function passwordRoutes(app, ctx, password, users, webhook) {
|
|
|
493
809
|
account: (userId) => ({ key: passwordAccountKey(userId), secret: passwordHash })
|
|
494
810
|
});
|
|
495
811
|
if (!user) return accepted(c);
|
|
496
|
-
await ctx.emit("user.created", { user: toMadauthUser(user), method: "password" });
|
|
497
812
|
return await emails.sendLinkEmail(user, "verify", target, locale) ? accepted(c) : unavailable(c);
|
|
498
813
|
});
|
|
499
814
|
app.post("/auth/password/send-reset", async (c) => {
|
|
@@ -506,14 +821,14 @@ function passwordRoutes(app, ctx, password, users, webhook) {
|
|
|
506
821
|
if (user && await users.passwordAccount(user.id)) {
|
|
507
822
|
if (!await emails.sendLinkEmail(user, "reset", target, locale)) return unavailable(c);
|
|
508
823
|
} else if (user) {
|
|
509
|
-
if (!await emails.sendAccountEmail(user, "email.
|
|
824
|
+
if (!await emails.sendAccountEmail(user, "email.send_password_reset_no_password", target, locale)) return unavailable(c);
|
|
510
825
|
}
|
|
511
826
|
return accepted(c);
|
|
512
827
|
});
|
|
513
828
|
app.post("/auth/password/reset", async (c) => {
|
|
514
829
|
const data = await body(c);
|
|
515
|
-
const
|
|
516
|
-
if (
|
|
830
|
+
const weak = await refused(c, data.password, [isValidEmail(data.email) ? data.email.trim() : null]);
|
|
831
|
+
if (weak) return weak;
|
|
517
832
|
const consumed = await emails.consume(data, "reset");
|
|
518
833
|
const user = consumed.userId ? await users.findById(consumed.userId) : null;
|
|
519
834
|
const account = user ? await users.passwordAccount(user.id) : null;
|
|
@@ -523,17 +838,216 @@ function passwordRoutes(app, ctx, password, users, webhook) {
|
|
|
523
838
|
const sessionVersion = user.sessionVersion + 1;
|
|
524
839
|
await users.updateUser(user.id, { emailVerified: true, sessionVersion, wrongCodes: 0 });
|
|
525
840
|
await users.clearVerifications(user.id);
|
|
526
|
-
const reset = { ...user, emailVerified: true, sessionVersion, wrongCodes: 0 };
|
|
527
|
-
await ctx.emit("
|
|
841
|
+
const reset = { ...user.emailVerified ? user : await users.reopenSignUp(user), emailVerified: true, sessionVersion, wrongCodes: 0 };
|
|
842
|
+
await ctx.emit("user.password_reset", { user: toMadauthUser(reset) });
|
|
528
843
|
return signInAnswer(c, await ctx.secondStep(c, reset, ["pwd"], "password"));
|
|
529
844
|
});
|
|
530
845
|
}
|
|
531
846
|
|
|
847
|
+
// src/routes/challenge.ts
|
|
848
|
+
import { deleteCookie as deleteCookie3, getCookie as getCookie3, setCookie as setCookie3 } from "hono/cookie";
|
|
849
|
+
var CHALLENGE_COOKIE = "madauth_challenge";
|
|
850
|
+
var CHALLENGE_TYP = "madauth-challenge+jwt";
|
|
851
|
+
var CHALLENGE_TTL = 10 * 60;
|
|
852
|
+
var COOKIE_PATH3 = "/auth";
|
|
853
|
+
var LEGACY_COOKIE_PATH = "/auth/totp";
|
|
854
|
+
var cookieOptions = (secure) => ({ path: COOKIE_PATH3, httpOnly: true, secure, sameSite: "Strict" });
|
|
855
|
+
async function setChallenge(c, ctx, challenge) {
|
|
856
|
+
const token = await signToken(await ctx.keys, ctx.config.issuer, CHALLENGE_TYP, { ...challenge }, CHALLENGE_TTL);
|
|
857
|
+
setCookie3(c, CHALLENGE_COOKIE, token, { ...cookieOptions(ctx.secure), maxAge: CHALLENGE_TTL });
|
|
858
|
+
}
|
|
859
|
+
function clearChallenge(c, secure) {
|
|
860
|
+
deleteCookie3(c, CHALLENGE_COOKIE, cookieOptions(secure));
|
|
861
|
+
deleteCookie3(c, CHALLENGE_COOKIE, { ...cookieOptions(secure), path: LEGACY_COOKIE_PATH });
|
|
862
|
+
}
|
|
863
|
+
async function readChallenge(c, ctx, step) {
|
|
864
|
+
const claims = await readToken(await ctx.keys, ctx.config.issuer, CHALLENGE_TYP, getCookie3(c, CHALLENGE_COOKIE));
|
|
865
|
+
if (!claims || typeof claims.sub !== "string" || !Array.isArray(claims.amr)) return null;
|
|
866
|
+
const factors = Array.isArray(claims.factors) ? claims.factors : [];
|
|
867
|
+
const fits = step === "totp-setup" ? claims.next === "totp-setup" : factors.includes(step);
|
|
868
|
+
return fits ? claims : null;
|
|
869
|
+
}
|
|
870
|
+
var methodStillOn = async (c, ctx, challenge) => isOn(challenge.method, ctx.configured, await ctx.methods(c));
|
|
871
|
+
var methodOff = (c) => error(c, 403, "method_disabled", "This way of signing in is switched off.");
|
|
872
|
+
async function pendingOrSignedIn(c, ctx) {
|
|
873
|
+
const challenge = await readChallenge(c, ctx, "totp-setup");
|
|
874
|
+
const waiting = challenge ? await ctx.users.findById(challenge.sub) : null;
|
|
875
|
+
if (challenge && waiting && waiting.sessionVersion === challenge.sv) return { user: waiting, challenge };
|
|
876
|
+
const session = await ctx.currentUser(c);
|
|
877
|
+
const user = session ? await ctx.users.findById(session.id) : null;
|
|
878
|
+
return user ? { user, challenge: null } : null;
|
|
879
|
+
}
|
|
880
|
+
async function finishSignIn(c, ctx, user, challenge, amr, via) {
|
|
881
|
+
const result = await ctx.startSession(c, toMadauthUser(user, challenge.profile), [...challenge.amr, amr], { method: challenge.method, signedUpAt: user.signedUpAt }, { sv: user.sessionVersion });
|
|
882
|
+
await ctx.emit("user.signed_in", { user: result, method: challenge.method, ...challenge.amr.length ? { secondFactor: via } : {} });
|
|
883
|
+
return result;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// src/routes/sms.ts
|
|
887
|
+
import { deleteCookie as deleteCookie4, getCookie as getCookie4, setCookie as setCookie4 } from "hono/cookie";
|
|
888
|
+
var SMS_TTL_MS = 10 * 60 * 1e3;
|
|
889
|
+
var SETUP_COOKIE = "madauth_sms_setup";
|
|
890
|
+
var SETUP_TYP = "madauth-sms-setup+jwt";
|
|
891
|
+
var SETUP_TTL = 10 * 60;
|
|
892
|
+
var ROUTE_PATH = "/auth/sms";
|
|
893
|
+
function normalizePhone(value) {
|
|
894
|
+
if (typeof value !== "string") return null;
|
|
895
|
+
const digits = value.replace(/[\s().-]/g, "");
|
|
896
|
+
return /^\+[1-9]\d{6,14}$/.test(digits) ? digits : null;
|
|
897
|
+
}
|
|
898
|
+
function maskPhone(phone) {
|
|
899
|
+
return `${phone.slice(0, 3)} ${"\u2022".repeat(Math.max(0, phone.length - 5))} ${phone.slice(-2)}`;
|
|
900
|
+
}
|
|
901
|
+
function smsRoutes(app, ctx, users, webhook) {
|
|
902
|
+
const { config, secure } = ctx;
|
|
903
|
+
const cookie = { path: ROUTE_PATH, httpOnly: true, secure, sameSite: "Strict" };
|
|
904
|
+
app.use(`${ROUTE_PATH}/*`, async (c, next) => {
|
|
905
|
+
if (!isOn("sms", ctx.configured, await ctx.methods(c))) return error(c, 403, "method_disabled", "SMS codes are switched off.");
|
|
906
|
+
await next();
|
|
907
|
+
});
|
|
908
|
+
const expired = (c) => error(c, 401, "challenge_expired", "The sign-in has expired. Please sign in again.");
|
|
909
|
+
const tooSoon = (c) => error(c, 429, "too_soon", "A code was sent to your phone less than a minute ago: enter that one, or wait a minute for a new one.");
|
|
910
|
+
const sendCode = async (c, user, phone, purpose, locale) => {
|
|
911
|
+
const { code } = await users.issueVerification(user.id, "sms", SMS_TTL_MS);
|
|
912
|
+
const data = { to: phone, code, purpose, expiresAt: Date.now() + SMS_TTL_MS, site: originHost(ctx, c), locale, user: { id: user.id, name: user.name ?? void 0 } };
|
|
913
|
+
const result = await webhook.call("sms.send_code", data, WEBHOOK_TIMEOUT_MS);
|
|
914
|
+
if (!result.ok) {
|
|
915
|
+
console.error(`[madauth] Webhook "sms.send_code" for user ${user.id} failed: ${result.reason}`);
|
|
916
|
+
return false;
|
|
917
|
+
}
|
|
918
|
+
await users.updateUser(user.id, { lastSmsAt: Date.now() });
|
|
919
|
+
return true;
|
|
920
|
+
};
|
|
921
|
+
const sentRecently = (user) => Date.now() - (user.lastSmsAt ?? 0) < MAIL_INTERVAL_MS;
|
|
922
|
+
const checkCode2 = async (c, user, account, data) => {
|
|
923
|
+
if (account) {
|
|
924
|
+
const refused = await countSignInAttempt(c, users, account);
|
|
925
|
+
if (refused) return refused;
|
|
926
|
+
}
|
|
927
|
+
const code = typeof data.code === "string" ? data.code.replace(/\s/g, "") : "";
|
|
928
|
+
const { userId } = code ? await users.consumeCode(user, "sms", code) : { userId: null };
|
|
929
|
+
if (!userId) return error(c, 401, "code_invalid", "The code is wrong or has expired.");
|
|
930
|
+
if (account) await users.updateAccount(account.id, { failedAttempts: 0, lockedUntil: 0 });
|
|
931
|
+
return null;
|
|
932
|
+
};
|
|
933
|
+
const whoSetsUp = async (c) => {
|
|
934
|
+
const who = await pendingOrSignedIn(c, ctx);
|
|
935
|
+
if (!who || who.challenge && !who.challenge.amr.length) return { answer: noSession(c) };
|
|
936
|
+
if (who.challenge && !await methodStillOn(c, ctx, who.challenge)) return { answer: methodOff(c) };
|
|
937
|
+
return who;
|
|
938
|
+
};
|
|
939
|
+
app.post(`${ROUTE_PATH}/setup`, async (c) => {
|
|
940
|
+
const who = await whoSetsUp(c);
|
|
941
|
+
if ("answer" in who) return who.answer;
|
|
942
|
+
const { user } = who;
|
|
943
|
+
const data = await body(c);
|
|
944
|
+
const phone = normalizePhone(data.phone);
|
|
945
|
+
if (!phone) return error(c, 400, "invalid_phone", "Enter a mobile number with the country code, e.g. +41 79 123 45 67.");
|
|
946
|
+
const existing = await users.smsAccount(user.id);
|
|
947
|
+
let approved;
|
|
948
|
+
let justApproved = false;
|
|
949
|
+
if (existing) {
|
|
950
|
+
const previous = await readToken(await ctx.keys, config.issuer, SETUP_TYP, getCookie4(c, SETUP_COOKIE));
|
|
951
|
+
const holds = previous?.sub === user.id && typeof previous.approved === "number" && Date.now() - previous.approved < SETUP_TTL * 1e3;
|
|
952
|
+
if (holds) {
|
|
953
|
+
approved = previous.approved;
|
|
954
|
+
} else {
|
|
955
|
+
if (data.code === void 0) return error(c, 401, "code_required", "Enter the code we sent to your current number to replace it.");
|
|
956
|
+
const wrong = await checkCode2(c, user, existing, data);
|
|
957
|
+
if (wrong) return wrong;
|
|
958
|
+
approved = Date.now();
|
|
959
|
+
justApproved = true;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
if (!justApproved && sentRecently(user)) return tooSoon(c);
|
|
963
|
+
if (!await sendCode(c, user, phone, "setup", localeOf(data.locale))) return smsUnavailable(c);
|
|
964
|
+
const token = await signToken(await ctx.keys, config.issuer, SETUP_TYP, { sub: user.id, phone, ...approved ? { approved } : {} }, SETUP_TTL);
|
|
965
|
+
setCookie4(c, SETUP_COOKIE, token, { ...cookie, maxAge: SETUP_TTL });
|
|
966
|
+
return c.json({ phone: maskPhone(phone) }, 202);
|
|
967
|
+
});
|
|
968
|
+
app.post(`${ROUTE_PATH}/confirm`, async (c) => {
|
|
969
|
+
const who = await whoSetsUp(c);
|
|
970
|
+
if ("answer" in who) return who.answer;
|
|
971
|
+
const { user, challenge } = who;
|
|
972
|
+
const setup = await readToken(await ctx.keys, config.issuer, SETUP_TYP, getCookie4(c, SETUP_COOKIE));
|
|
973
|
+
if (!setup || setup.sub !== user.id || typeof setup.phone !== "string") {
|
|
974
|
+
return error(c, 400, "setup_expired", "The setup has expired or was started in another browser. Please start again.");
|
|
975
|
+
}
|
|
976
|
+
const existing = await users.smsAccount(user.id);
|
|
977
|
+
if (existing && typeof setup.approved !== "number") {
|
|
978
|
+
deleteCookie4(c, SETUP_COOKIE, cookie);
|
|
979
|
+
return error(c, 400, "setup_expired", "Your phone number was changed elsewhere meanwhile. Please start again.");
|
|
980
|
+
}
|
|
981
|
+
const wrong = await checkCode2(c, user, existing, await body(c));
|
|
982
|
+
if (wrong) return wrong;
|
|
983
|
+
const written = existing ? await users.updateAccount(existing.id, { phone: setup.phone, failedAttempts: 0, lockedUntil: 0 }) : !!await users.linkAccount(user.id, { key: smsAccountKey(user.id), phone: setup.phone });
|
|
984
|
+
deleteCookie4(c, SETUP_COOKIE, cookie);
|
|
985
|
+
if (!written) return error(c, 400, "setup_expired", "Your phone number was changed elsewhere meanwhile. Please start again.");
|
|
986
|
+
await ctx.emit("user.second_factor_enabled", { user: toMadauthUser(user), method: "sms", phone: setup.phone });
|
|
987
|
+
if (!challenge) return c.json({});
|
|
988
|
+
return c.json({ user: await finishSignIn(c, ctx, user, challenge, "sms", "sms") });
|
|
989
|
+
});
|
|
990
|
+
app.post(`${ROUTE_PATH}/send`, async (c) => {
|
|
991
|
+
const data = await body(c);
|
|
992
|
+
const challenge = await readChallenge(c, ctx, "sms");
|
|
993
|
+
let user;
|
|
994
|
+
if (challenge) {
|
|
995
|
+
if (!await methodStillOn(c, ctx, challenge)) return methodOff(c);
|
|
996
|
+
const waiting = await users.findById(challenge.sub);
|
|
997
|
+
user = waiting && waiting.sessionVersion === challenge.sv ? waiting : null;
|
|
998
|
+
if (!user) return expired(c);
|
|
999
|
+
} else {
|
|
1000
|
+
const session = await ctx.currentUser(c);
|
|
1001
|
+
user = session ? await users.findById(session.id) : null;
|
|
1002
|
+
if (!user) return noSession(c);
|
|
1003
|
+
}
|
|
1004
|
+
const account = await users.smsAccount(user.id);
|
|
1005
|
+
if (!account?.phone) return challenge ? expired(c) : error(c, 400, "no_phone", "No phone number is set up.");
|
|
1006
|
+
if (sentRecently(user)) return tooSoon(c);
|
|
1007
|
+
if (!await sendCode(c, user, account.phone, challenge ? "signin" : "approve", localeOf(data.locale))) return smsUnavailable(c);
|
|
1008
|
+
return c.json({ phone: maskPhone(account.phone) }, 202);
|
|
1009
|
+
});
|
|
1010
|
+
app.post(`${ROUTE_PATH}/verify`, async (c) => {
|
|
1011
|
+
const challenge = await readChallenge(c, ctx, "sms");
|
|
1012
|
+
if (!challenge) return expired(c);
|
|
1013
|
+
if (!await methodStillOn(c, ctx, challenge)) return methodOff(c);
|
|
1014
|
+
const user = await users.findById(challenge.sub);
|
|
1015
|
+
const account = user && user.sessionVersion === challenge.sv ? await users.smsAccount(user.id) : null;
|
|
1016
|
+
if (!user || !account) return expired(c);
|
|
1017
|
+
const wrong = await checkCode2(c, user, account, await body(c));
|
|
1018
|
+
if (wrong) return wrong;
|
|
1019
|
+
return c.json({ user: await finishSignIn(c, ctx, user, challenge, "sms", "sms") });
|
|
1020
|
+
});
|
|
1021
|
+
app.post(`${ROUTE_PATH}/remove`, async (c) => {
|
|
1022
|
+
const session = await ctx.currentUser(c);
|
|
1023
|
+
const user = session ? await users.findById(session.id) : null;
|
|
1024
|
+
if (!user) return noSession(c);
|
|
1025
|
+
const account = await users.smsAccount(user.id);
|
|
1026
|
+
if (!account) return error(c, 400, "no_phone", "No phone number is set up.");
|
|
1027
|
+
const methods = await ctx.methods(c);
|
|
1028
|
+
const required = METHODS_WITH_SECOND_FACTOR.some((method) => isOn(method, ctx.configured, methods) && policyOf(method, methods) === "required");
|
|
1029
|
+
if (required && !await users.totpAccount(user.id)) {
|
|
1030
|
+
return error(c, 403, "required_by_policy", "Signing in requires a second factor, so the phone number can't be removed without an authenticator app.");
|
|
1031
|
+
}
|
|
1032
|
+
const wrong = await checkCode2(c, user, account, await body(c));
|
|
1033
|
+
if (wrong) return wrong;
|
|
1034
|
+
await users.removeSms(user.id);
|
|
1035
|
+
await ctx.emit("user.second_factor_disabled", { user: toMadauthUser(user), method: "sms" });
|
|
1036
|
+
return c.json({});
|
|
1037
|
+
});
|
|
1038
|
+
app.get(`${ROUTE_PATH}/status`, async (c) => {
|
|
1039
|
+
const session = await ctx.currentUser(c);
|
|
1040
|
+
if (!session) return noSession(c);
|
|
1041
|
+
const account = await users.smsAccount(session.id);
|
|
1042
|
+
return c.json({ enabled: !!account?.phone, phone: account?.phone ? maskPhone(account.phone) : null });
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
|
|
532
1046
|
// src/routes/totp.ts
|
|
533
|
-
import { deleteCookie as
|
|
1047
|
+
import { deleteCookie as deleteCookie5, getCookie as getCookie5, setCookie as setCookie5 } from "hono/cookie";
|
|
534
1048
|
|
|
535
1049
|
// src/totp.ts
|
|
536
|
-
import {
|
|
1050
|
+
import { createHmac, randomBytes as randomBytes2, randomInt } from "crypto";
|
|
537
1051
|
var TOTP_STEP_SECONDS = 30;
|
|
538
1052
|
var TOTP_DIGITS = 6;
|
|
539
1053
|
var TOTP_WINDOW = 1;
|
|
@@ -542,9 +1056,6 @@ var SECRET_BYTES = 20;
|
|
|
542
1056
|
var BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
543
1057
|
var RECOVERY_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
|
|
544
1058
|
var RECOVERY_CODE_LENGTH = 10;
|
|
545
|
-
var ENCRYPTION_VERSION = "v1";
|
|
546
|
-
var IV_BYTES = 12;
|
|
547
|
-
var TAG_BYTES = 16;
|
|
548
1059
|
function base32Encode(bytes) {
|
|
549
1060
|
let bits = 0;
|
|
550
1061
|
let value = 0;
|
|
@@ -585,7 +1096,7 @@ function verifyTotp(secret, code, nowMs, window = TOTP_WINDOW) {
|
|
|
585
1096
|
return found;
|
|
586
1097
|
}
|
|
587
1098
|
function generateTotpSecret() {
|
|
588
|
-
return
|
|
1099
|
+
return randomBytes2(SECRET_BYTES);
|
|
589
1100
|
}
|
|
590
1101
|
function otpauthUri(options) {
|
|
591
1102
|
const issuer = encodeURIComponent(options.issuer);
|
|
@@ -594,28 +1105,7 @@ function otpauthUri(options) {
|
|
|
594
1105
|
return `otpauth://totp/${issuer}:${account}?secret=${secret}&issuer=${issuer}&algorithm=SHA1&digits=${TOTP_DIGITS}&period=${TOTP_STEP_SECONDS}`;
|
|
595
1106
|
}
|
|
596
1107
|
function deriveTotpKey(signingKeyD) {
|
|
597
|
-
return
|
|
598
|
-
}
|
|
599
|
-
function encryptSecret(key, secret) {
|
|
600
|
-
const iv = randomBytes(IV_BYTES);
|
|
601
|
-
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
602
|
-
const encrypted = Buffer.concat([cipher.update(secret), cipher.final()]);
|
|
603
|
-
const parts = [iv, encrypted, cipher.getAuthTag()].map((part) => part.toString("base64url"));
|
|
604
|
-
return [ENCRYPTION_VERSION, ...parts].join(".");
|
|
605
|
-
}
|
|
606
|
-
function decryptSecret(key, stored) {
|
|
607
|
-
const [version, iv, encrypted, tag, ...rest] = stored.split(".");
|
|
608
|
-
if (version !== ENCRYPTION_VERSION || !iv || !encrypted || !tag || rest.length) return null;
|
|
609
|
-
const ivBytes = Buffer.from(iv, "base64url");
|
|
610
|
-
const tagBytes = Buffer.from(tag, "base64url");
|
|
611
|
-
if (ivBytes.length !== IV_BYTES || tagBytes.length !== TAG_BYTES) return null;
|
|
612
|
-
try {
|
|
613
|
-
const decipher = createDecipheriv("aes-256-gcm", key, ivBytes);
|
|
614
|
-
decipher.setAuthTag(tagBytes);
|
|
615
|
-
return Buffer.concat([decipher.update(Buffer.from(encrypted, "base64url")), decipher.final()]);
|
|
616
|
-
} catch {
|
|
617
|
-
return null;
|
|
618
|
-
}
|
|
1108
|
+
return deriveKey(signingKeyD, "madauth totp secret");
|
|
619
1109
|
}
|
|
620
1110
|
function generateRecoveryCodes() {
|
|
621
1111
|
const codes = /* @__PURE__ */ new Set();
|
|
@@ -636,25 +1126,11 @@ function hashRecoveryCode(codeKey, code) {
|
|
|
636
1126
|
}
|
|
637
1127
|
|
|
638
1128
|
// src/routes/totp.ts
|
|
639
|
-
var
|
|
640
|
-
var
|
|
641
|
-
var
|
|
642
|
-
var
|
|
643
|
-
var
|
|
644
|
-
var SETUP_TTL = 10 * 60;
|
|
645
|
-
var COOKIE_PATH = "/auth/totp";
|
|
646
|
-
var cookieOptions = (secure) => ({ path: COOKIE_PATH, httpOnly: true, secure, sameSite: "Strict" });
|
|
647
|
-
async function setChallenge(c, ctx, challenge) {
|
|
648
|
-
const token = await signToken(await ctx.keys, ctx.config.issuer, CHALLENGE_TYP, { ...challenge }, CHALLENGE_TTL);
|
|
649
|
-
setCookie2(c, CHALLENGE_COOKIE, token, { ...cookieOptions(ctx.secure), maxAge: CHALLENGE_TTL });
|
|
650
|
-
}
|
|
651
|
-
function clearChallenge(c, secure) {
|
|
652
|
-
deleteCookie2(c, CHALLENGE_COOKIE, cookieOptions(secure));
|
|
653
|
-
}
|
|
654
|
-
async function readChallenge(c, ctx, next) {
|
|
655
|
-
const claims = await readToken(await ctx.keys, ctx.config.issuer, CHALLENGE_TYP, getCookie2(c, CHALLENGE_COOKIE));
|
|
656
|
-
return claims && claims.next === next && typeof claims.sub === "string" && Array.isArray(claims.amr) ? claims : null;
|
|
657
|
-
}
|
|
1129
|
+
var SETUP_COOKIE2 = "madauth_totp_setup";
|
|
1130
|
+
var SETUP_TYP2 = "madauth-totp-setup+jwt";
|
|
1131
|
+
var SETUP_TTL2 = 10 * 60;
|
|
1132
|
+
var COOKIE_PATH4 = "/auth/totp";
|
|
1133
|
+
var cookieOptions2 = (secure) => ({ path: COOKIE_PATH4, httpOnly: true, secure, sameSite: "Strict" });
|
|
658
1134
|
async function checkCode(c, ctx, keys, user, account, data, wrong = () => error(c, 401, "code_invalid", "The code is wrong or was already used.")) {
|
|
659
1135
|
const { users } = ctx;
|
|
660
1136
|
const refused = await countSignInAttempt(c, users, account);
|
|
@@ -664,11 +1140,11 @@ async function checkCode(c, ctx, keys, user, account, data, wrong = () => error(
|
|
|
664
1140
|
if (!code || !await users.consumeRecoveryCode(account, hashRecoveryCode(keys.codeKey, code))) return wrong();
|
|
665
1141
|
await users.updateAccount(account.id, { failedAttempts: 0, lockedUntil: 0 });
|
|
666
1142
|
const remaining = await users.countRecoveryCodes(account);
|
|
667
|
-
await ctx.emit("
|
|
1143
|
+
await ctx.emit("user.recovery_code_used", { user: toMadauthUser(user), remaining });
|
|
668
1144
|
return "recovery_code";
|
|
669
1145
|
}
|
|
670
1146
|
if (typeof data.code !== "string" || !account.secret) return wrong();
|
|
671
|
-
const secret =
|
|
1147
|
+
const secret = decrypt(keys.totpKey, account.secret);
|
|
672
1148
|
if (!secret) {
|
|
673
1149
|
console.error(`[madauth] The authenticator secret of user ${user.id} can't be read: was MADAUTH_SIGNING_KEY changed?`);
|
|
674
1150
|
return wrong();
|
|
@@ -680,9 +1156,9 @@ async function checkCode(c, ctx, keys, user, account, data, wrong = () => error(
|
|
|
680
1156
|
function totpRoutes(app, ctx, keys) {
|
|
681
1157
|
const { config, secure, users } = ctx;
|
|
682
1158
|
const { issuer } = config;
|
|
683
|
-
const cookie =
|
|
1159
|
+
const cookie = cookieOptions2(secure);
|
|
684
1160
|
const methodsNow = (c) => ctx.methods(c);
|
|
685
|
-
app.use(`${
|
|
1161
|
+
app.use(`${COOKIE_PATH4}/*`, async (c, next) => {
|
|
686
1162
|
if (!authenticatorOn(ctx.configured, await methodsNow(c))) {
|
|
687
1163
|
return error(c, 403, "method_disabled", "The authenticator app is switched off.");
|
|
688
1164
|
}
|
|
@@ -690,42 +1166,25 @@ function totpRoutes(app, ctx, keys) {
|
|
|
690
1166
|
});
|
|
691
1167
|
const standalone = async (c) => isOn("totp", ctx.configured, await methodsNow(c));
|
|
692
1168
|
const notStandalone = (c) => error(c, 403, "method_disabled", "Signing in with the authenticator app alone is switched off.");
|
|
693
|
-
const methodStillOn = async (c, challenge) => isOn(challenge.method, ctx.configured, await methodsNow(c));
|
|
694
|
-
const methodOff = (c) => error(c, 403, "method_disabled", "This way of signing in is switched off.");
|
|
695
1169
|
const signedIn = async (c) => {
|
|
696
1170
|
const user = await ctx.currentUser(c);
|
|
697
1171
|
return user ? users.findById(user.id) : null;
|
|
698
1172
|
};
|
|
699
|
-
const whoSetsUp =
|
|
700
|
-
const challenge = await readChallenge(c, ctx, "totp-setup");
|
|
701
|
-
const waiting = challenge ? await users.findById(challenge.sub) : null;
|
|
702
|
-
if (challenge && waiting && waiting.sessionVersion === challenge.sv) return { user: waiting, challenge };
|
|
703
|
-
const user = await signedIn(c);
|
|
704
|
-
return user ? { user, challenge: null } : null;
|
|
705
|
-
};
|
|
1173
|
+
const whoSetsUp = (c) => pendingOrSignedIn(c, ctx);
|
|
706
1174
|
const newRecoveryCodes = () => {
|
|
707
1175
|
const recoveryCodes = generateRecoveryCodes();
|
|
708
1176
|
return { recoveryCodes, hashes: recoveryCodes.map((code) => hashRecoveryCode(keys.codeKey, normalizeRecoveryCode(code))) };
|
|
709
1177
|
};
|
|
710
|
-
const issuerLabel = (c) =>
|
|
711
|
-
|
|
712
|
-
const origin = c.req.header("origin");
|
|
713
|
-
return URL.canParse(origin ?? "") ? new URL(origin).hostname : new URL(issuer).hostname;
|
|
714
|
-
};
|
|
715
|
-
const finishSignIn = async (c, user, challenge, via) => {
|
|
716
|
-
const result = await ctx.startSession(c, toMadauthUser(user, challenge.profile), [...challenge.amr, "otp"], { sv: user.sessionVersion });
|
|
717
|
-
await ctx.emit("user.signed_in", { user: result, method: challenge.method, ...challenge.amr.length ? { secondFactor: via } : {} });
|
|
718
|
-
return result;
|
|
719
|
-
};
|
|
720
|
-
app.post(`${COOKIE_PATH}/setup`, async (c) => {
|
|
1178
|
+
const issuerLabel = (c) => config.totpIssuer || originHost(ctx, c);
|
|
1179
|
+
app.post(`${COOKIE_PATH4}/setup`, async (c) => {
|
|
721
1180
|
const who = await whoSetsUp(c);
|
|
722
1181
|
if (!who) return noSession(c);
|
|
723
|
-
if (who.challenge && !await methodStillOn(c, who.challenge)) return methodOff(c);
|
|
1182
|
+
if (who.challenge && !await methodStillOn(c, ctx, who.challenge)) return methodOff(c);
|
|
724
1183
|
const existing = await users.totpAccount(who.user.id);
|
|
725
1184
|
let approved;
|
|
726
1185
|
if (existing) {
|
|
727
|
-
const previous = await readToken(await ctx.keys, issuer,
|
|
728
|
-
const holds = previous?.sub === who.user.id && typeof previous.approved === "number" && Date.now() - previous.approved <
|
|
1186
|
+
const previous = await readToken(await ctx.keys, issuer, SETUP_TYP2, getCookie5(c, SETUP_COOKIE2));
|
|
1187
|
+
const holds = previous?.sub === who.user.id && typeof previous.approved === "number" && Date.now() - previous.approved < SETUP_TTL2 * 1e3;
|
|
729
1188
|
if (holds) {
|
|
730
1189
|
approved = previous.approved;
|
|
731
1190
|
} else {
|
|
@@ -742,89 +1201,102 @@ function totpRoutes(app, ctx, keys) {
|
|
|
742
1201
|
const claims = {
|
|
743
1202
|
sub: who.user.id,
|
|
744
1203
|
setup: newId("tsu"),
|
|
745
|
-
secret:
|
|
1204
|
+
secret: encrypt(keys.totpKey, secret),
|
|
746
1205
|
...existing ? { replaces: existing.setupId } : {},
|
|
747
1206
|
...approved ? { approved } : {}
|
|
748
1207
|
};
|
|
749
|
-
const token = await signToken(await ctx.keys, issuer,
|
|
750
|
-
|
|
1208
|
+
const token = await signToken(await ctx.keys, issuer, SETUP_TYP2, claims, SETUP_TTL2);
|
|
1209
|
+
setCookie5(c, SETUP_COOKIE2, token, { ...cookie, maxAge: SETUP_TTL2 });
|
|
751
1210
|
return c.json({ secret: base32Encode(secret), uri: otpauthUri({ issuer: issuerLabel(c), account: who.user.email, secret }) });
|
|
752
1211
|
});
|
|
753
|
-
app.post(`${
|
|
1212
|
+
app.post(`${COOKIE_PATH4}/confirm`, async (c) => {
|
|
754
1213
|
const who = await whoSetsUp(c);
|
|
755
1214
|
if (!who) return noSession(c);
|
|
756
|
-
const {
|
|
757
|
-
|
|
1215
|
+
const { challenge } = who;
|
|
1216
|
+
let { user } = who;
|
|
1217
|
+
if (challenge && !await methodStillOn(c, ctx, challenge)) return methodOff(c);
|
|
758
1218
|
const data = await body(c);
|
|
759
|
-
const setup = await readToken(await ctx.keys, issuer,
|
|
760
|
-
const secret = setup && setup.sub === user.id && typeof setup.secret === "string" && typeof setup.setup === "string" ?
|
|
1219
|
+
const setup = await readToken(await ctx.keys, issuer, SETUP_TYP2, getCookie5(c, SETUP_COOKIE2));
|
|
1220
|
+
const secret = setup && setup.sub === user.id && typeof setup.secret === "string" && typeof setup.setup === "string" ? decrypt(keys.totpKey, setup.secret) : null;
|
|
761
1221
|
if (!setup || !secret) return error(c, 400, "setup_expired", "The setup has expired or was started in another browser. Please start again.");
|
|
762
1222
|
const step = typeof data.code === "string" ? verifyTotp(secret, data.code, Date.now(), TOTP_WINDOW) : null;
|
|
763
1223
|
if (step === null) return error(c, 400, "code_invalid", "The code is wrong. Enter the current code from the app.");
|
|
1224
|
+
const signUp = challenge?.method === "totp";
|
|
1225
|
+
const fresh = signUp && !user.emailVerified ? await users.findById(user.id) : null;
|
|
1226
|
+
if (fresh && !fresh.emailVerified) {
|
|
1227
|
+
await users.verifyByProvider(fresh);
|
|
1228
|
+
user = { ...user, emailVerified: true, wrongCodes: 0 };
|
|
1229
|
+
}
|
|
764
1230
|
const { recoveryCodes, hashes } = newRecoveryCodes();
|
|
765
1231
|
await users.addRecoveryCodes(user.id, setup.setup, hashes);
|
|
766
1232
|
const patch = { secret: setup.secret, setupId: setup.setup, failedAttempts: 0, lockedUntil: 0, lastUsedStep: step };
|
|
767
|
-
const
|
|
768
|
-
const
|
|
1233
|
+
const replaced = setup.replaces === void 0 ? null : await users.totpApp(user.id);
|
|
1234
|
+
const current = replaced?.account ?? null;
|
|
1235
|
+
const written = setup.replaces === void 0 ? !!await users.linkAccount(user.id, { key: totpAccountKey(user.id), primary: signUp, ...patch }) : !!current && await users.confirmTotpSetup(current.id, setup.replaces, patch);
|
|
769
1236
|
if (!written) {
|
|
770
1237
|
await users.clearRecoveryCodes(user.id, { only: setup.setup });
|
|
771
|
-
|
|
1238
|
+
deleteCookie5(c, SETUP_COOKIE2, cookie);
|
|
772
1239
|
return error(c, 400, "setup_expired", "Your authenticator app was set up or removed elsewhere meanwhile. Please start again.");
|
|
773
1240
|
}
|
|
774
1241
|
await users.clearRecoveryCodes(user.id, { except: setup.setup });
|
|
775
|
-
|
|
776
|
-
|
|
1242
|
+
deleteCookie5(c, SETUP_COOKIE2, cookie);
|
|
1243
|
+
const secondFactor = replaced ? !replaced.signsIn : !signUp;
|
|
1244
|
+
if (secondFactor) await ctx.emit("user.second_factor_enabled", { user: toMadauthUser(user), method: "totp" });
|
|
777
1245
|
if (!challenge) return c.json({ recoveryCodes });
|
|
778
|
-
const result = await finishSignIn(c, user, challenge, "totp");
|
|
1246
|
+
const result = await finishSignIn(c, ctx, user, challenge, "otp", "totp");
|
|
779
1247
|
return c.json({ user: result, recoveryCodes });
|
|
780
1248
|
});
|
|
781
|
-
app.post(`${
|
|
1249
|
+
app.post(`${COOKIE_PATH4}/verify`, async (c) => {
|
|
782
1250
|
const expired = () => error(c, 401, "challenge_expired", "The sign-in has expired. Please sign in again.");
|
|
783
1251
|
const challenge = await readChallenge(c, ctx, "totp");
|
|
784
1252
|
if (!challenge) return expired();
|
|
785
|
-
if (!await methodStillOn(c, challenge)) return methodOff(c);
|
|
1253
|
+
if (!await methodStillOn(c, ctx, challenge)) return methodOff(c);
|
|
786
1254
|
const user = await users.findById(challenge.sub);
|
|
787
1255
|
const account = user && user.sessionVersion === challenge.sv ? await users.totpAccount(user.id) : null;
|
|
788
1256
|
if (!user || !account) return expired();
|
|
789
1257
|
const via = await checkCode(c, ctx, keys, user, account, await body(c));
|
|
790
1258
|
if (via instanceof Response) return via;
|
|
791
|
-
return c.json({ user: await finishSignIn(c, user, challenge, via) });
|
|
1259
|
+
return c.json({ user: await finishSignIn(c, ctx, user, challenge, "otp", via) });
|
|
792
1260
|
});
|
|
793
|
-
app.post(`${
|
|
1261
|
+
app.post(`${COOKIE_PATH4}/signin`, async (c) => {
|
|
794
1262
|
if (!await standalone(c)) return notStandalone(c);
|
|
795
1263
|
const data = await body(c);
|
|
796
1264
|
const wrong = () => error(c, 401, "invalid_credentials", "E-mail or code is wrong.");
|
|
797
1265
|
if (!isValidEmail(data.email)) return wrong();
|
|
798
1266
|
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
799
|
-
const
|
|
800
|
-
|
|
1267
|
+
const app2 = user ? await users.totpApp(user.id) : null;
|
|
1268
|
+
const account = app2?.account;
|
|
1269
|
+
if (!user || !user.emailVerified || !app2?.signsIn || !account?.secret) return wrong();
|
|
801
1270
|
const via = await checkCode(c, ctx, keys, user, account, data, wrong);
|
|
802
1271
|
if (via instanceof Response) return via;
|
|
803
|
-
const result = await ctx.startSession(c, toMadauthUser(user), ["otp"], { sv: user.sessionVersion });
|
|
1272
|
+
const result = await ctx.startSession(c, toMadauthUser(user), ["otp"], { method: "totp", signedUpAt: user.signedUpAt }, { sv: user.sessionVersion });
|
|
804
1273
|
await ctx.emit("user.signed_in", { user: result, method: "totp" });
|
|
805
1274
|
return c.json({ user: result });
|
|
806
1275
|
});
|
|
807
|
-
app.post(`${
|
|
1276
|
+
app.post(`${COOKIE_PATH4}/remove`, async (c) => {
|
|
808
1277
|
const user = await signedIn(c);
|
|
809
1278
|
if (!user) return noSession(c);
|
|
810
1279
|
const methods = await methodsNow(c);
|
|
811
1280
|
const required = METHODS_WITH_SECOND_FACTOR.filter((method) => isOn(method, ctx.configured, methods) && policyOf(method, methods) === "required");
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
1281
|
+
const smsOn = isOn("sms", ctx.configured, methods);
|
|
1282
|
+
if (required.length && !(smsOn && await users.smsAccount(user.id))) {
|
|
1283
|
+
const names = required.map((method) => ({ google: "Google", github: "GitHub", password: "e-mail & password", "email-link": "the e-mail link" })[method]);
|
|
1284
|
+
const alternative = smsOn ? " Add a phone number for SMS codes first, or" : " To stop using it,";
|
|
1285
|
+
return error(c, 403, "required_by_policy", `Signing in with ${names.join(" and ")} requires a second factor, so the authenticator app can't be removed.${alternative} delete your account.`);
|
|
815
1286
|
}
|
|
816
|
-
const
|
|
817
|
-
if (!
|
|
818
|
-
|
|
1287
|
+
const app2 = await users.totpApp(user.id);
|
|
1288
|
+
if (!app2) return c.json({});
|
|
1289
|
+
const { account } = app2;
|
|
1290
|
+
if (app2.signsIn) {
|
|
819
1291
|
return error(c, 403, "only_method", "The authenticator app is the only way you sign in, so it can't be removed. Delete your account instead.");
|
|
820
1292
|
}
|
|
821
1293
|
const via = await checkCode(c, ctx, keys, user, account, await body(c));
|
|
822
1294
|
if (via instanceof Response) return via;
|
|
823
1295
|
await users.removeTotp(user.id);
|
|
824
|
-
await ctx.emit("
|
|
1296
|
+
await ctx.emit("user.second_factor_disabled", { user: toMadauthUser(user), method: "totp" });
|
|
825
1297
|
return c.json({});
|
|
826
1298
|
});
|
|
827
|
-
app.post(`${
|
|
1299
|
+
app.post(`${COOKIE_PATH4}/recovery-codes`, async (c) => {
|
|
828
1300
|
const user = await signedIn(c);
|
|
829
1301
|
if (!user) return noSession(c);
|
|
830
1302
|
const account = await users.totpAccount(user.id);
|
|
@@ -835,7 +1307,7 @@ function totpRoutes(app, ctx, keys) {
|
|
|
835
1307
|
await users.replaceRecoveryCodes(account, hashes);
|
|
836
1308
|
return c.json({ recoveryCodes });
|
|
837
1309
|
});
|
|
838
|
-
app.get(`${
|
|
1310
|
+
app.get(`${COOKIE_PATH4}/status`, async (c) => {
|
|
839
1311
|
const user = await signedIn(c);
|
|
840
1312
|
if (!user) return noSession(c);
|
|
841
1313
|
const account = await users.totpAccount(user.id);
|
|
@@ -843,7 +1315,7 @@ function totpRoutes(app, ctx, keys) {
|
|
|
843
1315
|
});
|
|
844
1316
|
if (ctx.webhook && ctx.emailVerification) {
|
|
845
1317
|
const emails = createEmails(ctx, users, ctx.webhook);
|
|
846
|
-
app.post(`${
|
|
1318
|
+
app.post(`${COOKIE_PATH4}/signup`, async (c) => {
|
|
847
1319
|
if (!await standalone(c)) return notStandalone(c);
|
|
848
1320
|
const data = await body(c);
|
|
849
1321
|
const target = redirectTarget(ctx, c, data.redirectTo);
|
|
@@ -855,18 +1327,18 @@ function totpRoutes(app, ctx, keys) {
|
|
|
855
1327
|
const check = await ctx.checkSignup({ email, name: name ?? void 0, locale, method: "totp" });
|
|
856
1328
|
if (!check.ok) return check.reason === "rejected" ? error(c, 403, "signup_rejected", check.message) : unavailable(c);
|
|
857
1329
|
const existing = await users.findByEmail(normalizeEmail(email));
|
|
858
|
-
if (existing?.emailVerified && await users.
|
|
859
|
-
return await emails.sendAccountEmail(existing, "email.
|
|
1330
|
+
if (existing?.emailVerified && await users.hasSignInMethod(existing.id)) {
|
|
1331
|
+
return await emails.sendAccountEmail(existing, "email.send_already_registered", target, locale) ? accepted(c) : unavailable(c);
|
|
860
1332
|
}
|
|
861
1333
|
if (existing) {
|
|
862
1334
|
if (Date.now() - existing.lastMailAt < MAIL_INTERVAL_MS) return accepted(c);
|
|
863
|
-
if (!existing.emailVerified) await users.
|
|
1335
|
+
if (!existing.emailVerified) await users.dropUnprovenAccounts(existing.id);
|
|
1336
|
+
await users.reopenSignUp(existing);
|
|
864
1337
|
await users.updateUser(existing.id, { name });
|
|
865
1338
|
return await emails.sendLinkEmail({ ...existing, name }, "verify", target, locale) ? accepted(c) : unavailable(c);
|
|
866
1339
|
}
|
|
867
1340
|
const user = await users.createUser({ email, emailNormalized: normalizeEmail(email), name, emailVerified: false });
|
|
868
1341
|
if (!user) return accepted(c);
|
|
869
|
-
await ctx.emit("user.created", { user: toMadauthUser(user), method: "totp" });
|
|
870
1342
|
return await emails.sendLinkEmail(user, "verify", target, locale) ? accepted(c) : unavailable(c);
|
|
871
1343
|
});
|
|
872
1344
|
}
|
|
@@ -887,13 +1359,14 @@ function createApp(config) {
|
|
|
887
1359
|
const signsUp = (method) => canSignUp(methods, method);
|
|
888
1360
|
const emailVerification = sendsVerificationEmails(config);
|
|
889
1361
|
const totpKeys = { totpKey: deriveTotpKey(config.signingKey.d), codeKey };
|
|
1362
|
+
const tokenKey = deriveTokenKey(config.signingKey.d);
|
|
890
1363
|
const issueSession = async (c, user, amr, extra) => {
|
|
891
1364
|
const signingKeys = await keys;
|
|
892
1365
|
const [sessionToken, renewalToken] = await Promise.all([
|
|
893
1366
|
signSession(signingKeys, issuer, sessionTtlSeconds, user, amr, extra),
|
|
894
1367
|
signRenewal(signingKeys, issuer, renewalTtlSeconds, user, amr, extra)
|
|
895
1368
|
]);
|
|
896
|
-
|
|
1369
|
+
setCookie6(c, SESSION_COOKIE, sessionToken, {
|
|
897
1370
|
path: "/",
|
|
898
1371
|
domain: cookieDomain,
|
|
899
1372
|
httpOnly: true,
|
|
@@ -901,14 +1374,14 @@ function createApp(config) {
|
|
|
901
1374
|
sameSite: "Lax",
|
|
902
1375
|
maxAge: sessionTtlSeconds
|
|
903
1376
|
});
|
|
904
|
-
|
|
1377
|
+
setCookie6(c, RENEWAL_COOKIE, renewalToken, {
|
|
905
1378
|
path: "/auth",
|
|
906
1379
|
httpOnly: true,
|
|
907
1380
|
secure,
|
|
908
1381
|
sameSite: "Lax",
|
|
909
1382
|
maxAge: renewalTtlSeconds
|
|
910
1383
|
});
|
|
911
|
-
|
|
1384
|
+
setCookie6(c, EXPIRY_COOKIE, String(Math.floor(Date.now() / 1e3) + sessionTtlSeconds), {
|
|
912
1385
|
path: "/",
|
|
913
1386
|
domain: cookieDomain,
|
|
914
1387
|
secure,
|
|
@@ -917,11 +1390,11 @@ function createApp(config) {
|
|
|
917
1390
|
});
|
|
918
1391
|
};
|
|
919
1392
|
const clearSession = (c) => {
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
1393
|
+
deleteCookie6(c, SESSION_COOKIE, { path: "/", domain: cookieDomain, secure });
|
|
1394
|
+
deleteCookie6(c, RENEWAL_COOKIE, { path: "/auth", secure });
|
|
1395
|
+
deleteCookie6(c, EXPIRY_COOKIE, { path: "/", domain: cookieDomain, secure });
|
|
923
1396
|
};
|
|
924
|
-
const tokenClaims = async (c, cookie, typ) => readToken(await keys, issuer, typ,
|
|
1397
|
+
const tokenClaims = async (c, cookie, typ) => readToken(await keys, issuer, typ, getCookie6(c, cookie));
|
|
925
1398
|
const checked = async (claims) => {
|
|
926
1399
|
const stored = await users.findById(claims.sub);
|
|
927
1400
|
if (!stored || stored.sessionVersion !== claims.sv) return null;
|
|
@@ -960,24 +1433,35 @@ function createApp(config) {
|
|
|
960
1433
|
url.hash = "";
|
|
961
1434
|
return url;
|
|
962
1435
|
},
|
|
963
|
-
async startSession(c, user, amr, extra) {
|
|
1436
|
+
async startSession(c, user, amr, { method, signedUpAt }, extra) {
|
|
1437
|
+
const firstSession = signedUpAt === 0 && await users.markSignedUp(user.id);
|
|
964
1438
|
clearChallenge(c, secure);
|
|
965
1439
|
await issueSession(c, user, amr, extra);
|
|
966
|
-
|
|
1440
|
+
const result = { ...user, amr };
|
|
1441
|
+
if (firstSession) await this.emit("user.signed_up", { user: result, method });
|
|
1442
|
+
return result;
|
|
967
1443
|
},
|
|
968
1444
|
async secondStep(c, user, amr, method, profile) {
|
|
969
1445
|
const methods2 = await this.methods(c);
|
|
970
1446
|
if (!isOn(method, configured, methods2)) return { disabled: true };
|
|
971
|
-
const enrolled = await users.totpAccount(user.id);
|
|
972
1447
|
const policy = method === "totp" ? "required" : policyOf(method, methods2);
|
|
973
|
-
|
|
1448
|
+
let next = null;
|
|
1449
|
+
let factors = [];
|
|
1450
|
+
if (policy !== "none") {
|
|
1451
|
+
const [app2, phone] = await Promise.all([
|
|
1452
|
+
users.totpAccount(user.id),
|
|
1453
|
+
method !== "totp" && isOn("sms", configured, methods2) ? users.smsAccount(user.id) : null
|
|
1454
|
+
]);
|
|
1455
|
+
factors = [...app2 ? ["totp"] : [], ...phone ? ["sms"] : []];
|
|
1456
|
+
next = app2 ? "totp" : phone ? "sms" : policy === "required" ? "totp-setup" : null;
|
|
1457
|
+
}
|
|
974
1458
|
if (next) {
|
|
975
1459
|
clearSession(c);
|
|
976
1460
|
const known = { ...profile?.name ? { name: profile.name } : {}, ...profile?.picture ? { picture: profile.picture } : {} };
|
|
977
|
-
await setChallenge(c, this, { sub: user.id, sv: user.sessionVersion, amr, method, next, ...Object.keys(known).length ? { profile: known } : {} });
|
|
978
|
-
return { next, method };
|
|
1461
|
+
await setChallenge(c, this, { sub: user.id, sv: user.sessionVersion, amr, method, next, factors, ...Object.keys(known).length ? { profile: known } : {} });
|
|
1462
|
+
return { next, method, factors };
|
|
979
1463
|
}
|
|
980
|
-
const result = await this.startSession(c, toMadauthUser(user, profile), amr, { sv: user.sessionVersion });
|
|
1464
|
+
const result = await this.startSession(c, toMadauthUser(user, profile), amr, { method, signedUpAt: user.signedUpAt }, { sv: user.sessionVersion });
|
|
981
1465
|
await this.emit("user.signed_in", { user: result, method });
|
|
982
1466
|
return { user: result };
|
|
983
1467
|
},
|
|
@@ -999,10 +1483,10 @@ function createApp(config) {
|
|
|
999
1483
|
return isOn(method, configured, await this.methods(c));
|
|
1000
1484
|
},
|
|
1001
1485
|
async checkSignup(data) {
|
|
1002
|
-
if (!this.webhook?.wants("
|
|
1003
|
-
const check = await this.webhook.call("
|
|
1486
|
+
if (!this.webhook?.wants("user.signing_up")) return { ok: true };
|
|
1487
|
+
const check = await this.webhook.call("user.signing_up", data, WEBHOOK_TIMEOUT_MS);
|
|
1004
1488
|
if (!check.ok) {
|
|
1005
|
-
console.error(`[madauth] Webhook "
|
|
1489
|
+
console.error(`[madauth] Webhook "user.signing_up" failed: ${check.reason}`);
|
|
1006
1490
|
return { ok: false, reason: "unavailable" };
|
|
1007
1491
|
}
|
|
1008
1492
|
const answer = check.body;
|
|
@@ -1046,17 +1530,25 @@ function createApp(config) {
|
|
|
1046
1530
|
app.get("/auth/config", async (c) => {
|
|
1047
1531
|
const current = await ctx.methods(c);
|
|
1048
1532
|
const on = (method) => isOn(method, configured, current);
|
|
1533
|
+
const rules = on("password") ? await settings.passwordRules() : null;
|
|
1049
1534
|
return c.json({
|
|
1050
1535
|
google: on("google") ? { clientId: config.google.clientId, codeFlow: !!config.google.clientSecret, secondFactor: policyOf("google", current) } : null,
|
|
1051
|
-
|
|
1536
|
+
github: on("github") ? { secondFactor: policyOf("github", current) } : null,
|
|
1537
|
+
password: rules ? { ...rules, secondFactor: policyOf("password", current) } : null,
|
|
1052
1538
|
// The app can be set up when it is in use at all; `signIn` says whether it signs in on its own.
|
|
1053
1539
|
totp: authenticatorOn(configured, current) ? { signIn: on("totp"), signUp: on("totp") && emailVerification } : null,
|
|
1054
|
-
email: { verification: emailVerification }
|
|
1540
|
+
email: { verification: emailVerification },
|
|
1541
|
+
emailLink: on("email-link") ? { secondFactor: policyOf("email-link", current) } : null,
|
|
1542
|
+
// SMS codes as a second factor: a signed-in user can add a phone, and the dialog offers it in the second step.
|
|
1543
|
+
sms: on("sms")
|
|
1055
1544
|
});
|
|
1056
1545
|
});
|
|
1057
1546
|
if (emailVerification && ctx.webhook) emailRoutes(app, ctx, users, ctx.webhook);
|
|
1547
|
+
if (configured("email-link") && ctx.webhook) emailLinkRoutes(app, ctx, users, ctx.webhook);
|
|
1548
|
+
if (configured("sms") && ctx.webhook) smsRoutes(app, ctx, users, ctx.webhook);
|
|
1058
1549
|
if (config.google) googleRoutes(app, ctx, config.google);
|
|
1059
|
-
if (config.
|
|
1550
|
+
if (config.github) githubRoutes(app, ctx, config.github, tokenKey);
|
|
1551
|
+
if (config.password && ctx.webhook) passwordRoutes(app, ctx, users, ctx.webhook);
|
|
1060
1552
|
totpRoutes(app, ctx, totpKeys);
|
|
1061
1553
|
app.get("/auth/session", async (c) => {
|
|
1062
1554
|
const user = await currentSession(c);
|
|
@@ -1085,10 +1577,13 @@ function createApp(config) {
|
|
|
1085
1577
|
const via = await checkCode(c, ctx, totpKeys, stored, authenticator, data);
|
|
1086
1578
|
if (via instanceof Response) return via;
|
|
1087
1579
|
}
|
|
1088
|
-
await users.
|
|
1580
|
+
const googleSub = await users.googleSub(user.id);
|
|
1581
|
+
const githubAccount = config.github ? await users.githubAccount(user.id) : null;
|
|
1582
|
+
const deleted = await users.deleteById(user.id);
|
|
1583
|
+
if (deleted && config.github) await revokeGrant(config.github, tokenKey, githubAccount);
|
|
1089
1584
|
clearSession(c);
|
|
1090
1585
|
await ctx.emit("user.deleted", { user });
|
|
1091
|
-
return c.body(null, 204);
|
|
1586
|
+
return googleSub ? c.json({ googleSub }) : c.body(null, 204);
|
|
1092
1587
|
});
|
|
1093
1588
|
const admin = async (c) => {
|
|
1094
1589
|
const user = await currentSession(c);
|
|
@@ -1128,19 +1623,21 @@ function createApp(config) {
|
|
|
1128
1623
|
}
|
|
1129
1624
|
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
1130
1625
|
if (!user) return noUser(c);
|
|
1131
|
-
|
|
1626
|
+
const stored = Object.keys(claims).length ? JSON.stringify(claims) : null;
|
|
1627
|
+
await users.updateUser(user.id, { claims: stored });
|
|
1132
1628
|
const email = user.emailNormalized;
|
|
1133
|
-
await ctx.emit("user.claims_changed", {
|
|
1629
|
+
await ctx.emit("user.claims_changed", { user: toMadauthUser({ ...user, claims: stored }), claims, by: caller.email });
|
|
1134
1630
|
return c.json({ email, userId: user.id, claims });
|
|
1135
1631
|
});
|
|
1136
|
-
const settingsAnswer = (methods2) => ({
|
|
1632
|
+
const settingsAnswer = (methods2, password) => ({
|
|
1633
|
+
password,
|
|
1137
1634
|
methods: Object.fromEntries(
|
|
1138
|
-
|
|
1635
|
+
METHODS.map((method) => [
|
|
1139
1636
|
method,
|
|
1140
1637
|
{
|
|
1141
1638
|
configured: configured(method),
|
|
1142
1639
|
enabled: isOn(method, configured, methods2),
|
|
1143
|
-
...method
|
|
1640
|
+
...hasSecondFactor(method) ? { secondFactor: policyOf(method, methods2) } : {}
|
|
1144
1641
|
}
|
|
1145
1642
|
])
|
|
1146
1643
|
)
|
|
@@ -1148,22 +1645,43 @@ function createApp(config) {
|
|
|
1148
1645
|
app.post("/auth/admin/settings/get", async (c) => {
|
|
1149
1646
|
const caller = await admin(c);
|
|
1150
1647
|
if ("answer" in caller) return caller.answer;
|
|
1151
|
-
|
|
1648
|
+
const [methods2, password] = await Promise.all([settings.methods(), settings.passwordRules()]);
|
|
1649
|
+
return c.json(settingsAnswer(methods2, password));
|
|
1152
1650
|
});
|
|
1153
1651
|
app.post("/auth/admin/settings/set", async (c) => {
|
|
1154
1652
|
const caller = await admin(c);
|
|
1155
1653
|
if ("answer" in caller) return caller.answer;
|
|
1156
1654
|
const data = await body(c);
|
|
1157
1655
|
const invalid = (message) => c.json({ error: "invalid_settings", message }, 400);
|
|
1158
|
-
|
|
1159
|
-
|
|
1656
|
+
if (data.methods === void 0 && data.password === void 0) return invalid("Pass methods, password or both.");
|
|
1657
|
+
const next = data.methods === void 0 ? void 0 : parseMethodSettings(data.methods, { booleans: true });
|
|
1658
|
+
if (next === null) {
|
|
1160
1659
|
return invalid(
|
|
1161
|
-
`methods must list the sign-in methods that are on (${SIGN_IN_METHODS.join(", ")}), each as true, {} or { "secondFactor": "none" | "optional" | "required" } (google and
|
|
1660
|
+
`methods must list the sign-in methods that are on (${SIGN_IN_METHODS.join(", ")}; sms for SMS codes as a second factor), each as true, {} or { "secondFactor": "none" | "optional" | "required" } (google, github, password and email-link), e.g. { "google": {}, "password": { "secondFactor": "optional" }, "sms": {} }. A method that is not listed is off.`
|
|
1162
1661
|
);
|
|
1163
1662
|
}
|
|
1164
|
-
const
|
|
1165
|
-
if (
|
|
1166
|
-
|
|
1663
|
+
const rules = data.password === void 0 ? void 0 : parsePasswordRules(data.password);
|
|
1664
|
+
if (rules === null) {
|
|
1665
|
+
return invalid(
|
|
1666
|
+
`password must be { "minLength": 1 to ${MAX_MIN_LENGTH}, "minScore": 0 to 4 }, both optional, e.g. { "minLength": 10, "minScore": 3 }. What is left out takes its default (${DEFAULT_PASSWORD_RULES.minLength}, ${DEFAULT_PASSWORD_RULES.minScore}).`
|
|
1667
|
+
);
|
|
1668
|
+
}
|
|
1669
|
+
if (next && !anyOn(signsUp, next)) {
|
|
1670
|
+
return invalid("At least one sign-in method that creates users must stay on: Google, GitHub, e-mail & password, the e-mail link, or the authenticator app with sign-up by e-mail.");
|
|
1671
|
+
}
|
|
1672
|
+
const previousRules = rules && next ? await settings.passwordRules() : void 0;
|
|
1673
|
+
const password = rules ? await settings.setPasswordRules(rules, caller.email) : await settings.passwordRules();
|
|
1674
|
+
if (next) {
|
|
1675
|
+
try {
|
|
1676
|
+
await settings.changeMethods(() => next, caller.email);
|
|
1677
|
+
} catch (e) {
|
|
1678
|
+
if (previousRules) await settings.setPasswordRules(previousRules, caller.email).catch(() => {
|
|
1679
|
+
});
|
|
1680
|
+
throw e;
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
const methods2 = next ?? await settings.methods();
|
|
1684
|
+
return c.json(settingsAnswer(methods2, password));
|
|
1167
1685
|
});
|
|
1168
1686
|
app.post("/auth/admin/totp/remove", async (c) => {
|
|
1169
1687
|
const caller = await admin(c);
|
|
@@ -1172,8 +1690,21 @@ function createApp(config) {
|
|
|
1172
1690
|
if (!isValidEmail(data.email)) return invalidEmail(c);
|
|
1173
1691
|
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
1174
1692
|
if (!user) return noUser(c);
|
|
1693
|
+
const app2 = await users.totpApp(user.id);
|
|
1694
|
+
const secondFactor = !!app2 && !app2.signsIn;
|
|
1175
1695
|
const removed = await users.removeTotp(user.id);
|
|
1176
|
-
if (removed) await ctx.emit("
|
|
1696
|
+
if (removed && secondFactor) await ctx.emit("user.second_factor_disabled", { user: toMadauthUser(user), method: "totp", by: caller.email });
|
|
1697
|
+
return c.json({ email: user.emailNormalized, userId: user.id, enabled: false });
|
|
1698
|
+
});
|
|
1699
|
+
app.post("/auth/admin/sms/remove", async (c) => {
|
|
1700
|
+
const caller = await admin(c);
|
|
1701
|
+
if ("answer" in caller) return caller.answer;
|
|
1702
|
+
const data = await body(c);
|
|
1703
|
+
if (!isValidEmail(data.email)) return invalidEmail(c);
|
|
1704
|
+
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
1705
|
+
if (!user) return noUser(c);
|
|
1706
|
+
const removed = await users.removeSms(user.id);
|
|
1707
|
+
if (removed) await ctx.emit("user.second_factor_disabled", { user: toMadauthUser(user), method: "sms", by: caller.email });
|
|
1177
1708
|
return c.json({ email: user.emailNormalized, userId: user.id, enabled: false });
|
|
1178
1709
|
});
|
|
1179
1710
|
return app;
|