@madauth/server 0.3.0-beta.1 → 0.4.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 +104 -42
- package/dist/chunk-3PACOYTG.js +1185 -0
- package/dist/{chunk-DCDTO5LG.js → chunk-A7UYZRBL.js} +1 -0
- package/dist/{chunk-CD7Y4T66.js → chunk-GA4TF7EI.js} +24 -2
- package/dist/{chunk-ODITZLMM.js → chunk-ISJGOCF3.js} +258 -84
- package/dist/{chunk-56MOVFO4.js → chunk-OMIX3ERC.js} +11 -2
- package/dist/{chunk-NPNM26J3.js → chunk-Y7ZSUROX.js} +5 -1
- package/dist/{config-DEBYi8hb.d.ts → config-DkE_2D3-.d.ts} +5 -2
- 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 +29 -14
- package/dist/index.js +10 -6
- package/dist/{schema-3yhCc2jH.d.ts → schema-DTeiSuQB.d.ts} +39 -2
- package/dist/sqlite.js +2 -2
- package/dist/standalone/azure.mjs +1331 -676
- package/dist/standalone/lambda.mjs +1319 -664
- package/dist/standalone/main.mjs +1191 -536
- package/dist/store/dynamodb.d.ts +1 -1
- package/dist/store/sqlite.d.ts +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +2 -0
- package/dist/{user-BMWEWREH.d.ts → user-BZs84sui.d.ts} +5 -0
- package/dist/verify.d.ts +1 -1
- package/dist/verify.js +1 -1
- package/dist/{webhook-DCDP6SaE.d.ts → webhook-B6P50ocX.d.ts} +1 -1
- package/dist/webhook.d.ts +1 -1
- package/dist/webhook.js +1 -1
- package/package.json +2 -2
- package/dist/chunk-ZSBKNV5W.js +0 -713
package/dist/chunk-ZSBKNV5W.js
DELETED
|
@@ -1,713 +0,0 @@
|
|
|
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-DCDTO5LG.js";
|
|
14
|
-
import {
|
|
15
|
-
ADMIN_ROLE,
|
|
16
|
-
MAX_PASSWORD_LENGTH,
|
|
17
|
-
SIGN_IN_METHODS,
|
|
18
|
-
Settings,
|
|
19
|
-
Users,
|
|
20
|
-
anyOn,
|
|
21
|
-
checkPasswordPolicy,
|
|
22
|
-
claimsFromJson,
|
|
23
|
-
deriveCodeKey,
|
|
24
|
-
googleAccountKey,
|
|
25
|
-
hashPassword,
|
|
26
|
-
isAdmin,
|
|
27
|
-
isOn,
|
|
28
|
-
isValidEmail,
|
|
29
|
-
normalizeEmail,
|
|
30
|
-
parseClaims,
|
|
31
|
-
parseMethodSettings,
|
|
32
|
-
passwordAccountKey,
|
|
33
|
-
sameClaims,
|
|
34
|
-
toMadauthUser,
|
|
35
|
-
verifyAgainstDummy,
|
|
36
|
-
verifyPassword
|
|
37
|
-
} from "./chunk-ODITZLMM.js";
|
|
38
|
-
import {
|
|
39
|
-
importSigningKeys
|
|
40
|
-
} from "./chunk-JDQLSL36.js";
|
|
41
|
-
import {
|
|
42
|
-
WEBHOOK_TIMEOUT_MS,
|
|
43
|
-
createWebhookClient,
|
|
44
|
-
localeOf
|
|
45
|
-
} from "./chunk-NPNM26J3.js";
|
|
46
|
-
|
|
47
|
-
// src/routes/google.ts
|
|
48
|
-
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
|
49
|
-
import { base64url } from "jose";
|
|
50
|
-
|
|
51
|
-
// src/google.ts
|
|
52
|
-
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
53
|
-
var GOOGLE_ISSUERS = ["https://accounts.google.com", "accounts.google.com"];
|
|
54
|
-
var googleJwks;
|
|
55
|
-
function googleKeys() {
|
|
56
|
-
googleJwks ??= createRemoteJWKSet(new URL("https://www.googleapis.com/oauth2/v3/certs"));
|
|
57
|
-
return googleJwks;
|
|
58
|
-
}
|
|
59
|
-
async function verifyGoogleIdToken(idToken, opts) {
|
|
60
|
-
let payload;
|
|
61
|
-
try {
|
|
62
|
-
({ payload } = await jwtVerify(idToken, opts.keys ?? googleKeys(), {
|
|
63
|
-
issuer: GOOGLE_ISSUERS,
|
|
64
|
-
audience: opts.clientId,
|
|
65
|
-
algorithms: ["RS256", "ES256"]
|
|
66
|
-
}));
|
|
67
|
-
} catch (e) {
|
|
68
|
-
return { ok: false, error: "verification_failed", reason: e.message };
|
|
69
|
-
}
|
|
70
|
-
if (typeof payload.sub !== "string" || !payload.sub) {
|
|
71
|
-
return { ok: false, error: "verification_failed", reason: "missing sub" };
|
|
72
|
-
}
|
|
73
|
-
if (payload.nonce !== opts.nonce) {
|
|
74
|
-
return { ok: false, error: "verification_failed", reason: "nonce mismatch" };
|
|
75
|
-
}
|
|
76
|
-
if (typeof payload.email !== "string" || !payload.email || payload.email_verified !== true) {
|
|
77
|
-
return { ok: false, error: "email_unverified", reason: "e-mail address not verified" };
|
|
78
|
-
}
|
|
79
|
-
const profile = { sub: payload.sub, email: payload.email };
|
|
80
|
-
if (typeof payload.name === "string") profile.name = payload.name;
|
|
81
|
-
if (typeof payload.picture === "string") profile.picture = payload.picture;
|
|
82
|
-
return { ok: true, profile };
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// src/routes/google.ts
|
|
86
|
-
var NONCE_COOKIE = "madauth_nonce";
|
|
87
|
-
var NONCE_TYP = "madauth-nonce+jwt";
|
|
88
|
-
var NONCE_TTL = 5 * 60;
|
|
89
|
-
var OAUTH_COOKIE = "madauth_oauth";
|
|
90
|
-
var OAUTH_TYP = "madauth-oauth-state+jwt";
|
|
91
|
-
var OAUTH_TTL = 10 * 60;
|
|
92
|
-
var GOOGLE_AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
93
|
-
var GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
|
|
94
|
-
var REDIRECT_ERROR_PARAM = "madauth_error";
|
|
95
|
-
async function pkceChallenge(verifier) {
|
|
96
|
-
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
97
|
-
return base64url.encode(new Uint8Array(digest));
|
|
98
|
-
}
|
|
99
|
-
function googleRoutes(app, ctx, google) {
|
|
100
|
-
const { config, keys, secure, users } = ctx;
|
|
101
|
-
const { issuer } = config;
|
|
102
|
-
const callbackUrl = `${issuer}/auth/google/callback`;
|
|
103
|
-
const resolveUser = async (profile, locale) => {
|
|
104
|
-
const key = googleAccountKey(profile.sub);
|
|
105
|
-
const emailNormalized = normalizeEmail(profile.email);
|
|
106
|
-
const signedIn = (user) => ({ ok: true, user: toMadauthUser(user, profile), sv: user.sessionVersion });
|
|
107
|
-
const account = await users.findAccountByKey(key);
|
|
108
|
-
if (account) {
|
|
109
|
-
const user = await users.findById(account.userId);
|
|
110
|
-
if (user) {
|
|
111
|
-
if (account.email !== profile.email) await users.updateAccount(account.id, { email: profile.email });
|
|
112
|
-
return signedIn(user);
|
|
113
|
-
}
|
|
114
|
-
await users.deleteAccount(account.id);
|
|
115
|
-
}
|
|
116
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
117
|
-
const existing = await users.findByEmail(emailNormalized);
|
|
118
|
-
if (existing) {
|
|
119
|
-
await users.verifyByProvider(existing);
|
|
120
|
-
if (!await users.linkAccount(existing.id, { key, email: profile.email })) {
|
|
121
|
-
const linked = await users.findAccountByKey(key);
|
|
122
|
-
const owner = linked && await users.findById(linked.userId);
|
|
123
|
-
if (!owner) return { ok: false, error: "temporarily_unavailable", message: "Please try again." };
|
|
124
|
-
return signedIn(owner);
|
|
125
|
-
}
|
|
126
|
-
return signedIn(existing);
|
|
127
|
-
}
|
|
128
|
-
if (attempt === 0) {
|
|
129
|
-
const check = await ctx.checkSignup({ email: profile.email, name: profile.name, locale, method: "google" });
|
|
130
|
-
if (!check.ok) {
|
|
131
|
-
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." };
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
const user = await users.createUser({
|
|
135
|
-
email: profile.email,
|
|
136
|
-
emailNormalized,
|
|
137
|
-
name: profile.name ?? null,
|
|
138
|
-
emailVerified: true,
|
|
139
|
-
account: { key, email: profile.email }
|
|
140
|
-
});
|
|
141
|
-
if (user) {
|
|
142
|
-
await ctx.emit("user.created", { user: toMadauthUser(user), method: "google" });
|
|
143
|
-
return signedIn(user);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
return { ok: false, error: "temporarily_unavailable", message: "Please try again." };
|
|
147
|
-
};
|
|
148
|
-
const disabled = (c) => c.json({ error: "method_disabled", message: "Google sign-in is switched off." }, 403);
|
|
149
|
-
app.post("/auth/google/nonce", async (c) => {
|
|
150
|
-
if (!await ctx.enabled("google")) return disabled(c);
|
|
151
|
-
const nonce = randomString();
|
|
152
|
-
const token = await signToken(await keys, issuer, NONCE_TYP, { nonce }, NONCE_TTL);
|
|
153
|
-
setCookie(c, NONCE_COOKIE, token, {
|
|
154
|
-
path: "/auth/google",
|
|
155
|
-
httpOnly: true,
|
|
156
|
-
secure,
|
|
157
|
-
sameSite: "Strict",
|
|
158
|
-
maxAge: NONCE_TTL
|
|
159
|
-
});
|
|
160
|
-
return c.json({ nonce });
|
|
161
|
-
});
|
|
162
|
-
app.post("/auth/google/verify", async (c) => {
|
|
163
|
-
if (!await ctx.enabled("google")) return disabled(c);
|
|
164
|
-
const body = await c.req.json().catch(() => ({}));
|
|
165
|
-
if (typeof body.credential !== "string" || !body.credential) {
|
|
166
|
-
return c.json({ error: "verification_failed", message: "credential is missing" }, 400);
|
|
167
|
-
}
|
|
168
|
-
const nonceClaims = await readToken(await keys, issuer, NONCE_TYP, getCookie(c, NONCE_COOKIE));
|
|
169
|
-
if (!nonceClaims) {
|
|
170
|
-
return c.json({ error: "verification_failed", message: "nonce cookie missing or expired" }, 401);
|
|
171
|
-
}
|
|
172
|
-
const result = await verifyGoogleIdToken(body.credential, {
|
|
173
|
-
clientId: google.clientId,
|
|
174
|
-
nonce: nonceClaims.nonce,
|
|
175
|
-
keys: config.jwksResolver
|
|
176
|
-
});
|
|
177
|
-
if (!result.ok) {
|
|
178
|
-
return c.json({ error: result.error, message: result.reason }, result.error === "email_unverified" ? 403 : 401);
|
|
179
|
-
}
|
|
180
|
-
deleteCookie(c, NONCE_COOKIE, { path: "/auth/google", secure });
|
|
181
|
-
const resolved = await resolveUser(result.profile, localeOf(body.locale));
|
|
182
|
-
if (!resolved.ok) {
|
|
183
|
-
return c.json({ error: resolved.error, message: resolved.message }, resolved.error === "signup_rejected" ? 403 : 503);
|
|
184
|
-
}
|
|
185
|
-
const user = await ctx.startSession(c, resolved.user, ["google"], { sv: resolved.sv });
|
|
186
|
-
await ctx.emit("user.signed_in", { user, method: "google" });
|
|
187
|
-
return c.json({ user });
|
|
188
|
-
});
|
|
189
|
-
const requireCodeFlow = async (c, next) => {
|
|
190
|
-
if (!google.clientSecret) return c.json({ error: "not_found" }, 404);
|
|
191
|
-
if (!await ctx.enabled("google")) return disabled(c);
|
|
192
|
-
await next();
|
|
193
|
-
};
|
|
194
|
-
app.get("/auth/google/start", requireCodeFlow, async (c) => {
|
|
195
|
-
const returnUrl = ctx.allowedUrl(c.req.query("return_to"));
|
|
196
|
-
if (!returnUrl) return c.text("return_to must be an absolute URL whose origin is in ALLOWED_ORIGINS", 400);
|
|
197
|
-
const oauth = {
|
|
198
|
-
state: randomString(),
|
|
199
|
-
nonce: randomString(),
|
|
200
|
-
verifier: randomString(),
|
|
201
|
-
returnTo: returnUrl.href,
|
|
202
|
-
locale: localeOf(c.req.query("locale"))
|
|
203
|
-
};
|
|
204
|
-
const token = await signToken(await keys, issuer, OAUTH_TYP, { ...oauth }, OAUTH_TTL);
|
|
205
|
-
setCookie(c, OAUTH_COOKIE, token, {
|
|
206
|
-
path: "/auth/google",
|
|
207
|
-
httpOnly: true,
|
|
208
|
-
secure,
|
|
209
|
-
sameSite: "Lax",
|
|
210
|
-
maxAge: OAUTH_TTL
|
|
211
|
-
});
|
|
212
|
-
const url = new URL(GOOGLE_AUTH_ENDPOINT);
|
|
213
|
-
url.search = new URLSearchParams({
|
|
214
|
-
client_id: google.clientId,
|
|
215
|
-
redirect_uri: callbackUrl,
|
|
216
|
-
response_type: "code",
|
|
217
|
-
scope: "openid email profile",
|
|
218
|
-
state: oauth.state,
|
|
219
|
-
nonce: oauth.nonce,
|
|
220
|
-
code_challenge: await pkceChallenge(oauth.verifier),
|
|
221
|
-
code_challenge_method: "S256"
|
|
222
|
-
}).toString();
|
|
223
|
-
return c.redirect(url.href, 302);
|
|
224
|
-
});
|
|
225
|
-
app.get("/auth/google/callback", requireCodeFlow, async (c) => {
|
|
226
|
-
const oauth = await readToken(await keys, issuer, OAUTH_TYP, getCookie(c, OAUTH_COOKIE));
|
|
227
|
-
if (!oauth) return c.text("Sign-in expired or was started in another browser. Please try again.", 400);
|
|
228
|
-
deleteCookie(c, OAUTH_COOKIE, { path: "/auth/google", secure });
|
|
229
|
-
const back = (error) => {
|
|
230
|
-
const url = new URL(oauth.returnTo);
|
|
231
|
-
if (error) url.hash = `${REDIRECT_ERROR_PARAM}=${error}`;
|
|
232
|
-
return c.redirect(url.href, 302);
|
|
233
|
-
};
|
|
234
|
-
if (c.req.query("error")) return back(c.req.query("error") === "access_denied" ? "cancelled" : "verification_failed");
|
|
235
|
-
const code = c.req.query("code");
|
|
236
|
-
if (!code || c.req.query("state") !== oauth.state) return back("verification_failed");
|
|
237
|
-
let idToken;
|
|
238
|
-
try {
|
|
239
|
-
const res = await fetch(GOOGLE_TOKEN_ENDPOINT, {
|
|
240
|
-
method: "POST",
|
|
241
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
242
|
-
body: new URLSearchParams({
|
|
243
|
-
code,
|
|
244
|
-
client_id: google.clientId,
|
|
245
|
-
client_secret: google.clientSecret,
|
|
246
|
-
redirect_uri: callbackUrl,
|
|
247
|
-
grant_type: "authorization_code",
|
|
248
|
-
code_verifier: oauth.verifier
|
|
249
|
-
})
|
|
250
|
-
});
|
|
251
|
-
if (!res.ok) return back("verification_failed");
|
|
252
|
-
({ id_token: idToken } = await res.json());
|
|
253
|
-
} catch {
|
|
254
|
-
return back("verification_failed");
|
|
255
|
-
}
|
|
256
|
-
if (typeof idToken !== "string") return back("verification_failed");
|
|
257
|
-
const result = await verifyGoogleIdToken(idToken, {
|
|
258
|
-
clientId: google.clientId,
|
|
259
|
-
nonce: oauth.nonce,
|
|
260
|
-
keys: config.jwksResolver
|
|
261
|
-
});
|
|
262
|
-
if (!result.ok) return back(result.error);
|
|
263
|
-
const resolved = await resolveUser(result.profile, typeof oauth.locale === "string" ? oauth.locale : void 0);
|
|
264
|
-
if (!resolved.ok) return back(resolved.error);
|
|
265
|
-
const user = await ctx.startSession(c, resolved.user, ["google"], { sv: resolved.sv });
|
|
266
|
-
await ctx.emit("user.signed_in", { user, method: "google" });
|
|
267
|
-
return back();
|
|
268
|
-
});
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// src/app.ts
|
|
272
|
-
import { Hono } from "hono";
|
|
273
|
-
import { deleteCookie as deleteCookie2, getCookie as getCookie2, setCookie as setCookie2 } from "hono/cookie";
|
|
274
|
-
import { cors } from "hono/cors";
|
|
275
|
-
|
|
276
|
-
// src/routes/password.ts
|
|
277
|
-
var FREE_ATTEMPTS = 5;
|
|
278
|
-
var MAX_LOCK_MS = 15 * 60 * 1e3;
|
|
279
|
-
var MAIL_INTERVAL_MS = 60 * 1e3;
|
|
280
|
-
var VERIFY_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
281
|
-
var RESET_TTL_MS = 30 * 60 * 1e3;
|
|
282
|
-
var VERIFY_LINK_PARAM = "madauth_verify";
|
|
283
|
-
var RESET_LINK_PARAM = "madauth_reset";
|
|
284
|
-
var MAX_NAME_LENGTH = 100;
|
|
285
|
-
function passwordRoutes(app, ctx, password, users, webhook) {
|
|
286
|
-
const { minLength } = password;
|
|
287
|
-
const disabled = (c) => c.json({ error: "method_disabled", message: "E-mail & password sign-in is switched off." }, 403);
|
|
288
|
-
app.use("/auth/password/*", async (c, next) => {
|
|
289
|
-
if (!await ctx.enabled("password")) return disabled(c);
|
|
290
|
-
await next();
|
|
291
|
-
});
|
|
292
|
-
const body = async (c) => {
|
|
293
|
-
const data = await c.req.json().catch(() => null);
|
|
294
|
-
return data && typeof data === "object" ? data : {};
|
|
295
|
-
};
|
|
296
|
-
const error = (c, status, code, message) => c.json({ error: code, message }, status);
|
|
297
|
-
const unavailable = (c) => error(c, 503, "temporarily_unavailable", "E-mails can not be sent right now. Please try again later.");
|
|
298
|
-
const accepted = (c) => c.json({}, 202);
|
|
299
|
-
const redirectTarget = (c, value) => {
|
|
300
|
-
const url = ctx.allowedUrl(value);
|
|
301
|
-
return url ?? error(c, 400, "invalid_options", "redirectTo must be an absolute URL whose origin is in ALLOWED_ORIGINS.");
|
|
302
|
-
};
|
|
303
|
-
const sendEmail = async (user, type, data) => {
|
|
304
|
-
if (Date.now() - user.lastMailAt < MAIL_INTERVAL_MS) return true;
|
|
305
|
-
const result = await webhook.call(type, { to: user.email, user: { id: user.id, name: user.name ?? void 0 }, ...data }, WEBHOOK_TIMEOUT_MS);
|
|
306
|
-
if (!result.ok) {
|
|
307
|
-
console.error(`[madauth] Webhook "${type}" for ${user.email} failed: ${result.reason}`);
|
|
308
|
-
return false;
|
|
309
|
-
}
|
|
310
|
-
await users.updateUser(user.id, { lastMailAt: Date.now() });
|
|
311
|
-
return true;
|
|
312
|
-
};
|
|
313
|
-
const sendLinkEmail = async (user, purpose, target, locale) => {
|
|
314
|
-
if (Date.now() - user.lastMailAt < MAIL_INTERVAL_MS) return true;
|
|
315
|
-
const ttl = purpose === "verify" ? VERIFY_TTL_MS : RESET_TTL_MS;
|
|
316
|
-
const { token, code } = await users.issueVerification(user.id, purpose, ttl);
|
|
317
|
-
const link = new URL(target);
|
|
318
|
-
link.hash = `${purpose === "verify" ? VERIFY_LINK_PARAM : RESET_LINK_PARAM}=${token}`;
|
|
319
|
-
return sendEmail(user, purpose === "verify" ? "email.verify" : "email.reset", {
|
|
320
|
-
link: link.href,
|
|
321
|
-
code,
|
|
322
|
-
expiresAt: Date.now() + ttl,
|
|
323
|
-
site: target.host,
|
|
324
|
-
locale
|
|
325
|
-
});
|
|
326
|
-
};
|
|
327
|
-
const consume = async (data, purpose) => {
|
|
328
|
-
if (typeof data.token === "string" && data.token) {
|
|
329
|
-
return { userId: await users.consumeLinkToken(data.token, purpose), via: "link" };
|
|
330
|
-
}
|
|
331
|
-
if (isValidEmail(data.email) && typeof data.code === "string") {
|
|
332
|
-
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
333
|
-
if (!user) return { userId: null, via: "code" };
|
|
334
|
-
return { ...await users.consumeCode(user, purpose, data.code.replace(/\s/g, "")), via: "code" };
|
|
335
|
-
}
|
|
336
|
-
return { userId: null, via: "code" };
|
|
337
|
-
};
|
|
338
|
-
const invalidVerification = (c, { via, locked }) => {
|
|
339
|
-
if (locked) {
|
|
340
|
-
return error(c, 429, "codes_locked", "Too many wrong codes. Please ask for a new e-mail and use the link in it.");
|
|
341
|
-
}
|
|
342
|
-
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.");
|
|
343
|
-
};
|
|
344
|
-
app.post("/auth/password/signin", async (c) => {
|
|
345
|
-
const data = await body(c);
|
|
346
|
-
const wrong = () => error(c, 401, "invalid_credentials", "E-mail or password is wrong.");
|
|
347
|
-
const pw = typeof data.password === "string" ? data.password : "";
|
|
348
|
-
if (!isValidEmail(data.email) || !pw || pw.length > MAX_PASSWORD_LENGTH * 4) return wrong();
|
|
349
|
-
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
350
|
-
const account = user ? await users.passwordAccount(user.id) : null;
|
|
351
|
-
if (!user || !account?.secret) {
|
|
352
|
-
await verifyAgainstDummy(pw);
|
|
353
|
-
return wrong();
|
|
354
|
-
}
|
|
355
|
-
const now = Date.now();
|
|
356
|
-
if (account.lockedUntil > now) {
|
|
357
|
-
const seconds = Math.ceil((account.lockedUntil - now) / 1e3);
|
|
358
|
-
return error(c, 429, "too_many_attempts", `Too many failed attempts. Try again in ${seconds} seconds.`);
|
|
359
|
-
}
|
|
360
|
-
const failed = account.failedAttempts + 1;
|
|
361
|
-
const lockedUntil = failed >= FREE_ATTEMPTS ? now + Math.min(2 ** (failed - FREE_ATTEMPTS) * 1e3, MAX_LOCK_MS) : 0;
|
|
362
|
-
if (!await users.countAttempt(account, { failedAttempts: failed, lockedUntil })) {
|
|
363
|
-
return error(c, 429, "too_many_attempts", "Too many attempts at once. Please try again.");
|
|
364
|
-
}
|
|
365
|
-
const { ok, needsRehash } = await verifyPassword(pw, account.secret);
|
|
366
|
-
if (!ok) return wrong();
|
|
367
|
-
await users.updateAccount(account.id, {
|
|
368
|
-
failedAttempts: 0,
|
|
369
|
-
lockedUntil: 0,
|
|
370
|
-
...needsRehash ? { secret: await hashPassword(pw) } : {}
|
|
371
|
-
});
|
|
372
|
-
if (!user.emailVerified) {
|
|
373
|
-
return error(c, 403, "email_unverified", "Please confirm your e-mail address first. We can send the e-mail again.");
|
|
374
|
-
}
|
|
375
|
-
const result = await ctx.startSession(c, toMadauthUser(user), ["pwd"], { sv: user.sessionVersion });
|
|
376
|
-
await ctx.emit("user.signed_in", { user: result, method: "password" });
|
|
377
|
-
return c.json({ user: result });
|
|
378
|
-
});
|
|
379
|
-
app.post("/auth/password/signup", async (c) => {
|
|
380
|
-
const data = await body(c);
|
|
381
|
-
const target = redirectTarget(c, data.redirectTo);
|
|
382
|
-
if (target instanceof Response) return target;
|
|
383
|
-
if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
|
|
384
|
-
const policy = checkPasswordPolicy(data.password, minLength);
|
|
385
|
-
if (policy) return error(c, 400, "weak_password", policy);
|
|
386
|
-
const name = typeof data.name === "string" && data.name.trim() ? data.name.trim().slice(0, MAX_NAME_LENGTH) : null;
|
|
387
|
-
const email = data.email.trim();
|
|
388
|
-
const locale = localeOf(data.locale);
|
|
389
|
-
const check = await ctx.checkSignup({ email, name: name ?? void 0, locale, method: "password" });
|
|
390
|
-
if (!check.ok) return check.reason === "rejected" ? error(c, 403, "signup_rejected", check.message) : unavailable(c);
|
|
391
|
-
const passwordHash = await hashPassword(data.password);
|
|
392
|
-
const existing = await users.findByEmail(normalizeEmail(email));
|
|
393
|
-
if (existing?.emailVerified) {
|
|
394
|
-
if (!webhook.wants("email.already_registered")) return accepted(c);
|
|
395
|
-
const sent = await sendEmail(existing, "email.already_registered", { link: target.href, site: target.host, locale });
|
|
396
|
-
return sent ? accepted(c) : unavailable(c);
|
|
397
|
-
}
|
|
398
|
-
if (existing) {
|
|
399
|
-
if (Date.now() - existing.lastMailAt < MAIL_INTERVAL_MS) return accepted(c);
|
|
400
|
-
const account = await users.passwordAccount(existing.id);
|
|
401
|
-
if (account) await users.updateAccount(account.id, { secret: passwordHash, failedAttempts: 0, lockedUntil: 0 });
|
|
402
|
-
else await users.linkAccount(existing.id, { key: passwordAccountKey(existing.id), secret: passwordHash });
|
|
403
|
-
await users.updateUser(existing.id, { name });
|
|
404
|
-
return await sendLinkEmail({ ...existing, name }, "verify", target, locale) ? accepted(c) : unavailable(c);
|
|
405
|
-
}
|
|
406
|
-
const user = await users.createUser({
|
|
407
|
-
email,
|
|
408
|
-
emailNormalized: normalizeEmail(email),
|
|
409
|
-
name,
|
|
410
|
-
emailVerified: false,
|
|
411
|
-
account: (userId) => ({ key: passwordAccountKey(userId), secret: passwordHash })
|
|
412
|
-
});
|
|
413
|
-
if (!user) return accepted(c);
|
|
414
|
-
await ctx.emit("user.created", { user: toMadauthUser(user), method: "password" });
|
|
415
|
-
return await sendLinkEmail(user, "verify", target, locale) ? accepted(c) : unavailable(c);
|
|
416
|
-
});
|
|
417
|
-
app.post("/auth/password/send-verification", async (c) => {
|
|
418
|
-
const data = await body(c);
|
|
419
|
-
const target = redirectTarget(c, data.redirectTo);
|
|
420
|
-
if (target instanceof Response) return target;
|
|
421
|
-
if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
|
|
422
|
-
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
423
|
-
if (user && !user.emailVerified && await users.passwordAccount(user.id)) {
|
|
424
|
-
if (!await sendLinkEmail(user, "verify", target, localeOf(data.locale))) return unavailable(c);
|
|
425
|
-
}
|
|
426
|
-
return accepted(c);
|
|
427
|
-
});
|
|
428
|
-
app.post("/auth/password/verify-email", async (c) => {
|
|
429
|
-
const consumed = await consume(await body(c), "verify");
|
|
430
|
-
const { userId, via } = consumed;
|
|
431
|
-
const user = userId ? await users.findById(userId) : null;
|
|
432
|
-
if (!user) return invalidVerification(c, consumed);
|
|
433
|
-
await users.updateUser(user.id, { emailVerified: true, wrongCodes: 0 });
|
|
434
|
-
const result = await ctx.startSession(c, toMadauthUser(user), ["pwd"], { sv: user.sessionVersion });
|
|
435
|
-
await ctx.emit("email.verified", { user: result, via });
|
|
436
|
-
await ctx.emit("user.signed_in", { user: result, method: "password" });
|
|
437
|
-
return c.json({ user: result });
|
|
438
|
-
});
|
|
439
|
-
app.post("/auth/password/send-reset", async (c) => {
|
|
440
|
-
const data = await body(c);
|
|
441
|
-
const target = redirectTarget(c, data.redirectTo);
|
|
442
|
-
if (target instanceof Response) return target;
|
|
443
|
-
if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
|
|
444
|
-
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
445
|
-
if (user && (user.emailVerified || await users.passwordAccount(user.id))) {
|
|
446
|
-
if (!await sendLinkEmail(user, "reset", target, localeOf(data.locale))) return unavailable(c);
|
|
447
|
-
}
|
|
448
|
-
return accepted(c);
|
|
449
|
-
});
|
|
450
|
-
app.post("/auth/password/reset", async (c) => {
|
|
451
|
-
const data = await body(c);
|
|
452
|
-
const policy = checkPasswordPolicy(data.password, minLength);
|
|
453
|
-
if (policy) return error(c, 400, "weak_password", policy);
|
|
454
|
-
const consumed = await consume(data, "reset");
|
|
455
|
-
const user = consumed.userId ? await users.findById(consumed.userId) : null;
|
|
456
|
-
if (!user) return invalidVerification(c, consumed);
|
|
457
|
-
const secret = await hashPassword(data.password);
|
|
458
|
-
const account = await users.passwordAccount(user.id);
|
|
459
|
-
if (account) await users.updateAccount(account.id, { secret, failedAttempts: 0, lockedUntil: 0 });
|
|
460
|
-
else await users.linkAccount(user.id, { key: passwordAccountKey(user.id), secret });
|
|
461
|
-
const sessionVersion = user.sessionVersion + 1;
|
|
462
|
-
await users.updateUser(user.id, { emailVerified: true, sessionVersion, wrongCodes: 0 });
|
|
463
|
-
await users.clearVerifications(user.id);
|
|
464
|
-
const result = await ctx.startSession(c, toMadauthUser(user), ["pwd"], { sv: sessionVersion });
|
|
465
|
-
await ctx.emit("email.password_reset", { user: result });
|
|
466
|
-
await ctx.emit("user.signed_in", { user: result, method: "password" });
|
|
467
|
-
return c.json({ user: result });
|
|
468
|
-
});
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
// src/app.ts
|
|
472
|
-
var EVENT_TIMEOUT_MS = 5e3;
|
|
473
|
-
function createApp(config) {
|
|
474
|
-
const { issuer, allowedOrigins, sessionTtlSeconds, renewalTtlSeconds, cookieDomain } = config;
|
|
475
|
-
const keys = importSigningKeys(config.signingKey);
|
|
476
|
-
const secure = issuer.startsWith("https:");
|
|
477
|
-
const isAllowedOrigin = (origin) => !!origin && allowedOrigins.includes(origin);
|
|
478
|
-
const users = new Users(config.store, deriveCodeKey(config.signingKey.d));
|
|
479
|
-
const settings = new Settings(config.store);
|
|
480
|
-
const available = (method) => method === "google" ? !!config.google : !!config.password;
|
|
481
|
-
const issueSession = async (c, user, amr, extra) => {
|
|
482
|
-
const signingKeys = await keys;
|
|
483
|
-
const [sessionToken, renewalToken] = await Promise.all([
|
|
484
|
-
signSession(signingKeys, issuer, sessionTtlSeconds, user, amr, extra),
|
|
485
|
-
signRenewal(signingKeys, issuer, renewalTtlSeconds, user, amr, extra)
|
|
486
|
-
]);
|
|
487
|
-
setCookie2(c, SESSION_COOKIE, sessionToken, {
|
|
488
|
-
path: "/",
|
|
489
|
-
domain: cookieDomain,
|
|
490
|
-
httpOnly: true,
|
|
491
|
-
secure,
|
|
492
|
-
sameSite: "Lax",
|
|
493
|
-
maxAge: sessionTtlSeconds
|
|
494
|
-
});
|
|
495
|
-
setCookie2(c, RENEWAL_COOKIE, renewalToken, {
|
|
496
|
-
path: "/auth",
|
|
497
|
-
httpOnly: true,
|
|
498
|
-
secure,
|
|
499
|
-
sameSite: "Lax",
|
|
500
|
-
maxAge: renewalTtlSeconds
|
|
501
|
-
});
|
|
502
|
-
setCookie2(c, EXPIRY_COOKIE, String(Math.floor(Date.now() / 1e3) + sessionTtlSeconds), {
|
|
503
|
-
path: "/",
|
|
504
|
-
domain: cookieDomain,
|
|
505
|
-
secure,
|
|
506
|
-
sameSite: "Lax",
|
|
507
|
-
maxAge: renewalTtlSeconds
|
|
508
|
-
});
|
|
509
|
-
};
|
|
510
|
-
const clearSession = (c) => {
|
|
511
|
-
deleteCookie2(c, SESSION_COOKIE, { path: "/", domain: cookieDomain, secure });
|
|
512
|
-
deleteCookie2(c, RENEWAL_COOKIE, { path: "/auth", secure });
|
|
513
|
-
deleteCookie2(c, EXPIRY_COOKIE, { path: "/", domain: cookieDomain, secure });
|
|
514
|
-
};
|
|
515
|
-
const ctx = {
|
|
516
|
-
config,
|
|
517
|
-
keys,
|
|
518
|
-
secure,
|
|
519
|
-
isAllowedOrigin,
|
|
520
|
-
allowedUrl(value) {
|
|
521
|
-
let url;
|
|
522
|
-
try {
|
|
523
|
-
url = new URL(typeof value === "string" ? value : "");
|
|
524
|
-
} catch {
|
|
525
|
-
return null;
|
|
526
|
-
}
|
|
527
|
-
if (!isAllowedOrigin(url.origin)) return null;
|
|
528
|
-
url.hash = "";
|
|
529
|
-
return url;
|
|
530
|
-
},
|
|
531
|
-
async startSession(c, user, amr, extra) {
|
|
532
|
-
await issueSession(c, user, amr, extra);
|
|
533
|
-
return user;
|
|
534
|
-
},
|
|
535
|
-
users,
|
|
536
|
-
settings,
|
|
537
|
-
webhook: config.webhook ? createWebhookClient(config.webhook, config.webhookFetch) : void 0,
|
|
538
|
-
async enabled(method) {
|
|
539
|
-
return isOn(method, available, await settings.methods());
|
|
540
|
-
},
|
|
541
|
-
async checkSignup(data) {
|
|
542
|
-
if (!this.webhook?.wants("signup.before")) return { ok: true };
|
|
543
|
-
const check = await this.webhook.call("signup.before", data, WEBHOOK_TIMEOUT_MS);
|
|
544
|
-
if (!check.ok) {
|
|
545
|
-
console.error(`[madauth] Webhook "signup.before" failed: ${check.reason}`);
|
|
546
|
-
return { ok: false, reason: "unavailable" };
|
|
547
|
-
}
|
|
548
|
-
const answer = check.body;
|
|
549
|
-
if (answer?.allow === false) {
|
|
550
|
-
const message = typeof answer.message === "string" && answer.message ? answer.message : "Sign-up is not possible with this e-mail address.";
|
|
551
|
-
return { ok: false, reason: "rejected", message };
|
|
552
|
-
}
|
|
553
|
-
return { ok: true };
|
|
554
|
-
},
|
|
555
|
-
async emit(type, data) {
|
|
556
|
-
if (!this.webhook?.wants(type)) return;
|
|
557
|
-
const result = await this.webhook.call(type, data, EVENT_TIMEOUT_MS);
|
|
558
|
-
if (!result.ok) console.error(`[madauth] Webhook "${type}" failed: ${result.reason}`);
|
|
559
|
-
}
|
|
560
|
-
};
|
|
561
|
-
const app = new Hono();
|
|
562
|
-
app.use(
|
|
563
|
-
"/auth/*",
|
|
564
|
-
cors({
|
|
565
|
-
origin: (origin) => isAllowedOrigin(origin) ? origin : null,
|
|
566
|
-
credentials: true,
|
|
567
|
-
allowMethods: ["GET", "POST"],
|
|
568
|
-
allowHeaders: ["Content-Type"],
|
|
569
|
-
maxAge: 600
|
|
570
|
-
})
|
|
571
|
-
);
|
|
572
|
-
app.use("/auth/*", async (c, next) => {
|
|
573
|
-
if (c.req.method === "POST" && !isAllowedOrigin(c.req.header("origin"))) {
|
|
574
|
-
return c.json({ error: "forbidden_origin" }, 403);
|
|
575
|
-
}
|
|
576
|
-
await next();
|
|
577
|
-
});
|
|
578
|
-
app.get("/health", (c) => c.text("ok"));
|
|
579
|
-
app.get("/.well-known/jwks.json", async (c) => {
|
|
580
|
-
const { publicJwk } = await keys;
|
|
581
|
-
c.header("Cache-Control", "public, max-age=3600");
|
|
582
|
-
return c.json({ keys: [publicJwk] });
|
|
583
|
-
});
|
|
584
|
-
app.get("/auth/config", async (c) => {
|
|
585
|
-
const methods = await settings.methods();
|
|
586
|
-
const on = (method) => isOn(method, available, methods);
|
|
587
|
-
return c.json({
|
|
588
|
-
google: on("google") ? { clientId: config.google.clientId, codeFlow: !!config.google.clientSecret } : null,
|
|
589
|
-
password: on("password") ? { minLength: config.password.minLength } : null
|
|
590
|
-
});
|
|
591
|
-
});
|
|
592
|
-
if (config.google) googleRoutes(app, ctx, config.google);
|
|
593
|
-
if (config.password && ctx.webhook) passwordRoutes(app, ctx, config.password, users, ctx.webhook);
|
|
594
|
-
const tokenClaims = async (c, cookie, typ) => readToken(await keys, issuer, typ, getCookie2(c, cookie));
|
|
595
|
-
const checked = async (claims) => {
|
|
596
|
-
const stored = await users.findById(claims.sub);
|
|
597
|
-
if (!stored || stored.sessionVersion !== claims.sv) return null;
|
|
598
|
-
const { claims: _, ...user } = userFromClaims(claims);
|
|
599
|
-
const current = claimsFromJson(stored.claims);
|
|
600
|
-
return Object.keys(current).length ? { ...user, claims: current } : user;
|
|
601
|
-
};
|
|
602
|
-
const currentSession = async (c) => {
|
|
603
|
-
const session = await tokenClaims(c, SESSION_COOKIE, SESSION_TYP);
|
|
604
|
-
const user = session ? await checked(session) : null;
|
|
605
|
-
const upToDate = session && user && sameClaims(user.claims, session.claims) && Date.now() / 1e3 - session.iat <= sessionTtlSeconds / 2;
|
|
606
|
-
if (upToDate) return user;
|
|
607
|
-
const renewal = await tokenClaims(c, RENEWAL_COOKIE, RENEWAL_TYP);
|
|
608
|
-
const sameSignIn = session && user && renewal && renewal.sub === session.sub && renewal.sv === session.sv && renewal.email === session.email;
|
|
609
|
-
const renewed = sameSignIn ? user : renewal ? await checked(renewal) : null;
|
|
610
|
-
if (renewal && renewed) {
|
|
611
|
-
await issueSession(c, renewed, renewal.amr, { sv: renewal.sv });
|
|
612
|
-
return renewed;
|
|
613
|
-
}
|
|
614
|
-
return user;
|
|
615
|
-
};
|
|
616
|
-
app.get("/auth/session", async (c) => {
|
|
617
|
-
const user = await currentSession(c);
|
|
618
|
-
if (!user) {
|
|
619
|
-
clearSession(c);
|
|
620
|
-
return c.json({ error: "no_session" }, 401);
|
|
621
|
-
}
|
|
622
|
-
return c.json({ user });
|
|
623
|
-
});
|
|
624
|
-
app.post("/auth/logout", (c) => {
|
|
625
|
-
clearSession(c);
|
|
626
|
-
return c.body(null, 204);
|
|
627
|
-
});
|
|
628
|
-
app.post("/auth/account/delete", async (c) => {
|
|
629
|
-
const user = await currentSession(c);
|
|
630
|
-
if (!user) return c.json({ error: "no_session" }, 401);
|
|
631
|
-
await users.deleteById(user.id);
|
|
632
|
-
clearSession(c);
|
|
633
|
-
await ctx.emit("user.deleted", { user });
|
|
634
|
-
return c.body(null, 204);
|
|
635
|
-
});
|
|
636
|
-
const admin = async (c) => {
|
|
637
|
-
const user = await currentSession(c);
|
|
638
|
-
if (!user) return { answer: c.json({ error: "no_session" }, 401) };
|
|
639
|
-
const email = user.email ? normalizeEmail(user.email) : void 0;
|
|
640
|
-
if (!email || !isAdmin(user)) {
|
|
641
|
-
const message = `Only users with the role "${ADMIN_ROLE}" can manage claims and settings.`;
|
|
642
|
-
return { answer: c.json({ error: "forbidden", message }, 403) };
|
|
643
|
-
}
|
|
644
|
-
return { email };
|
|
645
|
-
};
|
|
646
|
-
const body = async (c) => {
|
|
647
|
-
const data = await c.req.json().catch(() => null);
|
|
648
|
-
return data && typeof data === "object" ? data : {};
|
|
649
|
-
};
|
|
650
|
-
const invalidEmail = (c) => c.json({ error: "invalid_email", message: "This is not a valid e-mail address." }, 400);
|
|
651
|
-
const noUser = (c) => c.json({ error: "user_not_found", message: "No user has this e-mail address." }, 404);
|
|
652
|
-
app.post("/auth/admin/claims/get", async (c) => {
|
|
653
|
-
const caller = await admin(c);
|
|
654
|
-
if ("answer" in caller) return caller.answer;
|
|
655
|
-
const data = await body(c);
|
|
656
|
-
if (!isValidEmail(data.email)) return invalidEmail(c);
|
|
657
|
-
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
658
|
-
if (!user) return noUser(c);
|
|
659
|
-
return c.json({ email: user.emailNormalized, userId: user.id, claims: claimsFromJson(user.claims) });
|
|
660
|
-
});
|
|
661
|
-
app.post("/auth/admin/claims/set", async (c) => {
|
|
662
|
-
const caller = await admin(c);
|
|
663
|
-
if ("answer" in caller) return caller.answer;
|
|
664
|
-
const data = await body(c);
|
|
665
|
-
if (!isValidEmail(data.email)) return invalidEmail(c);
|
|
666
|
-
const claims = parseClaims(data.claims);
|
|
667
|
-
if (!claims) {
|
|
668
|
-
return c.json(
|
|
669
|
-
{
|
|
670
|
-
error: "invalid_claims",
|
|
671
|
-
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 "_".'
|
|
672
|
-
},
|
|
673
|
-
400
|
|
674
|
-
);
|
|
675
|
-
}
|
|
676
|
-
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
677
|
-
if (!user) return noUser(c);
|
|
678
|
-
await users.updateUser(user.id, { claims: Object.keys(claims).length ? JSON.stringify(claims) : null });
|
|
679
|
-
const email = user.emailNormalized;
|
|
680
|
-
await ctx.emit("user.claims_changed", { userId: user.id, email, claims, by: caller.email });
|
|
681
|
-
return c.json({ email, userId: user.id, claims });
|
|
682
|
-
});
|
|
683
|
-
const settingsAnswer = (methods) => ({
|
|
684
|
-
methods: Object.fromEntries(
|
|
685
|
-
SIGN_IN_METHODS.map((method) => [method, { available: available(method), enabled: isOn(method, available, methods) }])
|
|
686
|
-
)
|
|
687
|
-
});
|
|
688
|
-
app.post("/auth/admin/settings/get", async (c) => {
|
|
689
|
-
const caller = await admin(c);
|
|
690
|
-
if ("answer" in caller) return caller.answer;
|
|
691
|
-
return c.json(settingsAnswer(await settings.methods()));
|
|
692
|
-
});
|
|
693
|
-
app.post("/auth/admin/settings/set", async (c) => {
|
|
694
|
-
const caller = await admin(c);
|
|
695
|
-
if ("answer" in caller) return caller.answer;
|
|
696
|
-
const data = await body(c);
|
|
697
|
-
const invalid = (message) => c.json({ error: "invalid_settings", message }, 400);
|
|
698
|
-
const changes = parseMethodSettings(data.methods);
|
|
699
|
-
if (!changes) return invalid(`methods must be an object with true or false for ${SIGN_IN_METHODS.join(" and/or ")}.`);
|
|
700
|
-
const methods = await settings.changeMethods((current) => {
|
|
701
|
-
const next = { ...current, ...changes };
|
|
702
|
-
return anyOn(available, next) ? next : null;
|
|
703
|
-
}, caller.email);
|
|
704
|
-
if (!methods) return invalid("At least one sign-in method must stay on.");
|
|
705
|
-
return c.json(settingsAnswer(methods));
|
|
706
|
-
});
|
|
707
|
-
return app;
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
export {
|
|
711
|
-
REDIRECT_ERROR_PARAM,
|
|
712
|
-
createApp
|
|
713
|
-
};
|