@madauth/server 0.3.0-beta.2 → 0.5.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/dist/bin.js +202 -50
- package/dist/chunk-7DHKDRGY.js +1706 -0
- package/dist/{chunk-DCDTO5LG.js → chunk-A7UYZRBL.js} +1 -0
- package/dist/chunk-NP75Y3IV.js +107 -0
- package/dist/chunk-NYIEFI4B.js +1040 -0
- package/dist/{chunk-56MOVFO4.js → chunk-O2URPWA6.js} +37 -4
- package/dist/{chunk-3QRKWLR7.js → chunk-SISM7YUZ.js} +15 -10
- package/dist/{config-Dg2fZuWR.d.ts → config-CcGlHVAN.d.ts} +19 -8
- package/dist/dynamodb.js +1 -1
- package/dist/entry/azure.d.ts +3 -3
- package/dist/entry/azure.js +4 -4
- package/dist/entry/lambda.d.ts +3 -3
- package/dist/entry/lambda.js +4 -4
- package/dist/entry/node.d.ts +3 -3
- package/dist/entry/node.js +4 -4
- package/dist/index.d.ts +37 -15
- package/dist/index.js +12 -6
- package/dist/{schema-3yhCc2jH.d.ts → schema-BTgDj1Xn.d.ts} +79 -3
- package/dist/sqlite.js +2 -2
- package/dist/standalone/azure.mjs +5511 -922
- package/dist/standalone/lambda.mjs +5461 -872
- package/dist/standalone/main.mjs +5487 -898
- 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 +21 -0
- package/dist/{user-BMWEWREH.d.ts → user-yoGjrw4k.d.ts} +5 -0
- package/dist/verify.d.ts +1 -1
- package/dist/verify.js +1 -1
- package/dist/{webhook-j5u9Kv9l.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 +4 -2
- package/dist/chunk-CD7Y4T66.js +0 -62
- package/dist/chunk-DVVBIFIV.js +0 -718
- package/dist/chunk-TI35AZ2U.js +0 -660
|
@@ -0,0 +1,1706 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EXPIRY_COOKIE,
|
|
3
|
+
RENEWAL_COOKIE,
|
|
4
|
+
RENEWAL_TYP,
|
|
5
|
+
SESSION_COOKIE,
|
|
6
|
+
SESSION_TYP,
|
|
7
|
+
randomString,
|
|
8
|
+
readToken,
|
|
9
|
+
signRenewal,
|
|
10
|
+
signSession,
|
|
11
|
+
signToken,
|
|
12
|
+
userFromClaims
|
|
13
|
+
} from "./chunk-A7UYZRBL.js";
|
|
14
|
+
import {
|
|
15
|
+
ADMIN_ROLE,
|
|
16
|
+
DEFAULT_PASSWORD_RULES,
|
|
17
|
+
MAX_MIN_LENGTH,
|
|
18
|
+
MAX_PASSWORD_LENGTH,
|
|
19
|
+
METHODS,
|
|
20
|
+
METHODS_WITH_SECOND_FACTOR,
|
|
21
|
+
SIGN_IN_METHODS,
|
|
22
|
+
Settings,
|
|
23
|
+
Users,
|
|
24
|
+
anyOn,
|
|
25
|
+
authenticatorOn,
|
|
26
|
+
canSignUp,
|
|
27
|
+
checkPasswordPolicy,
|
|
28
|
+
claimsFromJson,
|
|
29
|
+
deriveCodeKey,
|
|
30
|
+
emailLinkAccountKey,
|
|
31
|
+
githubAccountKey,
|
|
32
|
+
googleAccountKey,
|
|
33
|
+
hasSecondFactor,
|
|
34
|
+
hashCode,
|
|
35
|
+
hashPassword,
|
|
36
|
+
initialMethods,
|
|
37
|
+
isAdmin,
|
|
38
|
+
isConfigured,
|
|
39
|
+
isOn,
|
|
40
|
+
isValidEmail,
|
|
41
|
+
methodConfig,
|
|
42
|
+
newId,
|
|
43
|
+
normalizeEmail,
|
|
44
|
+
parseClaims,
|
|
45
|
+
parseMethodSettings,
|
|
46
|
+
parsePasswordRules,
|
|
47
|
+
passwordAccountKey,
|
|
48
|
+
policyOf,
|
|
49
|
+
safeEqual,
|
|
50
|
+
sameClaims,
|
|
51
|
+
sendsVerificationEmails,
|
|
52
|
+
smsAccountKey,
|
|
53
|
+
toMadauthUser,
|
|
54
|
+
totpAccountKey,
|
|
55
|
+
verifyAgainstDummy,
|
|
56
|
+
verifyPassword
|
|
57
|
+
} from "./chunk-NYIEFI4B.js";
|
|
58
|
+
import {
|
|
59
|
+
importSigningKeys
|
|
60
|
+
} from "./chunk-JDQLSL36.js";
|
|
61
|
+
import {
|
|
62
|
+
WEBHOOK_TIMEOUT_MS,
|
|
63
|
+
createWebhookClient,
|
|
64
|
+
localeOf
|
|
65
|
+
} from "./chunk-SISM7YUZ.js";
|
|
66
|
+
|
|
67
|
+
// src/routes/oauth.ts
|
|
68
|
+
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
|
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";
|
|
562
|
+
|
|
563
|
+
// src/google.ts
|
|
564
|
+
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
565
|
+
var GOOGLE_ISSUERS = ["https://accounts.google.com", "accounts.google.com"];
|
|
566
|
+
var googleJwks;
|
|
567
|
+
function googleKeys() {
|
|
568
|
+
googleJwks ??= createRemoteJWKSet(new URL("https://www.googleapis.com/oauth2/v3/certs"));
|
|
569
|
+
return googleJwks;
|
|
570
|
+
}
|
|
571
|
+
async function verifyGoogleIdToken(idToken, opts) {
|
|
572
|
+
let payload;
|
|
573
|
+
try {
|
|
574
|
+
({ payload } = await jwtVerify(idToken, opts.keys ?? googleKeys(), {
|
|
575
|
+
issuer: GOOGLE_ISSUERS,
|
|
576
|
+
audience: opts.clientId,
|
|
577
|
+
algorithms: ["RS256", "ES256"]
|
|
578
|
+
}));
|
|
579
|
+
} catch (e) {
|
|
580
|
+
return { ok: false, error: "verification_failed", reason: e.message };
|
|
581
|
+
}
|
|
582
|
+
if (typeof payload.sub !== "string" || !payload.sub) {
|
|
583
|
+
return { ok: false, error: "verification_failed", reason: "missing sub" };
|
|
584
|
+
}
|
|
585
|
+
if (payload.nonce !== opts.nonce) {
|
|
586
|
+
return { ok: false, error: "verification_failed", reason: "nonce mismatch" };
|
|
587
|
+
}
|
|
588
|
+
if (typeof payload.email !== "string" || !payload.email || payload.email_verified !== true) {
|
|
589
|
+
return { ok: false, error: "email_unverified", reason: "e-mail address not verified" };
|
|
590
|
+
}
|
|
591
|
+
const profile = { sub: payload.sub, email: payload.email };
|
|
592
|
+
if (typeof payload.name === "string") profile.name = payload.name;
|
|
593
|
+
if (typeof payload.picture === "string") profile.picture = payload.picture;
|
|
594
|
+
return { ok: true, profile };
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// src/routes/google.ts
|
|
598
|
+
var NONCE_COOKIE = "madauth_nonce";
|
|
599
|
+
var NONCE_TYP = "madauth-nonce+jwt";
|
|
600
|
+
var NONCE_TTL = 5 * 60;
|
|
601
|
+
var COOKIE_PATH2 = "/auth/google";
|
|
602
|
+
var GOOGLE_AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
603
|
+
var GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
|
|
604
|
+
var GOOGLE = { method: "google", name: "Google" };
|
|
605
|
+
var toProfile = (profile) => ({ key: googleAccountKey(profile.sub), email: profile.email, name: profile.name });
|
|
606
|
+
function googleRoutes(app, ctx, google) {
|
|
607
|
+
const { config, keys, secure } = ctx;
|
|
608
|
+
const { issuer } = config;
|
|
609
|
+
const callbackUrl = `${issuer}/auth/google/callback`;
|
|
610
|
+
const disabled = (c) => c.json({ error: "method_disabled", message: "Google sign-in is switched off." }, 403);
|
|
611
|
+
app.post("/auth/google/nonce", async (c) => {
|
|
612
|
+
if (!await ctx.enabled(c, "google")) return disabled(c);
|
|
613
|
+
const nonce = randomString();
|
|
614
|
+
const token = await signToken(await keys, issuer, NONCE_TYP, { nonce }, NONCE_TTL);
|
|
615
|
+
setCookie2(c, NONCE_COOKIE, token, {
|
|
616
|
+
path: COOKIE_PATH2,
|
|
617
|
+
httpOnly: true,
|
|
618
|
+
secure,
|
|
619
|
+
sameSite: "Strict",
|
|
620
|
+
maxAge: NONCE_TTL
|
|
621
|
+
});
|
|
622
|
+
return c.json({ nonce });
|
|
623
|
+
});
|
|
624
|
+
app.post("/auth/google/verify", async (c) => {
|
|
625
|
+
if (!await ctx.enabled(c, "google")) return disabled(c);
|
|
626
|
+
const body2 = await c.req.json().catch(() => ({}));
|
|
627
|
+
if (typeof body2.credential !== "string" || !body2.credential) {
|
|
628
|
+
return c.json({ error: "verification_failed", message: "credential is missing" }, 400);
|
|
629
|
+
}
|
|
630
|
+
const nonceClaims = await readToken(await keys, issuer, NONCE_TYP, getCookie2(c, NONCE_COOKIE));
|
|
631
|
+
if (!nonceClaims) {
|
|
632
|
+
return c.json({ error: "verification_failed", message: "nonce cookie missing or expired" }, 401);
|
|
633
|
+
}
|
|
634
|
+
const result = await verifyGoogleIdToken(body2.credential, {
|
|
635
|
+
clientId: google.clientId,
|
|
636
|
+
nonce: nonceClaims.nonce,
|
|
637
|
+
keys: config.jwksResolver
|
|
638
|
+
});
|
|
639
|
+
if (!result.ok) {
|
|
640
|
+
return c.json({ error: result.error, message: result.reason }, result.error === "email_unverified" ? 403 : 401);
|
|
641
|
+
}
|
|
642
|
+
deleteCookie2(c, NONCE_COOKIE, { path: COOKIE_PATH2, secure });
|
|
643
|
+
const resolved = await resolveProviderUser(ctx, GOOGLE, toProfile(result.profile), localeOf(body2.locale));
|
|
644
|
+
if (!resolved.ok) {
|
|
645
|
+
if (resolved.error === "other_method") return c.json({ error: resolved.error, message: resolved.message, methods: resolved.methods }, 403);
|
|
646
|
+
return c.json({ error: resolved.error, message: resolved.message }, resolved.error === "signup_rejected" ? 403 : 503);
|
|
647
|
+
}
|
|
648
|
+
return signInAnswer(c, await ctx.secondStep(c, resolved.user, ["google"], "google", result.profile));
|
|
649
|
+
});
|
|
650
|
+
const requireCodeFlow = async (c, next) => {
|
|
651
|
+
if (!google.clientSecret) return c.json({ error: "not_found" }, 404);
|
|
652
|
+
await next();
|
|
653
|
+
};
|
|
654
|
+
app.get("/auth/google/start", requireCodeFlow, async (c) => {
|
|
655
|
+
const returnUrl = ctx.allowedUrl(c.req.query("return_to"));
|
|
656
|
+
if (!returnUrl) return c.text("return_to must be an absolute URL whose origin is in ALLOWED_ORIGINS", 400);
|
|
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);
|
|
659
|
+
const url = new URL(GOOGLE_AUTH_ENDPOINT);
|
|
660
|
+
url.search = new URLSearchParams({
|
|
661
|
+
client_id: google.clientId,
|
|
662
|
+
redirect_uri: callbackUrl,
|
|
663
|
+
response_type: "code",
|
|
664
|
+
scope: "openid email profile",
|
|
665
|
+
state: oauth.state,
|
|
666
|
+
nonce: oauth.nonce,
|
|
667
|
+
code_challenge: await pkceChallenge(oauth.verifier),
|
|
668
|
+
code_challenge_method: "S256"
|
|
669
|
+
}).toString();
|
|
670
|
+
return c.redirect(url.href, 302);
|
|
671
|
+
});
|
|
672
|
+
app.get("/auth/google/callback", requireCodeFlow, async (c) => {
|
|
673
|
+
const oauth = await takeOAuthState(c, ctx, COOKIE_PATH2);
|
|
674
|
+
if (!oauth) return c.text("Sign-in expired or was started in another browser. Please try again.", 400);
|
|
675
|
+
const back = redirectBack(c, oauth.returnTo, "google");
|
|
676
|
+
if (!await ctx.enabled(c, "google")) return back("method_disabled");
|
|
677
|
+
if (c.req.query("error")) return back(c.req.query("error") === "access_denied" ? "cancelled" : "verification_failed");
|
|
678
|
+
const code = c.req.query("code");
|
|
679
|
+
if (!code || c.req.query("state") !== oauth.state) return back("verification_failed");
|
|
680
|
+
let idToken;
|
|
681
|
+
try {
|
|
682
|
+
const res = await fetch(GOOGLE_TOKEN_ENDPOINT, {
|
|
683
|
+
method: "POST",
|
|
684
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
685
|
+
body: new URLSearchParams({
|
|
686
|
+
code,
|
|
687
|
+
client_id: google.clientId,
|
|
688
|
+
client_secret: google.clientSecret,
|
|
689
|
+
redirect_uri: callbackUrl,
|
|
690
|
+
grant_type: "authorization_code",
|
|
691
|
+
code_verifier: oauth.verifier
|
|
692
|
+
})
|
|
693
|
+
});
|
|
694
|
+
if (!res.ok) return back("verification_failed");
|
|
695
|
+
({ id_token: idToken } = await res.json());
|
|
696
|
+
} catch {
|
|
697
|
+
return back("verification_failed");
|
|
698
|
+
}
|
|
699
|
+
if (typeof idToken !== "string") return back("verification_failed");
|
|
700
|
+
const result = await verifyGoogleIdToken(idToken, {
|
|
701
|
+
clientId: google.clientId,
|
|
702
|
+
nonce: oauth.nonce,
|
|
703
|
+
keys: config.jwksResolver
|
|
704
|
+
});
|
|
705
|
+
if (!result.ok) return back(result.error);
|
|
706
|
+
const resolved = await resolveProviderUser(ctx, GOOGLE, toProfile(result.profile), oauth.locale);
|
|
707
|
+
if (!resolved.ok) return back(resolved.error);
|
|
708
|
+
const step = await ctx.secondStep(c, resolved.user, ["google"], "google", result.profile);
|
|
709
|
+
if ("disabled" in step) return back("method_disabled");
|
|
710
|
+
if ("next" in step) return back(void 0, step.next, step.factors);
|
|
711
|
+
return back();
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// src/routes/lockout.ts
|
|
716
|
+
var FREE_ATTEMPTS = 5;
|
|
717
|
+
var MAX_LOCK_MS = 15 * 60 * 1e3;
|
|
718
|
+
async function countSignInAttempt(c, users, account, now = Date.now()) {
|
|
719
|
+
const tooMany = (message) => c.json({ error: "too_many_attempts", message }, 429);
|
|
720
|
+
if (account.lockedUntil > now) {
|
|
721
|
+
const seconds = Math.ceil((account.lockedUntil - now) / 1e3);
|
|
722
|
+
return tooMany(`Too many failed attempts. Try again in ${seconds} seconds.`);
|
|
723
|
+
}
|
|
724
|
+
const failed = account.failedAttempts + 1;
|
|
725
|
+
const lockedUntil = failed >= FREE_ATTEMPTS ? now + Math.min(2 ** (failed - FREE_ATTEMPTS) * 1e3, MAX_LOCK_MS) : 0;
|
|
726
|
+
if (!await users.countAttempt(account, { failedAttempts: failed, lockedUntil })) {
|
|
727
|
+
return tooMany("Too many attempts at once. Please try again.");
|
|
728
|
+
}
|
|
729
|
+
return null;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// src/routes/password.ts
|
|
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
|
+
};
|
|
743
|
+
const emails = createEmails(ctx, users, webhook);
|
|
744
|
+
app.use("/auth/password/*", async (c, next) => {
|
|
745
|
+
if (!await ctx.enabled(c, "password")) return error(c, 403, "method_disabled", "E-mail & password sign-in is switched off.");
|
|
746
|
+
await next();
|
|
747
|
+
});
|
|
748
|
+
app.post("/auth/password/signin", async (c) => {
|
|
749
|
+
const data = await body(c);
|
|
750
|
+
const wrong = () => error(c, 401, "invalid_credentials", "E-mail or password is wrong.");
|
|
751
|
+
const pw = typeof data.password === "string" ? data.password : "";
|
|
752
|
+
if (!isValidEmail(data.email) || !pw || pw.length > MAX_PASSWORD_LENGTH * 4) return wrong();
|
|
753
|
+
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
754
|
+
const account = user ? await users.passwordAccount(user.id) : null;
|
|
755
|
+
if (!user || !account?.secret) {
|
|
756
|
+
await verifyAgainstDummy(pw);
|
|
757
|
+
return wrong();
|
|
758
|
+
}
|
|
759
|
+
const refused2 = await countSignInAttempt(c, users, account);
|
|
760
|
+
if (refused2) return refused2;
|
|
761
|
+
const { ok, needsRehash } = await verifyPassword(pw, account.secret);
|
|
762
|
+
if (!ok) return wrong();
|
|
763
|
+
await users.updateAccount(account.id, {
|
|
764
|
+
failedAttempts: 0,
|
|
765
|
+
lockedUntil: 0,
|
|
766
|
+
...needsRehash ? { secret: await hashPassword(pw) } : {}
|
|
767
|
+
});
|
|
768
|
+
if (!user.emailVerified) {
|
|
769
|
+
return error(c, 403, "email_unverified", "Please confirm your e-mail address first. We can send the e-mail again.");
|
|
770
|
+
}
|
|
771
|
+
return signInAnswer(c, await ctx.secondStep(c, user, ["pwd"], "password"));
|
|
772
|
+
});
|
|
773
|
+
app.post("/auth/password/signup", async (c) => {
|
|
774
|
+
const data = await body(c);
|
|
775
|
+
const target = redirectTarget(ctx, c, data.redirectTo);
|
|
776
|
+
if (target instanceof Response) return target;
|
|
777
|
+
if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
|
|
778
|
+
const name = nameOf(data.name);
|
|
779
|
+
const email = data.email.trim();
|
|
780
|
+
const weak = await refused(c, data.password, [email, name]);
|
|
781
|
+
if (weak) return weak;
|
|
782
|
+
const locale = localeOf(data.locale);
|
|
783
|
+
const check = await ctx.checkSignup({ email, name: name ?? void 0, locale, method: "password" });
|
|
784
|
+
if (!check.ok) return check.reason === "rejected" ? error(c, 403, "signup_rejected", check.message) : unavailable(c);
|
|
785
|
+
const passwordHash = await hashPassword(data.password);
|
|
786
|
+
const existing = await users.findByEmail(normalizeEmail(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);
|
|
789
|
+
}
|
|
790
|
+
if (existing) {
|
|
791
|
+
if (Date.now() - existing.lastMailAt < MAIL_INTERVAL_MS) return accepted(c);
|
|
792
|
+
if (!existing.emailVerified) await users.dropUnprovenAccounts(existing.id, "password");
|
|
793
|
+
const account = await users.passwordAccount(existing.id);
|
|
794
|
+
if (account) await users.updateAccount(account.id, { secret: passwordHash, failedAttempts: 0, lockedUntil: 0 });
|
|
795
|
+
else if (!await users.linkAccount(existing.id, { key: passwordAccountKey(existing.id), secret: passwordHash })) return accepted(c);
|
|
796
|
+
await users.reopenSignUp(existing);
|
|
797
|
+
await users.updateUser(existing.id, { name });
|
|
798
|
+
return await emails.sendLinkEmail({ ...existing, name }, "verify", target, locale) ? accepted(c) : unavailable(c);
|
|
799
|
+
}
|
|
800
|
+
const user = await users.createUser({
|
|
801
|
+
email,
|
|
802
|
+
emailNormalized: normalizeEmail(email),
|
|
803
|
+
name,
|
|
804
|
+
emailVerified: false,
|
|
805
|
+
account: (userId) => ({ key: passwordAccountKey(userId), secret: passwordHash })
|
|
806
|
+
});
|
|
807
|
+
if (!user) return accepted(c);
|
|
808
|
+
return await emails.sendLinkEmail(user, "verify", target, locale) ? accepted(c) : unavailable(c);
|
|
809
|
+
});
|
|
810
|
+
app.post("/auth/password/send-reset", async (c) => {
|
|
811
|
+
const data = await body(c);
|
|
812
|
+
const target = redirectTarget(ctx, c, data.redirectTo);
|
|
813
|
+
if (target instanceof Response) return target;
|
|
814
|
+
if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
|
|
815
|
+
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
816
|
+
const locale = localeOf(data.locale);
|
|
817
|
+
if (user && await users.passwordAccount(user.id)) {
|
|
818
|
+
if (!await emails.sendLinkEmail(user, "reset", target, locale)) return unavailable(c);
|
|
819
|
+
} else if (user) {
|
|
820
|
+
if (!await emails.sendAccountEmail(user, "email.send_password_reset_no_password", target, locale)) return unavailable(c);
|
|
821
|
+
}
|
|
822
|
+
return accepted(c);
|
|
823
|
+
});
|
|
824
|
+
app.post("/auth/password/reset", async (c) => {
|
|
825
|
+
const data = await body(c);
|
|
826
|
+
const weak = await refused(c, data.password, [isValidEmail(data.email) ? data.email.trim() : null]);
|
|
827
|
+
if (weak) return weak;
|
|
828
|
+
const consumed = await emails.consume(data, "reset");
|
|
829
|
+
const user = consumed.userId ? await users.findById(consumed.userId) : null;
|
|
830
|
+
const account = user ? await users.passwordAccount(user.id) : null;
|
|
831
|
+
if (!user || !account) return emails.invalidVerification(c, consumed);
|
|
832
|
+
const secret = await hashPassword(data.password);
|
|
833
|
+
if (!await users.updateAccount(account.id, { secret, failedAttempts: 0, lockedUntil: 0 })) return emails.invalidVerification(c, consumed);
|
|
834
|
+
const sessionVersion = user.sessionVersion + 1;
|
|
835
|
+
await users.updateUser(user.id, { emailVerified: true, sessionVersion, wrongCodes: 0 });
|
|
836
|
+
await users.clearVerifications(user.id);
|
|
837
|
+
const reset = { ...user.emailVerified ? user : await users.reopenSignUp(user), emailVerified: true, sessionVersion, wrongCodes: 0 };
|
|
838
|
+
await ctx.emit("user.password_reset", { user: toMadauthUser(reset) });
|
|
839
|
+
return signInAnswer(c, await ctx.secondStep(c, reset, ["pwd"], "password"));
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// src/routes/challenge.ts
|
|
844
|
+
import { deleteCookie as deleteCookie3, getCookie as getCookie3, setCookie as setCookie3 } from "hono/cookie";
|
|
845
|
+
var CHALLENGE_COOKIE = "madauth_challenge";
|
|
846
|
+
var CHALLENGE_TYP = "madauth-challenge+jwt";
|
|
847
|
+
var CHALLENGE_TTL = 10 * 60;
|
|
848
|
+
var COOKIE_PATH3 = "/auth";
|
|
849
|
+
var LEGACY_COOKIE_PATH = "/auth/totp";
|
|
850
|
+
var cookieOptions = (secure) => ({ path: COOKIE_PATH3, httpOnly: true, secure, sameSite: "Strict" });
|
|
851
|
+
async function setChallenge(c, ctx, challenge) {
|
|
852
|
+
const token = await signToken(await ctx.keys, ctx.config.issuer, CHALLENGE_TYP, { ...challenge }, CHALLENGE_TTL);
|
|
853
|
+
setCookie3(c, CHALLENGE_COOKIE, token, { ...cookieOptions(ctx.secure), maxAge: CHALLENGE_TTL });
|
|
854
|
+
}
|
|
855
|
+
function clearChallenge(c, secure) {
|
|
856
|
+
deleteCookie3(c, CHALLENGE_COOKIE, cookieOptions(secure));
|
|
857
|
+
deleteCookie3(c, CHALLENGE_COOKIE, { ...cookieOptions(secure), path: LEGACY_COOKIE_PATH });
|
|
858
|
+
}
|
|
859
|
+
async function readChallenge(c, ctx, step) {
|
|
860
|
+
const claims = await readToken(await ctx.keys, ctx.config.issuer, CHALLENGE_TYP, getCookie3(c, CHALLENGE_COOKIE));
|
|
861
|
+
if (!claims || typeof claims.sub !== "string" || !Array.isArray(claims.amr)) return null;
|
|
862
|
+
const factors = Array.isArray(claims.factors) ? claims.factors : [];
|
|
863
|
+
const fits = step === "totp-setup" ? claims.next === "totp-setup" : factors.includes(step);
|
|
864
|
+
return fits ? claims : null;
|
|
865
|
+
}
|
|
866
|
+
var methodStillOn = async (c, ctx, challenge) => isOn(challenge.method, ctx.configured, await ctx.methods(c));
|
|
867
|
+
var methodOff = (c) => error(c, 403, "method_disabled", "This way of signing in is switched off.");
|
|
868
|
+
async function pendingOrSignedIn(c, ctx) {
|
|
869
|
+
const challenge = await readChallenge(c, ctx, "totp-setup");
|
|
870
|
+
const waiting = challenge ? await ctx.users.findById(challenge.sub) : null;
|
|
871
|
+
if (challenge && waiting && waiting.sessionVersion === challenge.sv) return { user: waiting, challenge };
|
|
872
|
+
const session = await ctx.currentUser(c);
|
|
873
|
+
const user = session ? await ctx.users.findById(session.id) : null;
|
|
874
|
+
return user ? { user, challenge: null } : null;
|
|
875
|
+
}
|
|
876
|
+
async function finishSignIn(c, ctx, user, challenge, amr, via) {
|
|
877
|
+
const result = await ctx.startSession(c, toMadauthUser(user, challenge.profile), [...challenge.amr, amr], { method: challenge.method, signedUpAt: user.signedUpAt }, { sv: user.sessionVersion });
|
|
878
|
+
await ctx.emit("user.signed_in", { user: result, method: challenge.method, ...challenge.amr.length ? { secondFactor: via } : {} });
|
|
879
|
+
return result;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// src/routes/sms.ts
|
|
883
|
+
import { deleteCookie as deleteCookie4, getCookie as getCookie4, setCookie as setCookie4 } from "hono/cookie";
|
|
884
|
+
var SMS_TTL_MS = 10 * 60 * 1e3;
|
|
885
|
+
var SETUP_COOKIE = "madauth_sms_setup";
|
|
886
|
+
var SETUP_TYP = "madauth-sms-setup+jwt";
|
|
887
|
+
var SETUP_TTL = 10 * 60;
|
|
888
|
+
var ROUTE_PATH = "/auth/sms";
|
|
889
|
+
function normalizePhone(value) {
|
|
890
|
+
if (typeof value !== "string") return null;
|
|
891
|
+
const digits = value.replace(/[\s().-]/g, "");
|
|
892
|
+
return /^\+[1-9]\d{6,14}$/.test(digits) ? digits : null;
|
|
893
|
+
}
|
|
894
|
+
function maskPhone(phone) {
|
|
895
|
+
return `${phone.slice(0, 3)} ${"\u2022".repeat(Math.max(0, phone.length - 5))} ${phone.slice(-2)}`;
|
|
896
|
+
}
|
|
897
|
+
function smsRoutes(app, ctx, users, webhook) {
|
|
898
|
+
const { config, secure } = ctx;
|
|
899
|
+
const cookie = { path: ROUTE_PATH, httpOnly: true, secure, sameSite: "Strict" };
|
|
900
|
+
app.use(`${ROUTE_PATH}/*`, async (c, next) => {
|
|
901
|
+
if (!isOn("sms", ctx.configured, await ctx.methods(c))) return error(c, 403, "method_disabled", "SMS codes are switched off.");
|
|
902
|
+
await next();
|
|
903
|
+
});
|
|
904
|
+
const expired = (c) => error(c, 401, "challenge_expired", "The sign-in has expired. Please sign in again.");
|
|
905
|
+
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.");
|
|
906
|
+
const sendCode = async (c, user, phone, purpose, locale) => {
|
|
907
|
+
const { code } = await users.issueVerification(user.id, "sms", SMS_TTL_MS);
|
|
908
|
+
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 } };
|
|
909
|
+
const result = await webhook.call("sms.send_code", data, WEBHOOK_TIMEOUT_MS);
|
|
910
|
+
if (!result.ok) {
|
|
911
|
+
console.error(`[madauth] Webhook "sms.send_code" for user ${user.id} failed: ${result.reason}`);
|
|
912
|
+
return false;
|
|
913
|
+
}
|
|
914
|
+
await users.updateUser(user.id, { lastSmsAt: Date.now() });
|
|
915
|
+
return true;
|
|
916
|
+
};
|
|
917
|
+
const sentRecently = (user) => Date.now() - (user.lastSmsAt ?? 0) < MAIL_INTERVAL_MS;
|
|
918
|
+
const checkCode2 = async (c, user, account, data) => {
|
|
919
|
+
if (account) {
|
|
920
|
+
const refused = await countSignInAttempt(c, users, account);
|
|
921
|
+
if (refused) return refused;
|
|
922
|
+
}
|
|
923
|
+
const code = typeof data.code === "string" ? data.code.replace(/\s/g, "") : "";
|
|
924
|
+
const { userId } = code ? await users.consumeCode(user, "sms", code) : { userId: null };
|
|
925
|
+
if (!userId) return error(c, 401, "code_invalid", "The code is wrong or has expired.");
|
|
926
|
+
if (account) await users.updateAccount(account.id, { failedAttempts: 0, lockedUntil: 0 });
|
|
927
|
+
return null;
|
|
928
|
+
};
|
|
929
|
+
const whoSetsUp = async (c) => {
|
|
930
|
+
const who = await pendingOrSignedIn(c, ctx);
|
|
931
|
+
if (!who || who.challenge && !who.challenge.amr.length) return { answer: noSession(c) };
|
|
932
|
+
if (who.challenge && !await methodStillOn(c, ctx, who.challenge)) return { answer: methodOff(c) };
|
|
933
|
+
return who;
|
|
934
|
+
};
|
|
935
|
+
app.post(`${ROUTE_PATH}/setup`, async (c) => {
|
|
936
|
+
const who = await whoSetsUp(c);
|
|
937
|
+
if ("answer" in who) return who.answer;
|
|
938
|
+
const { user } = who;
|
|
939
|
+
const data = await body(c);
|
|
940
|
+
const phone = normalizePhone(data.phone);
|
|
941
|
+
if (!phone) return error(c, 400, "invalid_phone", "Enter a mobile number with the country code, e.g. +41 79 123 45 67.");
|
|
942
|
+
const existing = await users.smsAccount(user.id);
|
|
943
|
+
let approved;
|
|
944
|
+
let justApproved = false;
|
|
945
|
+
if (existing) {
|
|
946
|
+
const previous = await readToken(await ctx.keys, config.issuer, SETUP_TYP, getCookie4(c, SETUP_COOKIE));
|
|
947
|
+
const holds = previous?.sub === user.id && typeof previous.approved === "number" && Date.now() - previous.approved < SETUP_TTL * 1e3;
|
|
948
|
+
if (holds) {
|
|
949
|
+
approved = previous.approved;
|
|
950
|
+
} else {
|
|
951
|
+
if (data.code === void 0) return error(c, 401, "code_required", "Enter the code we sent to your current number to replace it.");
|
|
952
|
+
const wrong = await checkCode2(c, user, existing, data);
|
|
953
|
+
if (wrong) return wrong;
|
|
954
|
+
approved = Date.now();
|
|
955
|
+
justApproved = true;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
if (!justApproved && sentRecently(user)) return tooSoon(c);
|
|
959
|
+
if (!await sendCode(c, user, phone, "setup", localeOf(data.locale))) return smsUnavailable(c);
|
|
960
|
+
const token = await signToken(await ctx.keys, config.issuer, SETUP_TYP, { sub: user.id, phone, ...approved ? { approved } : {} }, SETUP_TTL);
|
|
961
|
+
setCookie4(c, SETUP_COOKIE, token, { ...cookie, maxAge: SETUP_TTL });
|
|
962
|
+
return c.json({ phone: maskPhone(phone) }, 202);
|
|
963
|
+
});
|
|
964
|
+
app.post(`${ROUTE_PATH}/confirm`, async (c) => {
|
|
965
|
+
const who = await whoSetsUp(c);
|
|
966
|
+
if ("answer" in who) return who.answer;
|
|
967
|
+
const { user, challenge } = who;
|
|
968
|
+
const setup = await readToken(await ctx.keys, config.issuer, SETUP_TYP, getCookie4(c, SETUP_COOKIE));
|
|
969
|
+
if (!setup || setup.sub !== user.id || typeof setup.phone !== "string") {
|
|
970
|
+
return error(c, 400, "setup_expired", "The setup has expired or was started in another browser. Please start again.");
|
|
971
|
+
}
|
|
972
|
+
const existing = await users.smsAccount(user.id);
|
|
973
|
+
if (existing && typeof setup.approved !== "number") {
|
|
974
|
+
deleteCookie4(c, SETUP_COOKIE, cookie);
|
|
975
|
+
return error(c, 400, "setup_expired", "Your phone number was changed elsewhere meanwhile. Please start again.");
|
|
976
|
+
}
|
|
977
|
+
const wrong = await checkCode2(c, user, existing, await body(c));
|
|
978
|
+
if (wrong) return wrong;
|
|
979
|
+
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 });
|
|
980
|
+
deleteCookie4(c, SETUP_COOKIE, cookie);
|
|
981
|
+
if (!written) return error(c, 400, "setup_expired", "Your phone number was changed elsewhere meanwhile. Please start again.");
|
|
982
|
+
await ctx.emit("user.second_factor_enabled", { user: toMadauthUser(user), method: "sms", phone: setup.phone });
|
|
983
|
+
if (!challenge) return c.json({});
|
|
984
|
+
return c.json({ user: await finishSignIn(c, ctx, user, challenge, "sms", "sms") });
|
|
985
|
+
});
|
|
986
|
+
app.post(`${ROUTE_PATH}/send`, async (c) => {
|
|
987
|
+
const data = await body(c);
|
|
988
|
+
const challenge = await readChallenge(c, ctx, "sms");
|
|
989
|
+
let user;
|
|
990
|
+
if (challenge) {
|
|
991
|
+
if (!await methodStillOn(c, ctx, challenge)) return methodOff(c);
|
|
992
|
+
const waiting = await users.findById(challenge.sub);
|
|
993
|
+
user = waiting && waiting.sessionVersion === challenge.sv ? waiting : null;
|
|
994
|
+
if (!user) return expired(c);
|
|
995
|
+
} else {
|
|
996
|
+
const session = await ctx.currentUser(c);
|
|
997
|
+
user = session ? await users.findById(session.id) : null;
|
|
998
|
+
if (!user) return noSession(c);
|
|
999
|
+
}
|
|
1000
|
+
const account = await users.smsAccount(user.id);
|
|
1001
|
+
if (!account?.phone) return challenge ? expired(c) : error(c, 400, "no_phone", "No phone number is set up.");
|
|
1002
|
+
if (sentRecently(user)) return tooSoon(c);
|
|
1003
|
+
if (!await sendCode(c, user, account.phone, challenge ? "signin" : "approve", localeOf(data.locale))) return smsUnavailable(c);
|
|
1004
|
+
return c.json({ phone: maskPhone(account.phone) }, 202);
|
|
1005
|
+
});
|
|
1006
|
+
app.post(`${ROUTE_PATH}/verify`, async (c) => {
|
|
1007
|
+
const challenge = await readChallenge(c, ctx, "sms");
|
|
1008
|
+
if (!challenge) return expired(c);
|
|
1009
|
+
if (!await methodStillOn(c, ctx, challenge)) return methodOff(c);
|
|
1010
|
+
const user = await users.findById(challenge.sub);
|
|
1011
|
+
const account = user && user.sessionVersion === challenge.sv ? await users.smsAccount(user.id) : null;
|
|
1012
|
+
if (!user || !account) return expired(c);
|
|
1013
|
+
const wrong = await checkCode2(c, user, account, await body(c));
|
|
1014
|
+
if (wrong) return wrong;
|
|
1015
|
+
return c.json({ user: await finishSignIn(c, ctx, user, challenge, "sms", "sms") });
|
|
1016
|
+
});
|
|
1017
|
+
app.post(`${ROUTE_PATH}/remove`, async (c) => {
|
|
1018
|
+
const session = await ctx.currentUser(c);
|
|
1019
|
+
const user = session ? await users.findById(session.id) : null;
|
|
1020
|
+
if (!user) return noSession(c);
|
|
1021
|
+
const account = await users.smsAccount(user.id);
|
|
1022
|
+
if (!account) return error(c, 400, "no_phone", "No phone number is set up.");
|
|
1023
|
+
const methods = await ctx.methods(c);
|
|
1024
|
+
const required = METHODS_WITH_SECOND_FACTOR.some((method) => isOn(method, ctx.configured, methods) && policyOf(method, methods) === "required");
|
|
1025
|
+
if (required && !await users.totpAccount(user.id)) {
|
|
1026
|
+
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.");
|
|
1027
|
+
}
|
|
1028
|
+
const wrong = await checkCode2(c, user, account, await body(c));
|
|
1029
|
+
if (wrong) return wrong;
|
|
1030
|
+
await users.removeSms(user.id);
|
|
1031
|
+
await ctx.emit("user.second_factor_disabled", { user: toMadauthUser(user), method: "sms" });
|
|
1032
|
+
return c.json({});
|
|
1033
|
+
});
|
|
1034
|
+
app.get(`${ROUTE_PATH}/status`, async (c) => {
|
|
1035
|
+
const session = await ctx.currentUser(c);
|
|
1036
|
+
if (!session) return noSession(c);
|
|
1037
|
+
const account = await users.smsAccount(session.id);
|
|
1038
|
+
return c.json({ enabled: !!account?.phone, phone: account?.phone ? maskPhone(account.phone) : null });
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// src/routes/totp.ts
|
|
1043
|
+
import { deleteCookie as deleteCookie5, getCookie as getCookie5, setCookie as setCookie5 } from "hono/cookie";
|
|
1044
|
+
|
|
1045
|
+
// src/totp.ts
|
|
1046
|
+
import { createHmac, randomBytes as randomBytes2, randomInt } from "crypto";
|
|
1047
|
+
var TOTP_STEP_SECONDS = 30;
|
|
1048
|
+
var TOTP_DIGITS = 6;
|
|
1049
|
+
var TOTP_WINDOW = 1;
|
|
1050
|
+
var RECOVERY_CODES = 10;
|
|
1051
|
+
var SECRET_BYTES = 20;
|
|
1052
|
+
var BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
1053
|
+
var RECOVERY_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
|
|
1054
|
+
var RECOVERY_CODE_LENGTH = 10;
|
|
1055
|
+
function base32Encode(bytes) {
|
|
1056
|
+
let bits = 0;
|
|
1057
|
+
let value = 0;
|
|
1058
|
+
let out = "";
|
|
1059
|
+
for (const byte of bytes) {
|
|
1060
|
+
value = (value << 8 | byte) & 65535;
|
|
1061
|
+
bits += 8;
|
|
1062
|
+
while (bits >= 5) {
|
|
1063
|
+
out += BASE32[value >>> bits - 5 & 31];
|
|
1064
|
+
bits -= 5;
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
if (bits > 0) out += BASE32[value << 5 - bits & 31];
|
|
1068
|
+
return out;
|
|
1069
|
+
}
|
|
1070
|
+
function hotp(secret, counter, digits = TOTP_DIGITS) {
|
|
1071
|
+
const message = Buffer.alloc(8);
|
|
1072
|
+
message.writeBigUInt64BE(BigInt(counter));
|
|
1073
|
+
const digest = createHmac("sha1", secret).update(message).digest();
|
|
1074
|
+
const offset = digest[digest.length - 1] & 15;
|
|
1075
|
+
const binary = (digest[offset] & 127) << 24 | digest[offset + 1] << 16 | digest[offset + 2] << 8 | digest[offset + 3];
|
|
1076
|
+
return (binary % 10 ** digits).toString().padStart(digits, "0");
|
|
1077
|
+
}
|
|
1078
|
+
function stepOf(nowMs) {
|
|
1079
|
+
return Math.floor(nowMs / 1e3 / TOTP_STEP_SECONDS);
|
|
1080
|
+
}
|
|
1081
|
+
function totp(secret, step, digits = TOTP_DIGITS) {
|
|
1082
|
+
return hotp(secret, step, digits);
|
|
1083
|
+
}
|
|
1084
|
+
function verifyTotp(secret, code, nowMs, window = TOTP_WINDOW) {
|
|
1085
|
+
const given = code.replace(/\s/g, "");
|
|
1086
|
+
if (!new RegExp(`^\\d{${TOTP_DIGITS}}$`).test(given)) return null;
|
|
1087
|
+
const now = stepOf(nowMs);
|
|
1088
|
+
let found = null;
|
|
1089
|
+
for (let step = now - window; step <= now + window; step++) {
|
|
1090
|
+
if (safeEqual(totp(secret, step), given) && found === null) found = step;
|
|
1091
|
+
}
|
|
1092
|
+
return found;
|
|
1093
|
+
}
|
|
1094
|
+
function generateTotpSecret() {
|
|
1095
|
+
return randomBytes2(SECRET_BYTES);
|
|
1096
|
+
}
|
|
1097
|
+
function otpauthUri(options) {
|
|
1098
|
+
const issuer = encodeURIComponent(options.issuer);
|
|
1099
|
+
const account = encodeURIComponent(options.account);
|
|
1100
|
+
const secret = base32Encode(options.secret);
|
|
1101
|
+
return `otpauth://totp/${issuer}:${account}?secret=${secret}&issuer=${issuer}&algorithm=SHA1&digits=${TOTP_DIGITS}&period=${TOTP_STEP_SECONDS}`;
|
|
1102
|
+
}
|
|
1103
|
+
function deriveTotpKey(signingKeyD) {
|
|
1104
|
+
return deriveKey(signingKeyD, "madauth totp secret");
|
|
1105
|
+
}
|
|
1106
|
+
function generateRecoveryCodes() {
|
|
1107
|
+
const codes = /* @__PURE__ */ new Set();
|
|
1108
|
+
while (codes.size < RECOVERY_CODES) {
|
|
1109
|
+
let code = "";
|
|
1110
|
+
for (let i = 0; i < RECOVERY_CODE_LENGTH; i++) code += RECOVERY_ALPHABET[randomInt(RECOVERY_ALPHABET.length)];
|
|
1111
|
+
codes.add(`${code.slice(0, 5)}-${code.slice(5)}`);
|
|
1112
|
+
}
|
|
1113
|
+
return [...codes];
|
|
1114
|
+
}
|
|
1115
|
+
function normalizeRecoveryCode(text) {
|
|
1116
|
+
if (typeof text !== "string") return null;
|
|
1117
|
+
const code = text.toLowerCase().replace(/[\s-]/g, "");
|
|
1118
|
+
return code.length === RECOVERY_CODE_LENGTH && [...code].every((char) => RECOVERY_ALPHABET.includes(char)) ? code : null;
|
|
1119
|
+
}
|
|
1120
|
+
function hashRecoveryCode(codeKey, code) {
|
|
1121
|
+
return hashCode(codeKey, `recovery:${code}`);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// src/routes/totp.ts
|
|
1125
|
+
var SETUP_COOKIE2 = "madauth_totp_setup";
|
|
1126
|
+
var SETUP_TYP2 = "madauth-totp-setup+jwt";
|
|
1127
|
+
var SETUP_TTL2 = 10 * 60;
|
|
1128
|
+
var COOKIE_PATH4 = "/auth/totp";
|
|
1129
|
+
var cookieOptions2 = (secure) => ({ path: COOKIE_PATH4, httpOnly: true, secure, sameSite: "Strict" });
|
|
1130
|
+
async function checkCode(c, ctx, keys, user, account, data, wrong = () => error(c, 401, "code_invalid", "The code is wrong or was already used.")) {
|
|
1131
|
+
const { users } = ctx;
|
|
1132
|
+
const refused = await countSignInAttempt(c, users, account);
|
|
1133
|
+
if (refused) return refused;
|
|
1134
|
+
if (typeof data.recoveryCode === "string") {
|
|
1135
|
+
const code = normalizeRecoveryCode(data.recoveryCode);
|
|
1136
|
+
if (!code || !await users.consumeRecoveryCode(account, hashRecoveryCode(keys.codeKey, code))) return wrong();
|
|
1137
|
+
await users.updateAccount(account.id, { failedAttempts: 0, lockedUntil: 0 });
|
|
1138
|
+
const remaining = await users.countRecoveryCodes(account);
|
|
1139
|
+
await ctx.emit("user.recovery_code_used", { user: toMadauthUser(user), remaining });
|
|
1140
|
+
return "recovery_code";
|
|
1141
|
+
}
|
|
1142
|
+
if (typeof data.code !== "string" || !account.secret) return wrong();
|
|
1143
|
+
const secret = decrypt(keys.totpKey, account.secret);
|
|
1144
|
+
if (!secret) {
|
|
1145
|
+
console.error(`[madauth] The authenticator secret of user ${user.id} can't be read: was MADAUTH_SIGNING_KEY changed?`);
|
|
1146
|
+
return wrong();
|
|
1147
|
+
}
|
|
1148
|
+
const step = verifyTotp(secret, data.code, Date.now(), TOTP_WINDOW);
|
|
1149
|
+
if (step === null || step <= (account.lastUsedStep ?? 0) || !await users.useTotpStep(account, step)) return wrong();
|
|
1150
|
+
return "totp";
|
|
1151
|
+
}
|
|
1152
|
+
function totpRoutes(app, ctx, keys) {
|
|
1153
|
+
const { config, secure, users } = ctx;
|
|
1154
|
+
const { issuer } = config;
|
|
1155
|
+
const cookie = cookieOptions2(secure);
|
|
1156
|
+
const methodsNow = (c) => ctx.methods(c);
|
|
1157
|
+
app.use(`${COOKIE_PATH4}/*`, async (c, next) => {
|
|
1158
|
+
if (!authenticatorOn(ctx.configured, await methodsNow(c))) {
|
|
1159
|
+
return error(c, 403, "method_disabled", "The authenticator app is switched off.");
|
|
1160
|
+
}
|
|
1161
|
+
await next();
|
|
1162
|
+
});
|
|
1163
|
+
const standalone = async (c) => isOn("totp", ctx.configured, await methodsNow(c));
|
|
1164
|
+
const notStandalone = (c) => error(c, 403, "method_disabled", "Signing in with the authenticator app alone is switched off.");
|
|
1165
|
+
const signedIn = async (c) => {
|
|
1166
|
+
const user = await ctx.currentUser(c);
|
|
1167
|
+
return user ? users.findById(user.id) : null;
|
|
1168
|
+
};
|
|
1169
|
+
const whoSetsUp = (c) => pendingOrSignedIn(c, ctx);
|
|
1170
|
+
const newRecoveryCodes = () => {
|
|
1171
|
+
const recoveryCodes = generateRecoveryCodes();
|
|
1172
|
+
return { recoveryCodes, hashes: recoveryCodes.map((code) => hashRecoveryCode(keys.codeKey, normalizeRecoveryCode(code))) };
|
|
1173
|
+
};
|
|
1174
|
+
const issuerLabel = (c) => config.totpIssuer || originHost(ctx, c);
|
|
1175
|
+
app.post(`${COOKIE_PATH4}/setup`, async (c) => {
|
|
1176
|
+
const who = await whoSetsUp(c);
|
|
1177
|
+
if (!who) return noSession(c);
|
|
1178
|
+
if (who.challenge && !await methodStillOn(c, ctx, who.challenge)) return methodOff(c);
|
|
1179
|
+
const existing = await users.totpAccount(who.user.id);
|
|
1180
|
+
let approved;
|
|
1181
|
+
if (existing) {
|
|
1182
|
+
const previous = await readToken(await ctx.keys, issuer, SETUP_TYP2, getCookie5(c, SETUP_COOKIE2));
|
|
1183
|
+
const holds = previous?.sub === who.user.id && typeof previous.approved === "number" && Date.now() - previous.approved < SETUP_TTL2 * 1e3;
|
|
1184
|
+
if (holds) {
|
|
1185
|
+
approved = previous.approved;
|
|
1186
|
+
} else {
|
|
1187
|
+
const data = await body(c);
|
|
1188
|
+
if (data.code === void 0 && data.recoveryCode === void 0) {
|
|
1189
|
+
return error(c, 401, "code_required", "Enter a code from your current authenticator app (or a recovery code) to replace it.");
|
|
1190
|
+
}
|
|
1191
|
+
const via = await checkCode(c, ctx, keys, who.user, existing, data);
|
|
1192
|
+
if (via instanceof Response) return via;
|
|
1193
|
+
approved = Date.now();
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
const secret = generateTotpSecret();
|
|
1197
|
+
const claims = {
|
|
1198
|
+
sub: who.user.id,
|
|
1199
|
+
setup: newId("tsu"),
|
|
1200
|
+
secret: encrypt(keys.totpKey, secret),
|
|
1201
|
+
...existing ? { replaces: existing.setupId } : {},
|
|
1202
|
+
...approved ? { approved } : {}
|
|
1203
|
+
};
|
|
1204
|
+
const token = await signToken(await ctx.keys, issuer, SETUP_TYP2, claims, SETUP_TTL2);
|
|
1205
|
+
setCookie5(c, SETUP_COOKIE2, token, { ...cookie, maxAge: SETUP_TTL2 });
|
|
1206
|
+
return c.json({ secret: base32Encode(secret), uri: otpauthUri({ issuer: issuerLabel(c), account: who.user.email, secret }) });
|
|
1207
|
+
});
|
|
1208
|
+
app.post(`${COOKIE_PATH4}/confirm`, async (c) => {
|
|
1209
|
+
const who = await whoSetsUp(c);
|
|
1210
|
+
if (!who) return noSession(c);
|
|
1211
|
+
const { user, challenge } = who;
|
|
1212
|
+
if (challenge && !await methodStillOn(c, ctx, challenge)) return methodOff(c);
|
|
1213
|
+
const data = await body(c);
|
|
1214
|
+
const setup = await readToken(await ctx.keys, issuer, SETUP_TYP2, getCookie5(c, SETUP_COOKIE2));
|
|
1215
|
+
const secret = setup && setup.sub === user.id && typeof setup.secret === "string" && typeof setup.setup === "string" ? decrypt(keys.totpKey, setup.secret) : null;
|
|
1216
|
+
if (!setup || !secret) return error(c, 400, "setup_expired", "The setup has expired or was started in another browser. Please start again.");
|
|
1217
|
+
const step = typeof data.code === "string" ? verifyTotp(secret, data.code, Date.now(), TOTP_WINDOW) : null;
|
|
1218
|
+
if (step === null) return error(c, 400, "code_invalid", "The code is wrong. Enter the current code from the app.");
|
|
1219
|
+
const signUp = challenge?.method === "totp";
|
|
1220
|
+
const { recoveryCodes, hashes } = newRecoveryCodes();
|
|
1221
|
+
await users.addRecoveryCodes(user.id, setup.setup, hashes);
|
|
1222
|
+
const patch = { secret: setup.secret, setupId: setup.setup, failedAttempts: 0, lockedUntil: 0, lastUsedStep: step };
|
|
1223
|
+
const replaced = setup.replaces === void 0 ? null : await users.totpApp(user.id);
|
|
1224
|
+
const current = replaced?.account ?? null;
|
|
1225
|
+
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);
|
|
1226
|
+
if (!written) {
|
|
1227
|
+
await users.clearRecoveryCodes(user.id, { only: setup.setup });
|
|
1228
|
+
deleteCookie5(c, SETUP_COOKIE2, cookie);
|
|
1229
|
+
return error(c, 400, "setup_expired", "Your authenticator app was set up or removed elsewhere meanwhile. Please start again.");
|
|
1230
|
+
}
|
|
1231
|
+
await users.clearRecoveryCodes(user.id, { except: setup.setup });
|
|
1232
|
+
deleteCookie5(c, SETUP_COOKIE2, cookie);
|
|
1233
|
+
const secondFactor = replaced ? !replaced.signsIn : !signUp;
|
|
1234
|
+
if (secondFactor) await ctx.emit("user.second_factor_enabled", { user: toMadauthUser(user), method: "totp" });
|
|
1235
|
+
if (!challenge) return c.json({ recoveryCodes });
|
|
1236
|
+
const result = await finishSignIn(c, ctx, user, challenge, "otp", "totp");
|
|
1237
|
+
return c.json({ user: result, recoveryCodes });
|
|
1238
|
+
});
|
|
1239
|
+
app.post(`${COOKIE_PATH4}/verify`, async (c) => {
|
|
1240
|
+
const expired = () => error(c, 401, "challenge_expired", "The sign-in has expired. Please sign in again.");
|
|
1241
|
+
const challenge = await readChallenge(c, ctx, "totp");
|
|
1242
|
+
if (!challenge) return expired();
|
|
1243
|
+
if (!await methodStillOn(c, ctx, challenge)) return methodOff(c);
|
|
1244
|
+
const user = await users.findById(challenge.sub);
|
|
1245
|
+
const account = user && user.sessionVersion === challenge.sv ? await users.totpAccount(user.id) : null;
|
|
1246
|
+
if (!user || !account) return expired();
|
|
1247
|
+
const via = await checkCode(c, ctx, keys, user, account, await body(c));
|
|
1248
|
+
if (via instanceof Response) return via;
|
|
1249
|
+
return c.json({ user: await finishSignIn(c, ctx, user, challenge, "otp", via) });
|
|
1250
|
+
});
|
|
1251
|
+
app.post(`${COOKIE_PATH4}/signin`, async (c) => {
|
|
1252
|
+
if (!await standalone(c)) return notStandalone(c);
|
|
1253
|
+
const data = await body(c);
|
|
1254
|
+
const wrong = () => error(c, 401, "invalid_credentials", "E-mail or code is wrong.");
|
|
1255
|
+
if (!isValidEmail(data.email)) return wrong();
|
|
1256
|
+
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
1257
|
+
const app2 = user ? await users.totpApp(user.id) : null;
|
|
1258
|
+
const account = app2?.account;
|
|
1259
|
+
if (!user || !user.emailVerified || !app2?.signsIn || !account?.secret) return wrong();
|
|
1260
|
+
const via = await checkCode(c, ctx, keys, user, account, data, wrong);
|
|
1261
|
+
if (via instanceof Response) return via;
|
|
1262
|
+
const result = await ctx.startSession(c, toMadauthUser(user), ["otp"], { method: "totp", signedUpAt: user.signedUpAt }, { sv: user.sessionVersion });
|
|
1263
|
+
await ctx.emit("user.signed_in", { user: result, method: "totp" });
|
|
1264
|
+
return c.json({ user: result });
|
|
1265
|
+
});
|
|
1266
|
+
app.post(`${COOKIE_PATH4}/remove`, async (c) => {
|
|
1267
|
+
const user = await signedIn(c);
|
|
1268
|
+
if (!user) return noSession(c);
|
|
1269
|
+
const methods = await methodsNow(c);
|
|
1270
|
+
const required = METHODS_WITH_SECOND_FACTOR.filter((method) => isOn(method, ctx.configured, methods) && policyOf(method, methods) === "required");
|
|
1271
|
+
const smsOn = isOn("sms", ctx.configured, methods);
|
|
1272
|
+
if (required.length && !(smsOn && await users.smsAccount(user.id))) {
|
|
1273
|
+
const names = required.map((method) => ({ google: "Google", github: "GitHub", password: "e-mail & password", "email-link": "the e-mail link" })[method]);
|
|
1274
|
+
const alternative = smsOn ? " Add a phone number for SMS codes first, or" : " To stop using it,";
|
|
1275
|
+
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.`);
|
|
1276
|
+
}
|
|
1277
|
+
const app2 = await users.totpApp(user.id);
|
|
1278
|
+
if (!app2) return c.json({});
|
|
1279
|
+
const { account } = app2;
|
|
1280
|
+
if (app2.signsIn) {
|
|
1281
|
+
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.");
|
|
1282
|
+
}
|
|
1283
|
+
const via = await checkCode(c, ctx, keys, user, account, await body(c));
|
|
1284
|
+
if (via instanceof Response) return via;
|
|
1285
|
+
await users.removeTotp(user.id);
|
|
1286
|
+
await ctx.emit("user.second_factor_disabled", { user: toMadauthUser(user), method: "totp" });
|
|
1287
|
+
return c.json({});
|
|
1288
|
+
});
|
|
1289
|
+
app.post(`${COOKIE_PATH4}/recovery-codes`, async (c) => {
|
|
1290
|
+
const user = await signedIn(c);
|
|
1291
|
+
if (!user) return noSession(c);
|
|
1292
|
+
const account = await users.totpAccount(user.id);
|
|
1293
|
+
if (!account) return error(c, 400, "no_authenticator", "No authenticator app is set up.");
|
|
1294
|
+
const via = await checkCode(c, ctx, keys, user, account, await body(c));
|
|
1295
|
+
if (via instanceof Response) return via;
|
|
1296
|
+
const { recoveryCodes, hashes } = newRecoveryCodes();
|
|
1297
|
+
await users.replaceRecoveryCodes(account, hashes);
|
|
1298
|
+
return c.json({ recoveryCodes });
|
|
1299
|
+
});
|
|
1300
|
+
app.get(`${COOKIE_PATH4}/status`, async (c) => {
|
|
1301
|
+
const user = await signedIn(c);
|
|
1302
|
+
if (!user) return noSession(c);
|
|
1303
|
+
const account = await users.totpAccount(user.id);
|
|
1304
|
+
return c.json({ enabled: !!account, recoveryCodesLeft: account ? await users.countRecoveryCodes(account) : 0 });
|
|
1305
|
+
});
|
|
1306
|
+
if (ctx.webhook && ctx.emailVerification) {
|
|
1307
|
+
const emails = createEmails(ctx, users, ctx.webhook);
|
|
1308
|
+
app.post(`${COOKIE_PATH4}/signup`, async (c) => {
|
|
1309
|
+
if (!await standalone(c)) return notStandalone(c);
|
|
1310
|
+
const data = await body(c);
|
|
1311
|
+
const target = redirectTarget(ctx, c, data.redirectTo);
|
|
1312
|
+
if (target instanceof Response) return target;
|
|
1313
|
+
if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
|
|
1314
|
+
const name = nameOf(data.name);
|
|
1315
|
+
const email = data.email.trim();
|
|
1316
|
+
const locale = localeOf(data.locale);
|
|
1317
|
+
const check = await ctx.checkSignup({ email, name: name ?? void 0, locale, method: "totp" });
|
|
1318
|
+
if (!check.ok) return check.reason === "rejected" ? error(c, 403, "signup_rejected", check.message) : unavailable(c);
|
|
1319
|
+
const existing = await users.findByEmail(normalizeEmail(email));
|
|
1320
|
+
if (existing?.emailVerified && await users.hasSignInMethod(existing.id)) {
|
|
1321
|
+
return await emails.sendAccountEmail(existing, "email.send_already_registered", target, locale) ? accepted(c) : unavailable(c);
|
|
1322
|
+
}
|
|
1323
|
+
if (existing) {
|
|
1324
|
+
if (Date.now() - existing.lastMailAt < MAIL_INTERVAL_MS) return accepted(c);
|
|
1325
|
+
if (!existing.emailVerified) await users.dropUnprovenAccounts(existing.id);
|
|
1326
|
+
await users.reopenSignUp(existing);
|
|
1327
|
+
await users.updateUser(existing.id, { name });
|
|
1328
|
+
return await emails.sendLinkEmail({ ...existing, name }, "verify", target, locale) ? accepted(c) : unavailable(c);
|
|
1329
|
+
}
|
|
1330
|
+
const user = await users.createUser({ email, emailNormalized: normalizeEmail(email), name, emailVerified: false });
|
|
1331
|
+
if (!user) return accepted(c);
|
|
1332
|
+
return await emails.sendLinkEmail(user, "verify", target, locale) ? accepted(c) : unavailable(c);
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
// src/app.ts
|
|
1338
|
+
var EVENT_TIMEOUT_MS = 5e3;
|
|
1339
|
+
function createApp(config) {
|
|
1340
|
+
const { issuer, allowedOrigins, sessionTtlSeconds, renewalTtlSeconds, cookieDomain } = config;
|
|
1341
|
+
const keys = importSigningKeys(config.signingKey);
|
|
1342
|
+
const secure = issuer.startsWith("https:");
|
|
1343
|
+
const isAllowedOrigin = (origin) => !!origin && allowedOrigins.includes(origin);
|
|
1344
|
+
const codeKey = deriveCodeKey(config.signingKey.d);
|
|
1345
|
+
const users = new Users(config.store, codeKey);
|
|
1346
|
+
const methods = methodConfig(config);
|
|
1347
|
+
const settings = new Settings(config.store, initialMethods(methods));
|
|
1348
|
+
const configured = (method) => isConfigured(methods, method);
|
|
1349
|
+
const signsUp = (method) => canSignUp(methods, method);
|
|
1350
|
+
const emailVerification = sendsVerificationEmails(config);
|
|
1351
|
+
const totpKeys = { totpKey: deriveTotpKey(config.signingKey.d), codeKey };
|
|
1352
|
+
const tokenKey = deriveTokenKey(config.signingKey.d);
|
|
1353
|
+
const issueSession = async (c, user, amr, extra) => {
|
|
1354
|
+
const signingKeys = await keys;
|
|
1355
|
+
const [sessionToken, renewalToken] = await Promise.all([
|
|
1356
|
+
signSession(signingKeys, issuer, sessionTtlSeconds, user, amr, extra),
|
|
1357
|
+
signRenewal(signingKeys, issuer, renewalTtlSeconds, user, amr, extra)
|
|
1358
|
+
]);
|
|
1359
|
+
setCookie6(c, SESSION_COOKIE, sessionToken, {
|
|
1360
|
+
path: "/",
|
|
1361
|
+
domain: cookieDomain,
|
|
1362
|
+
httpOnly: true,
|
|
1363
|
+
secure,
|
|
1364
|
+
sameSite: "Lax",
|
|
1365
|
+
maxAge: sessionTtlSeconds
|
|
1366
|
+
});
|
|
1367
|
+
setCookie6(c, RENEWAL_COOKIE, renewalToken, {
|
|
1368
|
+
path: "/auth",
|
|
1369
|
+
httpOnly: true,
|
|
1370
|
+
secure,
|
|
1371
|
+
sameSite: "Lax",
|
|
1372
|
+
maxAge: renewalTtlSeconds
|
|
1373
|
+
});
|
|
1374
|
+
setCookie6(c, EXPIRY_COOKIE, String(Math.floor(Date.now() / 1e3) + sessionTtlSeconds), {
|
|
1375
|
+
path: "/",
|
|
1376
|
+
domain: cookieDomain,
|
|
1377
|
+
secure,
|
|
1378
|
+
sameSite: "Lax",
|
|
1379
|
+
maxAge: renewalTtlSeconds
|
|
1380
|
+
});
|
|
1381
|
+
};
|
|
1382
|
+
const clearSession = (c) => {
|
|
1383
|
+
deleteCookie6(c, SESSION_COOKIE, { path: "/", domain: cookieDomain, secure });
|
|
1384
|
+
deleteCookie6(c, RENEWAL_COOKIE, { path: "/auth", secure });
|
|
1385
|
+
deleteCookie6(c, EXPIRY_COOKIE, { path: "/", domain: cookieDomain, secure });
|
|
1386
|
+
};
|
|
1387
|
+
const tokenClaims = async (c, cookie, typ) => readToken(await keys, issuer, typ, getCookie6(c, cookie));
|
|
1388
|
+
const checked = async (claims) => {
|
|
1389
|
+
const stored = await users.findById(claims.sub);
|
|
1390
|
+
if (!stored || stored.sessionVersion !== claims.sv) return null;
|
|
1391
|
+
const { claims: _, ...user } = userFromClaims(claims);
|
|
1392
|
+
const current = claimsFromJson(stored.claims);
|
|
1393
|
+
return Object.keys(current).length ? { ...user, claims: current } : user;
|
|
1394
|
+
};
|
|
1395
|
+
const currentSession = async (c) => {
|
|
1396
|
+
const session = await tokenClaims(c, SESSION_COOKIE, SESSION_TYP);
|
|
1397
|
+
const user = session ? await checked(session) : null;
|
|
1398
|
+
const upToDate = session && user && sameClaims(user.claims, session.claims) && Date.now() / 1e3 - session.iat <= sessionTtlSeconds / 2;
|
|
1399
|
+
if (upToDate) return user;
|
|
1400
|
+
const renewal = await tokenClaims(c, RENEWAL_COOKIE, RENEWAL_TYP);
|
|
1401
|
+
const sameSignIn = session && user && renewal && renewal.sub === session.sub && renewal.sv === session.sv && renewal.email === session.email;
|
|
1402
|
+
const renewed = sameSignIn ? user : renewal ? await checked(renewal) : null;
|
|
1403
|
+
if (renewal && renewed) {
|
|
1404
|
+
await issueSession(c, renewed, renewal.amr, { sv: renewal.sv });
|
|
1405
|
+
return renewed;
|
|
1406
|
+
}
|
|
1407
|
+
return user;
|
|
1408
|
+
};
|
|
1409
|
+
const methodsOf = /* @__PURE__ */ new WeakMap();
|
|
1410
|
+
const ctx = {
|
|
1411
|
+
config,
|
|
1412
|
+
keys,
|
|
1413
|
+
secure,
|
|
1414
|
+
isAllowedOrigin,
|
|
1415
|
+
allowedUrl(value) {
|
|
1416
|
+
let url;
|
|
1417
|
+
try {
|
|
1418
|
+
url = new URL(typeof value === "string" ? value : "");
|
|
1419
|
+
} catch {
|
|
1420
|
+
return null;
|
|
1421
|
+
}
|
|
1422
|
+
if (!isAllowedOrigin(url.origin)) return null;
|
|
1423
|
+
url.hash = "";
|
|
1424
|
+
return url;
|
|
1425
|
+
},
|
|
1426
|
+
async startSession(c, user, amr, { method, signedUpAt }, extra) {
|
|
1427
|
+
const firstSession = signedUpAt === 0 && await users.markSignedUp(user.id);
|
|
1428
|
+
clearChallenge(c, secure);
|
|
1429
|
+
await issueSession(c, user, amr, extra);
|
|
1430
|
+
const result = { ...user, amr };
|
|
1431
|
+
if (firstSession) await this.emit("user.signed_up", { user: result, method });
|
|
1432
|
+
return result;
|
|
1433
|
+
},
|
|
1434
|
+
async secondStep(c, user, amr, method, profile) {
|
|
1435
|
+
const methods2 = await this.methods(c);
|
|
1436
|
+
if (!isOn(method, configured, methods2)) return { disabled: true };
|
|
1437
|
+
const policy = method === "totp" ? "required" : policyOf(method, methods2);
|
|
1438
|
+
let next = null;
|
|
1439
|
+
let factors = [];
|
|
1440
|
+
if (policy !== "none") {
|
|
1441
|
+
const [app2, phone] = await Promise.all([
|
|
1442
|
+
users.totpAccount(user.id),
|
|
1443
|
+
method !== "totp" && isOn("sms", configured, methods2) ? users.smsAccount(user.id) : null
|
|
1444
|
+
]);
|
|
1445
|
+
factors = [...app2 ? ["totp"] : [], ...phone ? ["sms"] : []];
|
|
1446
|
+
next = app2 ? "totp" : phone ? "sms" : policy === "required" ? "totp-setup" : null;
|
|
1447
|
+
}
|
|
1448
|
+
if (next) {
|
|
1449
|
+
clearSession(c);
|
|
1450
|
+
const known = { ...profile?.name ? { name: profile.name } : {}, ...profile?.picture ? { picture: profile.picture } : {} };
|
|
1451
|
+
await setChallenge(c, this, { sub: user.id, sv: user.sessionVersion, amr, method, next, factors, ...Object.keys(known).length ? { profile: known } : {} });
|
|
1452
|
+
return { next, method, factors };
|
|
1453
|
+
}
|
|
1454
|
+
const result = await this.startSession(c, toMadauthUser(user, profile), amr, { method, signedUpAt: user.signedUpAt }, { sv: user.sessionVersion });
|
|
1455
|
+
await this.emit("user.signed_in", { user: result, method });
|
|
1456
|
+
return { user: result };
|
|
1457
|
+
},
|
|
1458
|
+
currentUser: currentSession,
|
|
1459
|
+
users,
|
|
1460
|
+
settings,
|
|
1461
|
+
webhook: config.webhook ? createWebhookClient(config.webhook, config.webhookFetch) : void 0,
|
|
1462
|
+
configured,
|
|
1463
|
+
emailVerification,
|
|
1464
|
+
methods(c) {
|
|
1465
|
+
let reading = methodsOf.get(c.req.raw);
|
|
1466
|
+
if (!reading) {
|
|
1467
|
+
reading = settings.methods();
|
|
1468
|
+
methodsOf.set(c.req.raw, reading);
|
|
1469
|
+
}
|
|
1470
|
+
return reading;
|
|
1471
|
+
},
|
|
1472
|
+
async enabled(c, method) {
|
|
1473
|
+
return isOn(method, configured, await this.methods(c));
|
|
1474
|
+
},
|
|
1475
|
+
async checkSignup(data) {
|
|
1476
|
+
if (!this.webhook?.wants("user.signing_up")) return { ok: true };
|
|
1477
|
+
const check = await this.webhook.call("user.signing_up", data, WEBHOOK_TIMEOUT_MS);
|
|
1478
|
+
if (!check.ok) {
|
|
1479
|
+
console.error(`[madauth] Webhook "user.signing_up" failed: ${check.reason}`);
|
|
1480
|
+
return { ok: false, reason: "unavailable" };
|
|
1481
|
+
}
|
|
1482
|
+
const answer = check.body;
|
|
1483
|
+
if (answer?.allow === false) {
|
|
1484
|
+
const message = typeof answer.message === "string" && answer.message ? answer.message : "Sign-up is not possible with this e-mail address.";
|
|
1485
|
+
return { ok: false, reason: "rejected", message };
|
|
1486
|
+
}
|
|
1487
|
+
return { ok: true };
|
|
1488
|
+
},
|
|
1489
|
+
async emit(type, data) {
|
|
1490
|
+
if (!this.webhook?.wants(type)) return;
|
|
1491
|
+
const user = data.user;
|
|
1492
|
+
const payload = user && typeof user === "object" && "amr" in user ? { ...data, user: { ...user, amr: void 0 } } : data;
|
|
1493
|
+
const result = await this.webhook.call(type, payload, EVENT_TIMEOUT_MS);
|
|
1494
|
+
if (!result.ok) console.error(`[madauth] Webhook "${type}" failed: ${result.reason}`);
|
|
1495
|
+
}
|
|
1496
|
+
};
|
|
1497
|
+
const app = new Hono();
|
|
1498
|
+
app.use(
|
|
1499
|
+
"/auth/*",
|
|
1500
|
+
cors({
|
|
1501
|
+
origin: (origin) => isAllowedOrigin(origin) ? origin : null,
|
|
1502
|
+
credentials: true,
|
|
1503
|
+
allowMethods: ["GET", "POST"],
|
|
1504
|
+
allowHeaders: ["Content-Type"],
|
|
1505
|
+
maxAge: 600
|
|
1506
|
+
})
|
|
1507
|
+
);
|
|
1508
|
+
app.use("/auth/*", async (c, next) => {
|
|
1509
|
+
if (c.req.method === "POST" && !isAllowedOrigin(c.req.header("origin"))) {
|
|
1510
|
+
return c.json({ error: "forbidden_origin" }, 403);
|
|
1511
|
+
}
|
|
1512
|
+
await next();
|
|
1513
|
+
});
|
|
1514
|
+
app.get("/health", (c) => c.text("ok"));
|
|
1515
|
+
app.get("/.well-known/jwks.json", async (c) => {
|
|
1516
|
+
const { publicJwk } = await keys;
|
|
1517
|
+
c.header("Cache-Control", "public, max-age=3600");
|
|
1518
|
+
return c.json({ keys: [publicJwk] });
|
|
1519
|
+
});
|
|
1520
|
+
app.get("/auth/config", async (c) => {
|
|
1521
|
+
const current = await ctx.methods(c);
|
|
1522
|
+
const on = (method) => isOn(method, configured, current);
|
|
1523
|
+
const rules = on("password") ? await settings.passwordRules() : null;
|
|
1524
|
+
return c.json({
|
|
1525
|
+
google: on("google") ? { clientId: config.google.clientId, codeFlow: !!config.google.clientSecret, secondFactor: policyOf("google", current) } : null,
|
|
1526
|
+
github: on("github") ? { secondFactor: policyOf("github", current) } : null,
|
|
1527
|
+
password: rules ? { ...rules, secondFactor: policyOf("password", current) } : null,
|
|
1528
|
+
// The app can be set up when it is in use at all; `signIn` says whether it signs in on its own.
|
|
1529
|
+
totp: authenticatorOn(configured, current) ? { signIn: on("totp"), signUp: on("totp") && emailVerification } : null,
|
|
1530
|
+
email: { verification: emailVerification },
|
|
1531
|
+
emailLink: on("email-link") ? { secondFactor: policyOf("email-link", current) } : null,
|
|
1532
|
+
// SMS codes as a second factor: a signed-in user can add a phone, and the dialog offers it in the second step.
|
|
1533
|
+
sms: on("sms")
|
|
1534
|
+
});
|
|
1535
|
+
});
|
|
1536
|
+
if (emailVerification && ctx.webhook) emailRoutes(app, ctx, users, ctx.webhook);
|
|
1537
|
+
if (configured("email-link") && ctx.webhook) emailLinkRoutes(app, ctx, users, ctx.webhook);
|
|
1538
|
+
if (configured("sms") && ctx.webhook) smsRoutes(app, ctx, users, ctx.webhook);
|
|
1539
|
+
if (config.google) googleRoutes(app, ctx, config.google);
|
|
1540
|
+
if (config.github) githubRoutes(app, ctx, config.github, tokenKey);
|
|
1541
|
+
if (config.password && ctx.webhook) passwordRoutes(app, ctx, users, ctx.webhook);
|
|
1542
|
+
totpRoutes(app, ctx, totpKeys);
|
|
1543
|
+
app.get("/auth/session", async (c) => {
|
|
1544
|
+
const user = await currentSession(c);
|
|
1545
|
+
if (!user) {
|
|
1546
|
+
clearSession(c);
|
|
1547
|
+
return c.json({ error: "no_session" }, 401);
|
|
1548
|
+
}
|
|
1549
|
+
return c.json({ user });
|
|
1550
|
+
});
|
|
1551
|
+
app.post("/auth/logout", (c) => {
|
|
1552
|
+
clearSession(c);
|
|
1553
|
+
clearChallenge(c, secure);
|
|
1554
|
+
return c.body(null, 204);
|
|
1555
|
+
});
|
|
1556
|
+
app.post("/auth/account/delete", async (c) => {
|
|
1557
|
+
const user = await currentSession(c);
|
|
1558
|
+
if (!user) return c.json({ error: "no_session" }, 401);
|
|
1559
|
+
const stored = await users.findById(user.id);
|
|
1560
|
+
const protects = stored && authenticatorOn(configured, await ctx.methods(c));
|
|
1561
|
+
const authenticator = protects ? await users.totpAccount(user.id) : null;
|
|
1562
|
+
if (stored && authenticator) {
|
|
1563
|
+
const data = await body(c);
|
|
1564
|
+
if (data.code === void 0 && data.recoveryCode === void 0) {
|
|
1565
|
+
return error(c, 401, "code_required", "Enter a code from your authenticator app (or a recovery code) to delete your account.");
|
|
1566
|
+
}
|
|
1567
|
+
const via = await checkCode(c, ctx, totpKeys, stored, authenticator, data);
|
|
1568
|
+
if (via instanceof Response) return via;
|
|
1569
|
+
}
|
|
1570
|
+
const googleSub = await users.googleSub(user.id);
|
|
1571
|
+
const githubAccount = config.github ? await users.githubAccount(user.id) : null;
|
|
1572
|
+
const deleted = await users.deleteById(user.id);
|
|
1573
|
+
if (deleted && config.github) await revokeGrant(config.github, tokenKey, githubAccount);
|
|
1574
|
+
clearSession(c);
|
|
1575
|
+
await ctx.emit("user.deleted", { user });
|
|
1576
|
+
return googleSub ? c.json({ googleSub }) : c.body(null, 204);
|
|
1577
|
+
});
|
|
1578
|
+
const admin = async (c) => {
|
|
1579
|
+
const user = await currentSession(c);
|
|
1580
|
+
if (!user) return { answer: c.json({ error: "no_session" }, 401) };
|
|
1581
|
+
const email = user.email ? normalizeEmail(user.email) : void 0;
|
|
1582
|
+
if (!email || !isAdmin(user)) {
|
|
1583
|
+
const message = `Only users with the role "${ADMIN_ROLE}" can manage claims and settings.`;
|
|
1584
|
+
return { answer: c.json({ error: "forbidden", message }, 403) };
|
|
1585
|
+
}
|
|
1586
|
+
return { email };
|
|
1587
|
+
};
|
|
1588
|
+
const invalidEmail = (c) => c.json({ error: "invalid_email", message: "This is not a valid e-mail address." }, 400);
|
|
1589
|
+
const noUser = (c) => c.json({ error: "user_not_found", message: "No user has this e-mail address." }, 404);
|
|
1590
|
+
app.post("/auth/admin/claims/get", async (c) => {
|
|
1591
|
+
const caller = await admin(c);
|
|
1592
|
+
if ("answer" in caller) return caller.answer;
|
|
1593
|
+
const data = await body(c);
|
|
1594
|
+
if (!isValidEmail(data.email)) return invalidEmail(c);
|
|
1595
|
+
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
1596
|
+
if (!user) return noUser(c);
|
|
1597
|
+
return c.json({ email: user.emailNormalized, userId: user.id, claims: claimsFromJson(user.claims) });
|
|
1598
|
+
});
|
|
1599
|
+
app.post("/auth/admin/claims/set", async (c) => {
|
|
1600
|
+
const caller = await admin(c);
|
|
1601
|
+
if ("answer" in caller) return caller.answer;
|
|
1602
|
+
const data = await body(c);
|
|
1603
|
+
if (!isValidEmail(data.email)) return invalidEmail(c);
|
|
1604
|
+
const claims = parseClaims(data.claims);
|
|
1605
|
+
if (!claims) {
|
|
1606
|
+
return c.json(
|
|
1607
|
+
{
|
|
1608
|
+
error: "invalid_claims",
|
|
1609
|
+
message: 'claims must be a JSON object of at most 2048 characters whose keys are names (letters, digits and "_", starting with a letter). "roles" must be a list of names of lower-case letters, digits, "-" and "_".'
|
|
1610
|
+
},
|
|
1611
|
+
400
|
|
1612
|
+
);
|
|
1613
|
+
}
|
|
1614
|
+
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
1615
|
+
if (!user) return noUser(c);
|
|
1616
|
+
const stored = Object.keys(claims).length ? JSON.stringify(claims) : null;
|
|
1617
|
+
await users.updateUser(user.id, { claims: stored });
|
|
1618
|
+
const email = user.emailNormalized;
|
|
1619
|
+
await ctx.emit("user.claims_changed", { user: toMadauthUser({ ...user, claims: stored }), claims, by: caller.email });
|
|
1620
|
+
return c.json({ email, userId: user.id, claims });
|
|
1621
|
+
});
|
|
1622
|
+
const settingsAnswer = (methods2, password) => ({
|
|
1623
|
+
password,
|
|
1624
|
+
methods: Object.fromEntries(
|
|
1625
|
+
METHODS.map((method) => [
|
|
1626
|
+
method,
|
|
1627
|
+
{
|
|
1628
|
+
configured: configured(method),
|
|
1629
|
+
enabled: isOn(method, configured, methods2),
|
|
1630
|
+
...hasSecondFactor(method) ? { secondFactor: policyOf(method, methods2) } : {}
|
|
1631
|
+
}
|
|
1632
|
+
])
|
|
1633
|
+
)
|
|
1634
|
+
});
|
|
1635
|
+
app.post("/auth/admin/settings/get", async (c) => {
|
|
1636
|
+
const caller = await admin(c);
|
|
1637
|
+
if ("answer" in caller) return caller.answer;
|
|
1638
|
+
const [methods2, password] = await Promise.all([settings.methods(), settings.passwordRules()]);
|
|
1639
|
+
return c.json(settingsAnswer(methods2, password));
|
|
1640
|
+
});
|
|
1641
|
+
app.post("/auth/admin/settings/set", async (c) => {
|
|
1642
|
+
const caller = await admin(c);
|
|
1643
|
+
if ("answer" in caller) return caller.answer;
|
|
1644
|
+
const data = await body(c);
|
|
1645
|
+
const invalid = (message) => c.json({ error: "invalid_settings", message }, 400);
|
|
1646
|
+
if (data.methods === void 0 && data.password === void 0) return invalid("Pass methods, password or both.");
|
|
1647
|
+
const next = data.methods === void 0 ? void 0 : parseMethodSettings(data.methods, { booleans: true });
|
|
1648
|
+
if (next === null) {
|
|
1649
|
+
return invalid(
|
|
1650
|
+
`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.`
|
|
1651
|
+
);
|
|
1652
|
+
}
|
|
1653
|
+
const rules = data.password === void 0 ? void 0 : parsePasswordRules(data.password);
|
|
1654
|
+
if (rules === null) {
|
|
1655
|
+
return invalid(
|
|
1656
|
+
`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}).`
|
|
1657
|
+
);
|
|
1658
|
+
}
|
|
1659
|
+
if (next && !anyOn(signsUp, next)) {
|
|
1660
|
+
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.");
|
|
1661
|
+
}
|
|
1662
|
+
const previousRules = rules && next ? await settings.passwordRules() : void 0;
|
|
1663
|
+
const password = rules ? await settings.setPasswordRules(rules, caller.email) : await settings.passwordRules();
|
|
1664
|
+
if (next) {
|
|
1665
|
+
try {
|
|
1666
|
+
await settings.changeMethods(() => next, caller.email);
|
|
1667
|
+
} catch (e) {
|
|
1668
|
+
if (previousRules) await settings.setPasswordRules(previousRules, caller.email).catch(() => {
|
|
1669
|
+
});
|
|
1670
|
+
throw e;
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
const methods2 = next ?? await settings.methods();
|
|
1674
|
+
return c.json(settingsAnswer(methods2, password));
|
|
1675
|
+
});
|
|
1676
|
+
app.post("/auth/admin/totp/remove", async (c) => {
|
|
1677
|
+
const caller = await admin(c);
|
|
1678
|
+
if ("answer" in caller) return caller.answer;
|
|
1679
|
+
const data = await body(c);
|
|
1680
|
+
if (!isValidEmail(data.email)) return invalidEmail(c);
|
|
1681
|
+
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
1682
|
+
if (!user) return noUser(c);
|
|
1683
|
+
const app2 = await users.totpApp(user.id);
|
|
1684
|
+
const secondFactor = !!app2 && !app2.signsIn;
|
|
1685
|
+
const removed = await users.removeTotp(user.id);
|
|
1686
|
+
if (removed && secondFactor) await ctx.emit("user.second_factor_disabled", { user: toMadauthUser(user), method: "totp", by: caller.email });
|
|
1687
|
+
return c.json({ email: user.emailNormalized, userId: user.id, enabled: false });
|
|
1688
|
+
});
|
|
1689
|
+
app.post("/auth/admin/sms/remove", async (c) => {
|
|
1690
|
+
const caller = await admin(c);
|
|
1691
|
+
if ("answer" in caller) return caller.answer;
|
|
1692
|
+
const data = await body(c);
|
|
1693
|
+
if (!isValidEmail(data.email)) return invalidEmail(c);
|
|
1694
|
+
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
1695
|
+
if (!user) return noUser(c);
|
|
1696
|
+
const removed = await users.removeSms(user.id);
|
|
1697
|
+
if (removed) await ctx.emit("user.second_factor_disabled", { user: toMadauthUser(user), method: "sms", by: caller.email });
|
|
1698
|
+
return c.json({ email: user.emailNormalized, userId: user.id, enabled: false });
|
|
1699
|
+
});
|
|
1700
|
+
return app;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
export {
|
|
1704
|
+
REDIRECT_ERROR_PARAM,
|
|
1705
|
+
createApp
|
|
1706
|
+
};
|