@madauth/server 0.1.0 → 0.2.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -7
- package/dist/bin.js +284 -22
- package/dist/{chunk-V2XKGETK.js → chunk-5CKEMC6M.js} +163 -28
- package/dist/{chunk-AVZE6FFR.js → chunk-6EJDVZCD.js} +8 -3
- package/dist/{chunk-PRRKLEW7.js → chunk-7CTAB3QV.js} +21 -0
- package/dist/{chunk-3WIWWKRI.js → chunk-CNL562LZ.js} +17 -42
- package/dist/chunk-XNBWZZHT.js +60 -0
- package/dist/{chunk-TEMZRMFF.js → chunk-YALS7OMV.js} +162 -42
- package/dist/{config-KQvE2imU.d.ts → config-BkxADgtp.d.ts} +16 -4
- package/dist/dynamodb.js +244 -0
- package/dist/entry/azure.d.ts +5 -5
- package/dist/entry/azure.js +9 -7
- package/dist/entry/lambda.d.ts +5 -5
- package/dist/entry/lambda.js +9 -7
- package/dist/entry/node.d.ts +5 -8
- package/dist/entry/node.js +8 -6
- package/dist/index.d.ts +12 -7
- package/dist/index.js +12 -8
- package/dist/{schema-Dotc6LU7.d.ts → schema-BSgYVRpd.d.ts} +28 -1
- package/dist/sqlite.js +7 -4
- package/dist/standalone/azure.mjs +611 -78
- package/dist/standalone/lambda.mjs +611 -78
- package/dist/standalone/main.mjs +29508 -592
- package/dist/store/dynamodb.d.ts +19 -0
- package/dist/store/sqlite.d.ts +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +65 -2
- package/dist/{user-3DDyggFV.d.ts → user-C1A3BfOo.d.ts} +2 -0
- package/dist/verify.d.ts +1 -1
- package/dist/verify.js +1 -1
- package/dist/{webhook-CT80emTQ.d.ts → webhook-ltwulgcB.d.ts} +3 -3
- package/dist/webhook.d.ts +1 -1
- package/dist/webhook.js +1 -1
- package/package.json +12 -2
|
@@ -1,29 +1,37 @@
|
|
|
1
1
|
import {
|
|
2
|
+
EXPIRY_COOKIE,
|
|
3
|
+
RENEWAL_COOKIE,
|
|
4
|
+
RENEWAL_TYP,
|
|
2
5
|
SESSION_COOKIE,
|
|
3
6
|
SESSION_TYP,
|
|
4
7
|
randomString,
|
|
5
8
|
readToken,
|
|
9
|
+
signRenewal,
|
|
6
10
|
signSession,
|
|
7
11
|
signToken,
|
|
8
12
|
userFromClaims
|
|
9
|
-
} from "./chunk-
|
|
13
|
+
} from "./chunk-7CTAB3QV.js";
|
|
10
14
|
import {
|
|
15
|
+
ADMIN_ROLE,
|
|
11
16
|
MAX_PASSWORD_LENGTH,
|
|
17
|
+
Roles,
|
|
12
18
|
Users,
|
|
13
19
|
checkPasswordPolicy,
|
|
14
20
|
deriveCodeKey,
|
|
15
21
|
hashPassword,
|
|
16
22
|
isValidEmail,
|
|
17
23
|
normalizeEmail,
|
|
24
|
+
parseRoles,
|
|
25
|
+
sameRoles,
|
|
18
26
|
verifyAgainstDummy,
|
|
19
27
|
verifyPassword
|
|
20
|
-
} from "./chunk-
|
|
28
|
+
} from "./chunk-5CKEMC6M.js";
|
|
21
29
|
import {
|
|
22
30
|
importSigningKeys
|
|
23
31
|
} from "./chunk-JDQLSL36.js";
|
|
24
32
|
import {
|
|
25
33
|
createWebhookClient
|
|
26
|
-
} from "./chunk-
|
|
34
|
+
} from "./chunk-6EJDVZCD.js";
|
|
27
35
|
|
|
28
36
|
// src/routes/google.ts
|
|
29
37
|
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
|
@@ -112,9 +120,9 @@ function googleRoutes(app, ctx, google) {
|
|
|
112
120
|
return c.json({ error: result.error, message: result.reason }, result.error === "email_unverified" ? 403 : 401);
|
|
113
121
|
}
|
|
114
122
|
deleteCookie(c, NONCE_COOKIE, { path: "/auth/google", secure });
|
|
115
|
-
await ctx.startSession(c, result.user, ["google"]);
|
|
116
|
-
await ctx.emit("user.signed_in", { user
|
|
117
|
-
return c.json({ user
|
|
123
|
+
const user = await ctx.startSession(c, result.user, ["google"]);
|
|
124
|
+
await ctx.emit("user.signed_in", { user, method: "google" });
|
|
125
|
+
return c.json({ user });
|
|
118
126
|
});
|
|
119
127
|
const requireCodeFlow = async (c, next) => {
|
|
120
128
|
if (!google.clientSecret) return c.json({ error: "not_found" }, 404);
|
|
@@ -188,8 +196,8 @@ function googleRoutes(app, ctx, google) {
|
|
|
188
196
|
keys: config.jwksResolver
|
|
189
197
|
});
|
|
190
198
|
if (!result.ok) return back(result.error);
|
|
191
|
-
await ctx.startSession(c, result.user, ["google"]);
|
|
192
|
-
await ctx.emit("user.signed_in", { user
|
|
199
|
+
const user = await ctx.startSession(c, result.user, ["google"]);
|
|
200
|
+
await ctx.emit("user.signed_in", { user, method: "google" });
|
|
193
201
|
return back();
|
|
194
202
|
});
|
|
195
203
|
}
|
|
@@ -259,12 +267,17 @@ function passwordRoutes(app, ctx, password, users, webhook) {
|
|
|
259
267
|
}
|
|
260
268
|
if (isValidEmail(data.email) && typeof data.code === "string") {
|
|
261
269
|
const user = await users.findByEmail(normalizeEmail(data.email));
|
|
262
|
-
|
|
263
|
-
return {
|
|
270
|
+
if (!user) return { userId: null, via: "code" };
|
|
271
|
+
return { ...await users.consumeCode(user, purpose, data.code.replace(/\s/g, "")), via: "code" };
|
|
264
272
|
}
|
|
265
273
|
return { userId: null, via: "code" };
|
|
266
274
|
};
|
|
267
|
-
const invalidVerification = (c,
|
|
275
|
+
const 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
|
+
};
|
|
268
281
|
app.post("/auth/password/signin", async (c) => {
|
|
269
282
|
const data = await body(c);
|
|
270
283
|
const wrong = () => error(c, 401, "invalid_credentials", "E-mail or password is wrong.");
|
|
@@ -296,8 +309,7 @@ function passwordRoutes(app, ctx, password, users, webhook) {
|
|
|
296
309
|
if (!user.emailVerified) {
|
|
297
310
|
return error(c, 403, "email_unverified", "Please confirm your e-mail address first. We can send the e-mail again.");
|
|
298
311
|
}
|
|
299
|
-
const result = toMadauthUser(user);
|
|
300
|
-
await ctx.startSession(c, result, ["pwd"], { sv: user.sessionVersion });
|
|
312
|
+
const result = await ctx.startSession(c, toMadauthUser(user), ["pwd"], { sv: user.sessionVersion });
|
|
301
313
|
await ctx.emit("user.signed_in", { user: result, method: "password" });
|
|
302
314
|
return c.json({ user: result });
|
|
303
315
|
});
|
|
@@ -326,6 +338,7 @@ function passwordRoutes(app, ctx, password, users, webhook) {
|
|
|
326
338
|
const passwordHash = await hashPassword(data.password);
|
|
327
339
|
const existing = await users.findByEmail(normalizeEmail(email));
|
|
328
340
|
if (existing?.emailVerified) {
|
|
341
|
+
if (!webhook.wants("email.already_registered")) return accepted(c);
|
|
329
342
|
const sent = await sendEmail(existing, "email.already_registered", { link: target.href, site: target.host, locale });
|
|
330
343
|
return sent ? accepted(c) : unavailable(c);
|
|
331
344
|
}
|
|
@@ -359,12 +372,12 @@ function passwordRoutes(app, ctx, password, users, webhook) {
|
|
|
359
372
|
return accepted(c);
|
|
360
373
|
});
|
|
361
374
|
app.post("/auth/password/verify-email", async (c) => {
|
|
362
|
-
const
|
|
375
|
+
const consumed = await consume(await body(c), "verify");
|
|
376
|
+
const { userId, via } = consumed;
|
|
363
377
|
const user = userId ? await users.findById(userId) : null;
|
|
364
|
-
if (!user) return invalidVerification(c,
|
|
365
|
-
await users.updateUser(user.id, { emailVerified: true });
|
|
366
|
-
const result = toMadauthUser(user);
|
|
367
|
-
await ctx.startSession(c, result, ["pwd"], { sv: user.sessionVersion });
|
|
378
|
+
if (!user) return invalidVerification(c, consumed);
|
|
379
|
+
await users.updateUser(user.id, { emailVerified: true, wrongCodes: 0 });
|
|
380
|
+
const result = await ctx.startSession(c, toMadauthUser(user), ["pwd"], { sv: user.sessionVersion });
|
|
368
381
|
await ctx.emit("email.verified", { user: result, via });
|
|
369
382
|
await ctx.emit("user.signed_in", { user: result, method: "password" });
|
|
370
383
|
return c.json({ user: result });
|
|
@@ -384,16 +397,15 @@ function passwordRoutes(app, ctx, password, users, webhook) {
|
|
|
384
397
|
const data = await body(c);
|
|
385
398
|
const policy = checkPasswordPolicy(data.password, minLength);
|
|
386
399
|
if (policy) return error(c, 400, "weak_password", policy);
|
|
387
|
-
const
|
|
388
|
-
const user = userId ? await users.findById(userId) : null;
|
|
400
|
+
const consumed = await consume(data, "reset");
|
|
401
|
+
const user = consumed.userId ? await users.findById(consumed.userId) : null;
|
|
389
402
|
const account = user ? await users.passwordAccount(user.id) : null;
|
|
390
|
-
if (!user || !account) return invalidVerification(c,
|
|
403
|
+
if (!user || !account) return invalidVerification(c, consumed);
|
|
391
404
|
await users.updateAccount(account.id, { secret: await hashPassword(data.password), failedAttempts: 0, lockedUntil: 0 });
|
|
392
405
|
const sessionVersion = user.sessionVersion + 1;
|
|
393
|
-
await users.updateUser(user.id, { emailVerified: true, sessionVersion });
|
|
406
|
+
await users.updateUser(user.id, { emailVerified: true, sessionVersion, wrongCodes: 0 });
|
|
394
407
|
await users.clearVerifications(user.id);
|
|
395
|
-
const result = toMadauthUser(user);
|
|
396
|
-
await ctx.startSession(c, result, ["pwd"], { sv: sessionVersion });
|
|
408
|
+
const result = await ctx.startSession(c, toMadauthUser(user), ["pwd"], { sv: sessionVersion });
|
|
397
409
|
await ctx.emit("password.reset", { user: result });
|
|
398
410
|
await ctx.emit("user.signed_in", { user: result, method: "password" });
|
|
399
411
|
return c.json({ user: result });
|
|
@@ -403,10 +415,50 @@ function passwordRoutes(app, ctx, password, users, webhook) {
|
|
|
403
415
|
// src/app.ts
|
|
404
416
|
var EVENT_TIMEOUT_MS = 5e3;
|
|
405
417
|
function createApp(config) {
|
|
406
|
-
const { issuer, allowedOrigins, sessionTtlSeconds, cookieDomain } = config;
|
|
418
|
+
const { issuer, allowedOrigins, sessionTtlSeconds, renewalTtlSeconds, cookieDomain } = config;
|
|
407
419
|
const keys = importSigningKeys(config.signingKey);
|
|
408
420
|
const secure = issuer.startsWith("https:");
|
|
409
421
|
const isAllowedOrigin = (origin) => !!origin && allowedOrigins.includes(origin);
|
|
422
|
+
const roles = config.password ? new Roles(config.password.store) : void 0;
|
|
423
|
+
const withRoles = async (user) => {
|
|
424
|
+
const { roles: _, ...rest } = user;
|
|
425
|
+
const current = roles && user.email ? await roles.get(normalizeEmail(user.email)) : [];
|
|
426
|
+
return current.length ? { ...rest, roles: current } : rest;
|
|
427
|
+
};
|
|
428
|
+
const issueSession = async (c, user, amr, extra) => {
|
|
429
|
+
const signingKeys = await keys;
|
|
430
|
+
const [sessionToken, renewalToken] = await Promise.all([
|
|
431
|
+
signSession(signingKeys, issuer, sessionTtlSeconds, user, amr, extra),
|
|
432
|
+
signRenewal(signingKeys, issuer, renewalTtlSeconds, user, amr, extra)
|
|
433
|
+
]);
|
|
434
|
+
setCookie2(c, SESSION_COOKIE, sessionToken, {
|
|
435
|
+
path: "/",
|
|
436
|
+
domain: cookieDomain,
|
|
437
|
+
httpOnly: true,
|
|
438
|
+
secure,
|
|
439
|
+
sameSite: "Lax",
|
|
440
|
+
maxAge: sessionTtlSeconds
|
|
441
|
+
});
|
|
442
|
+
setCookie2(c, RENEWAL_COOKIE, renewalToken, {
|
|
443
|
+
path: "/auth",
|
|
444
|
+
httpOnly: true,
|
|
445
|
+
secure,
|
|
446
|
+
sameSite: "Lax",
|
|
447
|
+
maxAge: renewalTtlSeconds
|
|
448
|
+
});
|
|
449
|
+
setCookie2(c, EXPIRY_COOKIE, String(Math.floor(Date.now() / 1e3) + sessionTtlSeconds), {
|
|
450
|
+
path: "/",
|
|
451
|
+
domain: cookieDomain,
|
|
452
|
+
secure,
|
|
453
|
+
sameSite: "Lax",
|
|
454
|
+
maxAge: renewalTtlSeconds
|
|
455
|
+
});
|
|
456
|
+
};
|
|
457
|
+
const clearSession = (c) => {
|
|
458
|
+
deleteCookie2(c, SESSION_COOKIE, { path: "/", domain: cookieDomain, secure });
|
|
459
|
+
deleteCookie2(c, RENEWAL_COOKIE, { path: "/auth", secure });
|
|
460
|
+
deleteCookie2(c, EXPIRY_COOKIE, { path: "/", domain: cookieDomain, secure });
|
|
461
|
+
};
|
|
410
462
|
const ctx = {
|
|
411
463
|
config,
|
|
412
464
|
keys,
|
|
@@ -424,15 +476,9 @@ function createApp(config) {
|
|
|
424
476
|
return url;
|
|
425
477
|
},
|
|
426
478
|
async startSession(c, user, amr, extra) {
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
domain: cookieDomain,
|
|
431
|
-
httpOnly: true,
|
|
432
|
-
secure,
|
|
433
|
-
sameSite: "Lax",
|
|
434
|
-
maxAge: sessionTtlSeconds
|
|
435
|
-
});
|
|
479
|
+
const signedIn = await withRoles(user);
|
|
480
|
+
await issueSession(c, signedIn, amr, extra);
|
|
481
|
+
return signedIn;
|
|
436
482
|
},
|
|
437
483
|
users: config.password ? new Users(config.password.store, deriveCodeKey(config.signingKey.d)) : void 0,
|
|
438
484
|
webhook: config.webhook ? createWebhookClient(config.webhook, config.webhookFetch) : void 0,
|
|
@@ -474,21 +520,95 @@ function createApp(config) {
|
|
|
474
520
|
);
|
|
475
521
|
if (config.google) googleRoutes(app, ctx, config.google);
|
|
476
522
|
if (config.password && ctx.users && ctx.webhook) passwordRoutes(app, ctx, config.password, ctx.users, ctx.webhook);
|
|
523
|
+
const tokenClaims = async (c, cookie, typ) => readToken(await keys, issuer, typ, getCookie2(c, cookie));
|
|
524
|
+
const hasEnded = async (claims) => {
|
|
525
|
+
if (!claims.sub.startsWith("usr_")) return false;
|
|
526
|
+
const stored = await ctx.users?.findById(claims.sub);
|
|
527
|
+
return !stored || stored.sessionVersion !== claims.sv;
|
|
528
|
+
};
|
|
529
|
+
const checked = async (claims) => {
|
|
530
|
+
const [ended, user] = await Promise.all([hasEnded(claims), withRoles(userFromClaims(claims))]);
|
|
531
|
+
return ended ? null : user;
|
|
532
|
+
};
|
|
533
|
+
const currentSession = async (c) => {
|
|
534
|
+
const session = await tokenClaims(c, SESSION_COOKIE, SESSION_TYP);
|
|
535
|
+
const user = session ? await checked(session) : null;
|
|
536
|
+
const upToDate = session && user && sameRoles(user.roles ?? [], session.roles ?? []) && Date.now() / 1e3 - session.iat <= sessionTtlSeconds / 2;
|
|
537
|
+
if (upToDate) return user;
|
|
538
|
+
const renewal = await tokenClaims(c, RENEWAL_COOKIE, RENEWAL_TYP);
|
|
539
|
+
const sameSignIn = session && user && renewal && renewal.sub === session.sub && renewal.sv === session.sv && renewal.email === session.email;
|
|
540
|
+
const renewed = sameSignIn ? user : renewal ? await checked(renewal) : null;
|
|
541
|
+
if (renewal && renewed) {
|
|
542
|
+
await issueSession(c, renewed, renewal.amr, { sv: renewal.sv });
|
|
543
|
+
return renewed;
|
|
544
|
+
}
|
|
545
|
+
return user;
|
|
546
|
+
};
|
|
477
547
|
app.get("/auth/session", async (c) => {
|
|
478
|
-
const
|
|
479
|
-
if (!
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
if (!stored || stored.sessionVersion !== claims.sv) return c.json({ error: "no_session" }, 401);
|
|
548
|
+
const user = await currentSession(c);
|
|
549
|
+
if (!user) {
|
|
550
|
+
clearSession(c);
|
|
551
|
+
return c.json({ error: "no_session" }, 401);
|
|
483
552
|
}
|
|
484
|
-
const user = userFromClaims(claims);
|
|
485
|
-
if (Date.now() / 1e3 - claims.iat > sessionTtlSeconds / 2) await ctx.startSession(c, user, claims.amr, { sv: claims.sv });
|
|
486
553
|
return c.json({ user });
|
|
487
554
|
});
|
|
488
555
|
app.post("/auth/logout", (c) => {
|
|
489
|
-
|
|
556
|
+
clearSession(c);
|
|
490
557
|
return c.body(null, 204);
|
|
491
558
|
});
|
|
559
|
+
app.post("/auth/account/delete", async (c) => {
|
|
560
|
+
const user = await currentSession(c);
|
|
561
|
+
if (!user) return c.json({ error: "no_session" }, 401);
|
|
562
|
+
if (roles && user.email) await roles.remove(normalizeEmail(user.email));
|
|
563
|
+
const passwordUserId = ctx.users && user.email ? await ctx.users.deleteByEmail(normalizeEmail(user.email)) : null;
|
|
564
|
+
clearSession(c);
|
|
565
|
+
await ctx.emit("user.deleted", { user, passwordUserId: passwordUserId ?? void 0 });
|
|
566
|
+
return c.body(null, 204);
|
|
567
|
+
});
|
|
568
|
+
if (roles) {
|
|
569
|
+
const admin = async (c) => {
|
|
570
|
+
const user = await currentSession(c);
|
|
571
|
+
if (!user) return { answer: c.json({ error: "no_session" }, 401) };
|
|
572
|
+
const email = user.email ? normalizeEmail(user.email) : void 0;
|
|
573
|
+
if (!email || !user.roles?.includes(ADMIN_ROLE)) {
|
|
574
|
+
return { answer: c.json({ error: "forbidden", message: `Only users with the role "${ADMIN_ROLE}" can manage roles.` }, 403) };
|
|
575
|
+
}
|
|
576
|
+
return { email };
|
|
577
|
+
};
|
|
578
|
+
const body = async (c) => {
|
|
579
|
+
const data = await c.req.json().catch(() => null);
|
|
580
|
+
return data && typeof data === "object" ? data : {};
|
|
581
|
+
};
|
|
582
|
+
const invalidEmail = (c) => c.json({ error: "invalid_email", message: "This is not a valid e-mail address." }, 400);
|
|
583
|
+
app.post("/auth/admin/roles/get", async (c) => {
|
|
584
|
+
const caller = await admin(c);
|
|
585
|
+
if ("answer" in caller) return caller.answer;
|
|
586
|
+
const data = await body(c);
|
|
587
|
+
if (!isValidEmail(data.email)) return invalidEmail(c);
|
|
588
|
+
const email = normalizeEmail(data.email);
|
|
589
|
+
return c.json({ email, roles: await roles.get(email) });
|
|
590
|
+
});
|
|
591
|
+
app.post("/auth/admin/roles/set", async (c) => {
|
|
592
|
+
const caller = await admin(c);
|
|
593
|
+
if ("answer" in caller) return caller.answer;
|
|
594
|
+
const data = await body(c);
|
|
595
|
+
if (!isValidEmail(data.email)) return invalidEmail(c);
|
|
596
|
+
const names = parseRoles(data.roles);
|
|
597
|
+
if (!names) {
|
|
598
|
+
return c.json(
|
|
599
|
+
{
|
|
600
|
+
error: "invalid_roles",
|
|
601
|
+
message: 'roles must be a list of names: lower-case letters, digits, "-" and "_", starting with a letter.'
|
|
602
|
+
},
|
|
603
|
+
400
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
const email = normalizeEmail(data.email);
|
|
607
|
+
await roles.set(email, names, caller.email);
|
|
608
|
+
await ctx.emit("roles.changed", { email, roles: names, by: caller.email });
|
|
609
|
+
return c.json({ email, roles: names });
|
|
610
|
+
});
|
|
611
|
+
}
|
|
492
612
|
return app;
|
|
493
613
|
}
|
|
494
614
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { JWK, JWTVerifyGetKey } from 'jose';
|
|
2
|
-
import { S as StoreAdapter } from './schema-
|
|
3
|
-
import { W as WebhookSettings } from './webhook-
|
|
2
|
+
import { S as StoreAdapter } from './schema-BSgYVRpd.js';
|
|
3
|
+
import { W as WebhookSettings } from './webhook-ltwulgcB.js';
|
|
4
4
|
|
|
5
5
|
interface MadauthConfig {
|
|
6
6
|
/** Public base URL of the server (MADAUTH_ISSUER). */
|
|
@@ -9,8 +9,10 @@ interface MadauthConfig {
|
|
|
9
9
|
signingKey: JWK;
|
|
10
10
|
/** App origins allowed to call the server (ALLOWED_ORIGINS). */
|
|
11
11
|
allowedOrigins: string[];
|
|
12
|
-
/**
|
|
12
|
+
/** Lifetime of a session token in seconds (SESSION_TTL): how long backends accept it. */
|
|
13
13
|
sessionTtlSeconds: number;
|
|
14
|
+
/** How long a session can be renewed without signing in again, counted from its last renewal (SESSION_RENEWAL_TTL). */
|
|
15
|
+
renewalTtlSeconds: number;
|
|
14
16
|
/** Cookie domain, e.g. `.example.com` (COOKIE_DOMAIN). */
|
|
15
17
|
cookieDomain?: string;
|
|
16
18
|
/** Google sign-in; off when GOOGLE_CLIENT_ID is not set. */
|
|
@@ -46,6 +48,7 @@ declare const envVars: {
|
|
|
46
48
|
readonly WEBHOOK_SECRET: false;
|
|
47
49
|
readonly WEBHOOK_EVENTS: false;
|
|
48
50
|
readonly SESSION_TTL: false;
|
|
51
|
+
readonly SESSION_RENEWAL_TTL: false;
|
|
49
52
|
readonly COOKIE_DOMAIN: false;
|
|
50
53
|
};
|
|
51
54
|
/** Instead of environment variables: your own store adapter. */
|
|
@@ -53,6 +56,15 @@ interface ConfigOverrides {
|
|
|
53
56
|
/** Stores users; replaces DATABASE_URL. */
|
|
54
57
|
store?: StoreAdapter;
|
|
55
58
|
}
|
|
59
|
+
type Env = Record<string, string | undefined>;
|
|
60
|
+
/** Options of the hosting entry points: `start` (Node), `createHandler` (AWS Lambda) and `register` (Azure). */
|
|
61
|
+
interface EntryOptions extends ConfigOverrides {
|
|
62
|
+
/**
|
|
63
|
+
* The environment variables to read, or a function that loads them, e.g. to add secrets from a
|
|
64
|
+
* parameter store. The function runs once, before the first request. Default: `process.env`.
|
|
65
|
+
*/
|
|
66
|
+
env?: Env | (() => Env | Promise<Env>);
|
|
67
|
+
}
|
|
56
68
|
declare class ConfigError extends Error {
|
|
57
69
|
name: string;
|
|
58
70
|
}
|
|
@@ -62,4 +74,4 @@ declare class ConfigError extends Error {
|
|
|
62
74
|
*/
|
|
63
75
|
declare function loadConfig(env: Record<string, string | undefined>, overrides?: ConfigOverrides): Promise<MadauthConfig>;
|
|
64
76
|
|
|
65
|
-
export { type
|
|
77
|
+
export { ConfigError as C, type EntryOptions as E, type MadauthConfig as M, type ConfigOverrides as a, envVars as e, loadConfig as l };
|
package/dist/dynamodb.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import {
|
|
2
|
+
madauthSchema
|
|
3
|
+
} from "./chunk-XNBWZZHT.js";
|
|
4
|
+
|
|
5
|
+
// src/store/dynamodb.ts
|
|
6
|
+
var SORT_KEY = "#";
|
|
7
|
+
var MAX_RETRIES = 8;
|
|
8
|
+
var RETRYABLE_REASONS = ["TransactionConflict", "ThrottlingError", "ProvisionedThroughputExceeded"];
|
|
9
|
+
var RETRYABLE_ERRORS = ["TransactionConflictException", "TransactionInProgressException"];
|
|
10
|
+
var recordKey = (model, id) => ({ pk: { S: `r|${model}|${id}` }, sk: { S: SORT_KEY } });
|
|
11
|
+
var uniqueKey = (model, field, value) => ({
|
|
12
|
+
pk: { S: `u|${model}|${field}|${String(value)}` },
|
|
13
|
+
sk: { S: SORT_KEY }
|
|
14
|
+
});
|
|
15
|
+
var indexPartition = (model, field, value) => `i|${model}|${field}|${String(value)}`;
|
|
16
|
+
var indexKey = (model, field, value, id) => ({
|
|
17
|
+
pk: { S: indexPartition(model, field, value) },
|
|
18
|
+
sk: { S: id }
|
|
19
|
+
});
|
|
20
|
+
function toAttribute(value) {
|
|
21
|
+
if (value === null) return { NULL: true };
|
|
22
|
+
if (typeof value === "string") return { S: value };
|
|
23
|
+
if (typeof value === "number") return { N: String(value) };
|
|
24
|
+
return { BOOL: value };
|
|
25
|
+
}
|
|
26
|
+
function fromAttribute(attribute) {
|
|
27
|
+
if (attribute?.S !== void 0) return attribute.S;
|
|
28
|
+
if (attribute?.N !== void 0) return Number(attribute.N);
|
|
29
|
+
if (attribute?.BOOL !== void 0) return attribute.BOOL;
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
function fieldsOf(model) {
|
|
33
|
+
const fields = madauthSchema.models[model]?.fields;
|
|
34
|
+
if (!fields) throw new Error(`Unknown model "${model}"`);
|
|
35
|
+
return fields;
|
|
36
|
+
}
|
|
37
|
+
function checkFields(fields, names) {
|
|
38
|
+
for (const name of names) if (!fields[name]) throw new Error(`Unknown field "${name}"`);
|
|
39
|
+
}
|
|
40
|
+
function primaryKeyOf(fields) {
|
|
41
|
+
return Object.keys(fields).find((name) => fields[name].primaryKey);
|
|
42
|
+
}
|
|
43
|
+
function toRow(fields, item) {
|
|
44
|
+
return Object.fromEntries(Object.keys(fields).map((name) => [name, fromAttribute(item[name])]));
|
|
45
|
+
}
|
|
46
|
+
function matches(row, where) {
|
|
47
|
+
return Object.entries(where).every(([field, value]) => row[field] === value);
|
|
48
|
+
}
|
|
49
|
+
function stillMatches(where) {
|
|
50
|
+
const names = {};
|
|
51
|
+
const values = {};
|
|
52
|
+
const parts = ["attribute_exists(pk)"];
|
|
53
|
+
Object.entries(where).forEach(([field, value], i) => {
|
|
54
|
+
names[`#w${i}`] = field;
|
|
55
|
+
values[`:w${i}`] = toAttribute(value);
|
|
56
|
+
parts.push(value === null ? `(attribute_not_exists(#w${i}) OR #w${i} = :w${i})` : `#w${i} = :w${i}`);
|
|
57
|
+
});
|
|
58
|
+
return { text: parts.join(" AND "), names, values };
|
|
59
|
+
}
|
|
60
|
+
function failedWrite(error) {
|
|
61
|
+
const { name, CancellationReasons } = error;
|
|
62
|
+
if (name === "ConditionalCheckFailedException") return "refused";
|
|
63
|
+
if (name && RETRYABLE_ERRORS.includes(name)) return { retry: error };
|
|
64
|
+
if (name === "TransactionCanceledException") {
|
|
65
|
+
const reasons = (CancellationReasons ?? []).map((r) => r.Code ?? "None").filter((code) => code !== "None");
|
|
66
|
+
if (reasons.includes("ConditionalCheckFailed")) return "refused";
|
|
67
|
+
if (reasons.length && reasons.every((code) => RETRYABLE_REASONS.includes(code))) return { retry: error };
|
|
68
|
+
}
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
var pause = (attempt) => new Promise((resolve) => setTimeout(resolve, Math.random() * Math.min(25 * 2 ** attempt, 400)));
|
|
72
|
+
function createDynamoDbAdapter(options) {
|
|
73
|
+
const { tableName } = options;
|
|
74
|
+
let loading;
|
|
75
|
+
const aws = () => loading ??= import("@aws-sdk/client-dynamodb").then((sdk) => ({
|
|
76
|
+
sdk,
|
|
77
|
+
client: options.client ?? new sdk.DynamoDBClient({})
|
|
78
|
+
}));
|
|
79
|
+
const write = async (send) => {
|
|
80
|
+
for (let attempt = 0; ; attempt++) {
|
|
81
|
+
const outcome = await send().then(() => "written", failedWrite);
|
|
82
|
+
if (outcome === "written") return true;
|
|
83
|
+
if (outcome === "refused") return false;
|
|
84
|
+
if (attempt === MAX_RETRIES) throw outcome.retry;
|
|
85
|
+
await pause(attempt);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const transact = async (items) => {
|
|
89
|
+
const { sdk, client } = await aws();
|
|
90
|
+
return write(() => client.send(new sdk.TransactWriteItemsCommand({ TransactItems: items })));
|
|
91
|
+
};
|
|
92
|
+
const getItem = async (key) => {
|
|
93
|
+
const { sdk, client } = await aws();
|
|
94
|
+
return (await client.send(new sdk.GetItemCommand({ TableName: tableName, Key: key, ConsistentRead: true }))).Item;
|
|
95
|
+
};
|
|
96
|
+
const getRecord = async (model, fields, id) => {
|
|
97
|
+
const item = await getItem(recordKey(model, id));
|
|
98
|
+
return item ? toRow(fields, item) : null;
|
|
99
|
+
};
|
|
100
|
+
const idsOfIndex = async (model, field, value) => {
|
|
101
|
+
const { sdk, client } = await aws();
|
|
102
|
+
const ids = [];
|
|
103
|
+
let next;
|
|
104
|
+
do {
|
|
105
|
+
const page = await client.send(
|
|
106
|
+
new sdk.QueryCommand({
|
|
107
|
+
TableName: tableName,
|
|
108
|
+
KeyConditionExpression: "pk = :pk",
|
|
109
|
+
ExpressionAttributeValues: { ":pk": { S: indexPartition(model, field, value) } },
|
|
110
|
+
ProjectionExpression: "sk",
|
|
111
|
+
ConsistentRead: true,
|
|
112
|
+
ExclusiveStartKey: next
|
|
113
|
+
})
|
|
114
|
+
);
|
|
115
|
+
for (const item of page.Items ?? []) if (item.sk?.S) ids.push(item.sk.S);
|
|
116
|
+
next = page.LastEvaluatedKey;
|
|
117
|
+
} while (next);
|
|
118
|
+
return ids;
|
|
119
|
+
};
|
|
120
|
+
const find = async (model, where) => {
|
|
121
|
+
const fields = fieldsOf(model);
|
|
122
|
+
checkFields(fields, Object.keys(where));
|
|
123
|
+
const given = (name) => where[name] !== void 0 && where[name] !== null;
|
|
124
|
+
const primaryKey = primaryKeyOf(fields);
|
|
125
|
+
const unique = Object.keys(fields).find((name) => fields[name].unique && given(name));
|
|
126
|
+
const indexed = Object.keys(fields).find((name) => fields[name].index && given(name));
|
|
127
|
+
let ids;
|
|
128
|
+
if (given(primaryKey)) {
|
|
129
|
+
ids = [String(where[primaryKey])];
|
|
130
|
+
} else if (unique) {
|
|
131
|
+
const ref = (await getItem(uniqueKey(model, unique, where[unique])))?.ref?.S;
|
|
132
|
+
ids = ref ? [ref] : [];
|
|
133
|
+
} else if (indexed) {
|
|
134
|
+
ids = await idsOfIndex(model, indexed, where[indexed]);
|
|
135
|
+
} else {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`The DynamoDB adapter can't find "${model}" records by ${Object.keys(where).join(", ") || "nothing"}: the filter needs the id, a unique field or an indexed field.`
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
const rows = await Promise.all(ids.map((id) => getRecord(model, fields, id)));
|
|
141
|
+
return rows.filter((row) => row !== null && matches(row, where));
|
|
142
|
+
};
|
|
143
|
+
const derivedKeys = (model, fields, row, id) => Object.keys(fields).flatMap((name) => {
|
|
144
|
+
const value = row[name];
|
|
145
|
+
if (value === void 0 || value === null) return [];
|
|
146
|
+
return [
|
|
147
|
+
...fields[name].unique ? [uniqueKey(model, name, value)] : [],
|
|
148
|
+
...fields[name].index ? [indexKey(model, name, value, id)] : []
|
|
149
|
+
];
|
|
150
|
+
});
|
|
151
|
+
return {
|
|
152
|
+
async create(model, data) {
|
|
153
|
+
const fields = fieldsOf(model);
|
|
154
|
+
checkFields(fields, Object.keys(data));
|
|
155
|
+
const primaryKey = primaryKeyOf(fields);
|
|
156
|
+
const id = data[primaryKey];
|
|
157
|
+
if (typeof id !== "string" || !id) throw new Error(`A "${model}" record needs a "${primaryKey}"`);
|
|
158
|
+
const record = { ...recordKey(model, id) };
|
|
159
|
+
for (const [field, value] of Object.entries(data)) record[field] = toAttribute(value);
|
|
160
|
+
const unused = "attribute_not_exists(pk)";
|
|
161
|
+
return transact([
|
|
162
|
+
{ Put: { TableName: tableName, Item: record, ConditionExpression: unused } },
|
|
163
|
+
...derivedKeys(model, fields, data, id).map(
|
|
164
|
+
(key) => (
|
|
165
|
+
// A unique value refers to its record and may not be taken yet; an index entry is only a key.
|
|
166
|
+
key.pk.S.startsWith("u|") ? { Put: { TableName: tableName, Item: { ...key, ref: { S: id } }, ConditionExpression: unused } } : { Put: { TableName: tableName, Item: key } }
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
]);
|
|
170
|
+
},
|
|
171
|
+
async findOne(model, where) {
|
|
172
|
+
return (await find(model, where))[0] ?? null;
|
|
173
|
+
},
|
|
174
|
+
async findMany(model, where, opts = {}) {
|
|
175
|
+
const rows = await find(model, where);
|
|
176
|
+
return opts.limit === void 0 ? rows : rows.slice(0, Math.max(0, Math.floor(opts.limit)));
|
|
177
|
+
},
|
|
178
|
+
async update(model, where, patch) {
|
|
179
|
+
const fields = fieldsOf(model);
|
|
180
|
+
const changes = Object.entries(patch);
|
|
181
|
+
checkFields(fields, [...Object.keys(where), ...Object.keys(patch)]);
|
|
182
|
+
for (const [field] of changes) {
|
|
183
|
+
if (fields[field].primaryKey || fields[field].unique || fields[field].index) {
|
|
184
|
+
throw new Error(`The DynamoDB adapter can't change "${field}" of a "${model}" record: it is a key.`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (!changes.length) return 0;
|
|
188
|
+
const primaryKey = primaryKeyOf(fields);
|
|
189
|
+
const condition = stillMatches(where);
|
|
190
|
+
const names = { ...condition.names };
|
|
191
|
+
const values = { ...condition.values };
|
|
192
|
+
const assignments = changes.map(([field, value], i) => {
|
|
193
|
+
names[`#p${i}`] = field;
|
|
194
|
+
values[`:p${i}`] = toAttribute(value);
|
|
195
|
+
return `#p${i} = :p${i}`;
|
|
196
|
+
});
|
|
197
|
+
const byId = where[primaryKey];
|
|
198
|
+
const ids = byId !== void 0 && byId !== null ? [String(byId)] : (await find(model, where)).map((row) => String(row[primaryKey]));
|
|
199
|
+
const written = await Promise.all(
|
|
200
|
+
ids.map(
|
|
201
|
+
(id) => transact([
|
|
202
|
+
{
|
|
203
|
+
Update: {
|
|
204
|
+
TableName: tableName,
|
|
205
|
+
Key: recordKey(model, id),
|
|
206
|
+
UpdateExpression: `SET ${assignments.join(", ")}`,
|
|
207
|
+
ConditionExpression: condition.text,
|
|
208
|
+
ExpressionAttributeNames: names,
|
|
209
|
+
ExpressionAttributeValues: values
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
])
|
|
213
|
+
)
|
|
214
|
+
);
|
|
215
|
+
return written.filter(Boolean).length;
|
|
216
|
+
},
|
|
217
|
+
async delete(model, where) {
|
|
218
|
+
const fields = fieldsOf(model);
|
|
219
|
+
const primaryKey = primaryKeyOf(fields);
|
|
220
|
+
const condition = stillMatches(where);
|
|
221
|
+
const deleted = await Promise.all(
|
|
222
|
+
(await find(model, where)).map((row) => {
|
|
223
|
+
const id = String(row[primaryKey]);
|
|
224
|
+
return transact([
|
|
225
|
+
{
|
|
226
|
+
Delete: {
|
|
227
|
+
TableName: tableName,
|
|
228
|
+
Key: recordKey(model, id),
|
|
229
|
+
ConditionExpression: condition.text,
|
|
230
|
+
ExpressionAttributeNames: condition.names,
|
|
231
|
+
ExpressionAttributeValues: condition.values
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
...derivedKeys(model, fields, row, id).map((key) => ({ Delete: { TableName: tableName, Key: key } }))
|
|
235
|
+
]);
|
|
236
|
+
})
|
|
237
|
+
);
|
|
238
|
+
return deleted.filter(Boolean).length;
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
export {
|
|
243
|
+
createDynamoDbAdapter
|
|
244
|
+
};
|
package/dist/entry/azure.d.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { E as EntryOptions } from '../config-BkxADgtp.js';
|
|
2
2
|
import 'jose';
|
|
3
|
-
import '../schema-
|
|
4
|
-
import '../webhook-
|
|
3
|
+
import '../schema-BSgYVRpd.js';
|
|
4
|
+
import '../webhook-ltwulgcB.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Registers madAuth as an Azure Functions v4 HTTP function that handles every route. Pass `store` to use your
|
|
8
|
-
* own store adapter.
|
|
8
|
+
* own store adapter, and `env` to load settings from elsewhere, e.g. secrets from Key Vault.
|
|
9
9
|
* Set `"routePrefix": ""` in host.json so the routes are served at /auth/... (see docs/server.md).
|
|
10
10
|
*/
|
|
11
|
-
declare function register(
|
|
11
|
+
declare function register(options?: EntryOptions): void;
|
|
12
12
|
|
|
13
13
|
export { register };
|
package/dist/entry/azure.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createApp
|
|
3
|
-
} from "../chunk-
|
|
4
|
-
import "../chunk-
|
|
3
|
+
} from "../chunk-YALS7OMV.js";
|
|
4
|
+
import "../chunk-7CTAB3QV.js";
|
|
5
5
|
import {
|
|
6
|
-
loadConfig
|
|
7
|
-
|
|
6
|
+
loadConfig,
|
|
7
|
+
resolveEnv
|
|
8
|
+
} from "../chunk-5CKEMC6M.js";
|
|
8
9
|
import "../chunk-JDQLSL36.js";
|
|
9
|
-
import "../chunk-
|
|
10
|
+
import "../chunk-6EJDVZCD.js";
|
|
10
11
|
|
|
11
12
|
// src/entry/azure.ts
|
|
12
13
|
import { app } from "@azure/functions";
|
|
@@ -66,14 +67,15 @@ async function handleAzureRequest(app2, request) {
|
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
// src/entry/azure.ts
|
|
69
|
-
function register(
|
|
70
|
+
function register(options = {}) {
|
|
71
|
+
const { env, ...overrides } = options;
|
|
70
72
|
let appPromise;
|
|
71
73
|
app.http("madauth", {
|
|
72
74
|
route: "{*path}",
|
|
73
75
|
methods: ["GET", "POST", "OPTIONS"],
|
|
74
76
|
authLevel: "anonymous",
|
|
75
77
|
handler: async (request) => {
|
|
76
|
-
appPromise ??= loadConfig(
|
|
78
|
+
appPromise ??= resolveEnv(env).then((loaded) => loadConfig(loaded, overrides)).then(createApp);
|
|
77
79
|
appPromise.catch(() => appPromise = void 0);
|
|
78
80
|
return handleAzureRequest(await appPromise, request);
|
|
79
81
|
}
|