@podosoft/podokit 1.6.2 → 1.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +50 -4
- package/dist/templates/fullstack/tests/README.md +11 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +5 -5
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +61 -12
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +2 -2
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +17 -7
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.spec.ts +250 -26
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +76 -29
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +60 -15
- package/package.json +1 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { SQL } from "bun";
|
|
2
2
|
import { describe, expect, it } from "bun:test";
|
|
3
|
+
import { createSocket } from "node:dgram";
|
|
4
|
+
import { Resolver } from "node:dns/promises";
|
|
3
5
|
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
4
6
|
import { connect, createServer, type AddressInfo } from "node:net";
|
|
5
7
|
import { tmpdir } from "node:os";
|
|
@@ -62,6 +64,45 @@ function probedSql(): ProbedSql {
|
|
|
62
64
|
};
|
|
63
65
|
}
|
|
64
66
|
|
|
67
|
+
interface LocalDns {
|
|
68
|
+
resolver: Resolver;
|
|
69
|
+
close: () => void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// A resolver that asks a DNS server on a local UDP port instead of the system's,
|
|
73
|
+
// whose answers depend on the machine. The server answers every query with
|
|
74
|
+
// NXDOMAIN, or never answers when `answers` is false.
|
|
75
|
+
async function localDns(answers: boolean): Promise<LocalDns> {
|
|
76
|
+
const server = createSocket("udp4");
|
|
77
|
+
if (answers) {
|
|
78
|
+
server.on("message", (query, peer) => {
|
|
79
|
+
// The header and the question, flagged as a response with RCODE 3 and no records.
|
|
80
|
+
let end = 12;
|
|
81
|
+
while (end < query.length && query[end] !== 0) end += (query[end] ?? 0) + 1;
|
|
82
|
+
const response = Buffer.from(query.subarray(0, end + 5));
|
|
83
|
+
response[2] = 0x80 | ((query[2] ?? 0) & 0x01);
|
|
84
|
+
response[3] = 0x83;
|
|
85
|
+
response.writeUInt16BE(1, 4);
|
|
86
|
+
response.fill(0, 6, 12);
|
|
87
|
+
server.send(response, peer.port, peer.address);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
await new Promise<void>((resolve) => server.bind(0, "127.0.0.1", resolve));
|
|
91
|
+
// Bun waits at least a second for an answer; a single try keeps that the whole wait.
|
|
92
|
+
const resolver = new Resolver({ timeout: 200, tries: 1 });
|
|
93
|
+
resolver.setServers([`127.0.0.1:${server.address().port}`]);
|
|
94
|
+
return { resolver, close: () => server.close() };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function lookupError(answers: boolean): Promise<unknown> {
|
|
98
|
+
const dns = await localDns(answers);
|
|
99
|
+
try {
|
|
100
|
+
return await dns.resolver.resolve4("database.invalid").then(() => undefined, (reason: unknown) => reason);
|
|
101
|
+
} finally {
|
|
102
|
+
dns.close();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
65
106
|
describe("database provider", () => {
|
|
66
107
|
it("uses DATABASE_URL as the canonical connection setting", () => {
|
|
67
108
|
const provider = databaseProvider();
|
|
@@ -266,11 +307,16 @@ describe("isDatabaseUnavailable", () => {
|
|
|
266
307
|
});
|
|
267
308
|
|
|
268
309
|
it("recognizes a real lookup of a host that does not exist", async () => {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
const error: unknown = await fetch("http://database.invalid/").then(() => undefined, (reason: unknown) => reason);
|
|
310
|
+
const error = await lookupError(true);
|
|
311
|
+
expect((error as { code?: unknown }).code).toBe("ENOTFOUND");
|
|
272
312
|
expect(isDatabaseUnavailable(error)).toBe(true);
|
|
273
|
-
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("recognizes a real lookup that the resolver never answers", async () => {
|
|
316
|
+
const error = await lookupError(false);
|
|
317
|
+
expect((error as { code?: unknown }).code).toBe("ETIMEOUT");
|
|
318
|
+
expect(isDatabaseUnavailable(error)).toBe(true);
|
|
319
|
+
});
|
|
274
320
|
|
|
275
321
|
it("recognizes a real refused connection to localhost", async () => {
|
|
276
322
|
const server = createServer();
|
|
@@ -68,3 +68,14 @@ tests not run, and selecting an exclusive test runs every other test first. Use
|
|
|
68
68
|
|
|
69
69
|
Application-owned projects in `playwright.projects.cjs` get twins too. A project
|
|
70
70
|
that another project depends on is a setup project and keeps a single copy.
|
|
71
|
+
|
|
72
|
+
## Email tests
|
|
73
|
+
|
|
74
|
+
The email specs that the `admin-dashboard` module adds read messages from the
|
|
75
|
+
Mailpit at `MAILPIT_URL` (default `http://localhost:8025`). Before the first one,
|
|
76
|
+
each worker has the app send a password reset email to a throwaway account and
|
|
77
|
+
waits up to five seconds for it there. When it does not arrive, because the app
|
|
78
|
+
has no SMTP or sends to a catcher other than the one on that port, the email
|
|
79
|
+
tests skip with that cause. Set `MAILPIT_URL` to the Mailpit that receives the
|
|
80
|
+
app's mail to run them, and `E2E_REQUIRE_MAIL=true` to make a missing probe
|
|
81
|
+
message fail them instead of skipping.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { APIRequestContext } from "@playwright/test";
|
|
2
2
|
import { expect, test } from "../helpers/disposable-users";
|
|
3
3
|
import { ADMIN, USER } from "../helpers/accounts";
|
|
4
|
-
import {
|
|
4
|
+
import { skipUnlessMailReachesMailpit, waitForOtp } from "../helpers/mailpit";
|
|
5
5
|
import { totpCode } from "../helpers/totp";
|
|
6
6
|
|
|
7
7
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
@@ -171,7 +171,7 @@ test("a wrong password is not audited as a sign-in", async ({ playwright, dispos
|
|
|
171
171
|
});
|
|
172
172
|
|
|
173
173
|
test("an email code sign-in is audited once", async ({ playwright, disposableUsers }) => {
|
|
174
|
-
|
|
174
|
+
await skipUnlessMailReachesMailpit();
|
|
175
175
|
const admin = await signedIn(playwright, ADMIN);
|
|
176
176
|
test.skip((await admin.get("/api/audit-logs")).status() === 404, "audit-log module not installed");
|
|
177
177
|
const caps = await (await admin.get("/api/account/capabilities")).json();
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { expect, test } from "@playwright/test";
|
|
2
|
-
import {
|
|
2
|
+
import { skipUnlessMailReachesMailpit, waitForLink, waitForOtp } from "../helpers/mailpit";
|
|
3
3
|
|
|
4
4
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
5
5
|
const origin = { origin: base };
|
|
6
6
|
|
|
7
7
|
test("email one-time code signs a user in", async ({ playwright }) => {
|
|
8
|
-
|
|
8
|
+
await skipUnlessMailReachesMailpit();
|
|
9
9
|
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
10
10
|
const caps = await (await ctx.get("/api/account/capabilities")).json();
|
|
11
11
|
test.skip(!caps?.emailOtp, "email OTP not enabled");
|
|
@@ -49,7 +49,7 @@ test("organization invitation can be sent and accepted", async ({ playwright })
|
|
|
49
49
|
});
|
|
50
50
|
|
|
51
51
|
test("magic link signs a user in", async ({ playwright }) => {
|
|
52
|
-
|
|
52
|
+
await skipUnlessMailReachesMailpit();
|
|
53
53
|
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
54
54
|
const caps = await (await ctx.get("/api/account/capabilities")).json();
|
|
55
55
|
test.skip(!caps?.magicLink, "magic link not enabled");
|
|
@@ -74,7 +74,7 @@ function tokenFromRedirect(location: string): string | null {
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
test("password reset delivers an email and updates the password @smoke", async ({ playwright }) => {
|
|
77
|
-
|
|
77
|
+
await skipUnlessMailReachesMailpit();
|
|
78
78
|
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
79
79
|
const email = `reset-${Date.now()}@example.com`;
|
|
80
80
|
const signup = await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "Reset" } });
|
|
@@ -99,7 +99,7 @@ test("password reset delivers an email and updates the password @smoke", async (
|
|
|
99
99
|
});
|
|
100
100
|
|
|
101
101
|
test("email verification blocks sign-in until the address is confirmed", async ({ playwright }) => {
|
|
102
|
-
|
|
102
|
+
await skipUnlessMailReachesMailpit();
|
|
103
103
|
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
104
104
|
const email = `verify-${Date.now()}@example.com`;
|
|
105
105
|
const signup = await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "Verify", callbackURL: `${base}/admin` } });
|
|
@@ -1,33 +1,82 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { request, test } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
// Helpers for the local Mailpit catcher (docker-compose / CI service). Each test
|
|
3
4
|
// mails an address of its own and reads the newest message to it; the inbox is
|
|
4
5
|
// shared with tests on other workers, so no test empties it.
|
|
5
6
|
const MAILPIT = process.env.MAILPIT_URL ?? "http://localhost:8025";
|
|
7
|
+
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
8
|
+
const PROBE_WAIT_MS = 5000;
|
|
9
|
+
|
|
10
|
+
type MailpitList = { messages: Array<{ ID: string; To: Array<{ Address: string }> }> };
|
|
11
|
+
type MailpitMessage = { Text?: string; HTML?: string };
|
|
6
12
|
|
|
7
|
-
|
|
13
|
+
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
14
|
+
|
|
15
|
+
async function newestMessageTo(to: string): Promise<string | undefined> {
|
|
16
|
+
const list = (await (await fetch(`${MAILPIT}/api/v1/messages`)).json()) as MailpitList;
|
|
17
|
+
return list.messages?.find((m) => m.To?.some((t) => t.Address === to))?.ID;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// A Mailpit answering on MAILPIT_URL may belong to another stack while this app
|
|
21
|
+
// sends no mail or sends it elsewhere, so ask the app to mail an address of the
|
|
22
|
+
// probe's own and wait for that message. Returns why email tests cannot run, or
|
|
23
|
+
// null when the message arrives.
|
|
24
|
+
async function mailDeliveryProblem(): Promise<string | null> {
|
|
8
25
|
try {
|
|
9
|
-
|
|
26
|
+
if (!(await fetch(`${MAILPIT}/readyz`)).ok) return `Mailpit at ${MAILPIT} is not ready`;
|
|
10
27
|
} catch {
|
|
11
|
-
return
|
|
28
|
+
return `no Mailpit answers at ${MAILPIT}`;
|
|
12
29
|
}
|
|
30
|
+
const email = `mail-probe-${Date.now()}-${process.pid}@example.com`;
|
|
31
|
+
const app = await request.newContext({ baseURL: base, extraHTTPHeaders: { origin: base } });
|
|
32
|
+
try {
|
|
33
|
+
const signUp = await app.post("/api/auth/sign-up/email", {
|
|
34
|
+
data: { email, password: "Podokit3e-Str0ng!pw", name: "Mail probe" },
|
|
35
|
+
});
|
|
36
|
+
if (!signUp.ok()) return `the mail probe could not sign up (HTTP ${signUp.status()})`;
|
|
37
|
+
const reset = await app.post("/api/auth/request-password-reset", {
|
|
38
|
+
data: { email, redirectTo: `${base}/reset-password` },
|
|
39
|
+
});
|
|
40
|
+
if (!reset.ok()) return `the app could not send the probe email (HTTP ${reset.status()})`;
|
|
41
|
+
} finally {
|
|
42
|
+
await app.dispose();
|
|
43
|
+
}
|
|
44
|
+
const deadline = Date.now() + PROBE_WAIT_MS;
|
|
45
|
+
while (Date.now() < deadline) {
|
|
46
|
+
if (await newestMessageTo(email)) return null;
|
|
47
|
+
await sleep(250);
|
|
48
|
+
}
|
|
49
|
+
return `Mailpit at ${MAILPIT} does not receive this app's mail: a probe email to ${email} did not arrive within ${PROBE_WAIT_MS / 1000} s`;
|
|
13
50
|
}
|
|
14
51
|
|
|
15
|
-
|
|
16
|
-
|
|
52
|
+
let delivery: Promise<string | null> | undefined;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Skip the current test unless the app under test delivers its mail to the
|
|
56
|
+
* Mailpit these helpers read. The probe runs once per worker. With
|
|
57
|
+
* E2E_REQUIRE_MAIL=true, which a harness that wires the app's SMTP to that
|
|
58
|
+
* Mailpit sets, a failed probe fails the test instead.
|
|
59
|
+
*/
|
|
60
|
+
export async function skipUnlessMailReachesMailpit(): Promise<void> {
|
|
61
|
+
delivery ??= mailDeliveryProblem().catch((error: unknown) => `the mail probe failed: ${String(error)}`);
|
|
62
|
+
const problem = await delivery;
|
|
63
|
+
if (problem === null) return;
|
|
64
|
+
if (process.env.E2E_REQUIRE_MAIL === "true") throw new Error(`E2E_REQUIRE_MAIL is set, but ${problem}`);
|
|
65
|
+
test.skip(true, problem);
|
|
66
|
+
}
|
|
17
67
|
|
|
18
68
|
// Poll for the newest message addressed to `to` and return the first match of
|
|
19
69
|
// `pattern` in its text/HTML body.
|
|
20
70
|
async function waitForMatch(to: string, pattern: RegExp, label: string, timeoutMs: number): Promise<string> {
|
|
21
71
|
const deadline = Date.now() + timeoutMs;
|
|
22
72
|
while (Date.now() < deadline) {
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const full = (await (await fetch(`${MAILPIT}/api/v1/message/${msg.ID}`)).json()) as MailpitMessage;
|
|
73
|
+
const id = await newestMessageTo(to);
|
|
74
|
+
if (id) {
|
|
75
|
+
const full = (await (await fetch(`${MAILPIT}/api/v1/message/${id}`)).json()) as MailpitMessage;
|
|
27
76
|
const match = `${full.Text ?? ""} ${full.HTML ?? ""}`.match(pattern);
|
|
28
77
|
if (match) return match[0];
|
|
29
78
|
}
|
|
30
|
-
await
|
|
79
|
+
await sleep(300);
|
|
31
80
|
}
|
|
32
81
|
throw new Error(`no email with ${label} to ${to} within ${timeoutMs}ms`);
|
|
33
82
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { expect, test } from "../helpers/disposable-users";
|
|
2
2
|
import { ready } from "../helpers/hydration";
|
|
3
|
-
import {
|
|
3
|
+
import { skipUnlessMailReachesMailpit, waitForLink } from "../helpers/mailpit";
|
|
4
4
|
|
|
5
5
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
6
6
|
|
|
@@ -17,7 +17,7 @@ test("verification badges stay hidden when the feature is off", async ({ page })
|
|
|
17
17
|
|
|
18
18
|
// Admin can trigger a password-reset email for a user (not verification-gated).
|
|
19
19
|
test("admin sends a password reset email to a user", async ({ page, disposableUsers }) => {
|
|
20
|
-
|
|
20
|
+
await skipUnlessMailReachesMailpit();
|
|
21
21
|
await ready(page, "/admin/users");
|
|
22
22
|
const email = `admin-reset-${Date.now()}@example.com`;
|
|
23
23
|
await disposableUsers.create({ email, name: "AR" });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { expect, test } from "@playwright/test";
|
|
2
2
|
import { anonState } from "../helpers/accounts";
|
|
3
3
|
import { ready } from "../helpers/hydration";
|
|
4
|
-
import {
|
|
4
|
+
import { skipUnlessMailReachesMailpit, waitForLink } from "../helpers/mailpit";
|
|
5
5
|
|
|
6
6
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
7
7
|
const origin = { origin: base };
|
|
@@ -10,7 +10,7 @@ const origin = { origin: base };
|
|
|
10
10
|
test.use({ storageState: anonState });
|
|
11
11
|
|
|
12
12
|
test("forgot password: request, open the emailed link, set a new password, sign in", async ({ page, playwright }) => {
|
|
13
|
-
|
|
13
|
+
await skipUnlessMailReachesMailpit();
|
|
14
14
|
const email = `ui-reset-${Date.now()}@example.com`;
|
|
15
15
|
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
16
16
|
const signup = await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "UIReset" } });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { expect, test } from "@playwright/test";
|
|
2
2
|
import { anonState } from "../helpers/accounts";
|
|
3
3
|
import { ready } from "../helpers/hydration";
|
|
4
|
-
import {
|
|
4
|
+
import { skipUnlessMailReachesMailpit, waitForOtp } from "../helpers/mailpit";
|
|
5
5
|
|
|
6
6
|
test.use({ storageState: anonState });
|
|
7
7
|
|
|
@@ -24,7 +24,7 @@ test("login: an emailed one-time code signs a user in", async ({ page, playwrigh
|
|
|
24
24
|
const api = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
25
25
|
const caps = await (await api.get("/api/account/capabilities")).json();
|
|
26
26
|
test.skip(!caps?.emailOtp, "email otp disabled");
|
|
27
|
-
|
|
27
|
+
await skipUnlessMailReachesMailpit();
|
|
28
28
|
|
|
29
29
|
const email = `ui-otp-${Date.now()}@example.com`;
|
|
30
30
|
await api.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "OTP" } });
|
|
@@ -57,8 +57,8 @@ test("settings: open and save the SMTP dialog", { tag: "@exclusive" }, async ({
|
|
|
57
57
|
await page.getByRole("button", { name: "SMTP settings" }).click();
|
|
58
58
|
const dialog = page.getByRole("dialog");
|
|
59
59
|
try {
|
|
60
|
-
//
|
|
61
|
-
//
|
|
60
|
+
// SMTP stays switched off, so saving these values can't re-route mail for the
|
|
61
|
+
// other email specs: the mailer keeps the environment's transport.
|
|
62
62
|
await dialog.locator("#smtp-host").fill("localhost");
|
|
63
63
|
await dialog.locator("#smtp-port").fill("1125");
|
|
64
64
|
await dialog.getByRole("button", { name: "Save", exact: true }).click();
|
|
@@ -16,10 +16,17 @@ The plugin records an action only when the request succeeded, so a wrong
|
|
|
16
16
|
password or a forbidden admin call leaves no entry. Every sign-in method records
|
|
17
17
|
`auth.login` with the method in `metadata.method` (`password`, `email-otp`,
|
|
18
18
|
`magic-link`, `passkey`, `two-factor-totp`, ...) and, for social sign-in, the
|
|
19
|
-
provider in `metadata.provider`. A
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
provider in `metadata.provider`. A sign-in through an endpoint of a plugin the
|
|
20
|
+
app adds, such as `@better-auth/electron`'s `/electron/token`, is recorded too,
|
|
21
|
+
with the endpoint's path as the method: any request that ends with a new session
|
|
22
|
+
it did not come with is a sign-in, except the endpoints in `NOT_SIGN_INS`
|
|
23
|
+
(sign-up, impersonation, switching between a browser's sessions, and requests
|
|
24
|
+
that replace the signed-in user's own session). Session reads and updates that
|
|
25
|
+
refresh the request's own session, including bearer and API key requests, are
|
|
26
|
+
not. A two-factor sign-in is recorded when the second factor completes it, not
|
|
27
|
+
at the password step. `auth.ts` pushes the plugin after every other plugin:
|
|
28
|
+
Better Auth runs after hooks in plugin order, and two-factor changes the outcome
|
|
29
|
+
of a password sign-in in its own after hook.
|
|
23
30
|
|
|
24
31
|
## Audit your own Elysia routes
|
|
25
32
|
|
|
@@ -96,9 +103,12 @@ survives renames/deletes), `targetType/targetId/targetLabel`, `ip`, `metadata`
|
|
|
96
103
|
## Customize
|
|
97
104
|
|
|
98
105
|
- **Auth actions:** `ACTIONS` in `audit-hook.ts` maps Better Auth endpoints to
|
|
99
|
-
action codes,
|
|
100
|
-
|
|
101
|
-
|
|
106
|
+
action codes, `SIGN_IN_METHODS` names the method of Better Auth's sign-in
|
|
107
|
+
endpoints, and `NOT_SIGN_INS` lists the endpoints that start or swap a session
|
|
108
|
+
without a sign-in. The `audit-hook.spec.ts` test fails when Better Auth or a
|
|
109
|
+
plugin PodoKit configures gains an endpoint that it has not classified; the
|
|
110
|
+
endpoints of plugins the app adds are recorded as sign-ins when they start a
|
|
111
|
+
session, without a change to these lists.
|
|
102
112
|
- **Your routes:** call `AuditService.recordRequest()` after successful operations;
|
|
103
113
|
pass `{ defer: true }` only for high-volume, non-security events.
|
|
104
114
|
- **Schema:** add columns with a migration, and keep the
|
|
@@ -1,25 +1,82 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
2
2
|
import { createHash, createHmac } from "node:crypto";
|
|
3
|
+
import { apiKey } from "@better-auth/api-key";
|
|
3
4
|
import { oauthProvider } from "@better-auth/oauth-provider";
|
|
4
5
|
import { envAuthConfig } from "@podosoft/podokit-auth";
|
|
5
|
-
import { betterAuth } from "better-auth";
|
|
6
|
+
import { betterAuth, type BetterAuthOptions, type BetterAuthPlugin } from "better-auth";
|
|
6
7
|
import { memoryAdapter } from "better-auth/adapters/memory";
|
|
8
|
+
import { APIError, createAuthEndpoint } from "better-auth/api";
|
|
9
|
+
import { setSessionCookie } from "better-auth/cookies";
|
|
7
10
|
import { getAuthTables } from "better-auth/db";
|
|
8
|
-
import { admin, emailOTP, jwt, magicLink, twoFactor } from "better-auth/plugins";
|
|
11
|
+
import { admin, bearer, emailOTP, jwt, magicLink, multiSession, twoFactor } from "better-auth/plugins";
|
|
9
12
|
import { buildAuth } from "../auth/auth";
|
|
10
13
|
import { setAuditRecorder, type AuditEntry } from "./audit-events";
|
|
11
|
-
import { auditPlugin, SIGN_IN_METHODS } from "./audit-hook";
|
|
14
|
+
import { auditPlugin, NOT_SIGN_INS, SIGN_IN_METHODS } from "./audit-hook";
|
|
12
15
|
|
|
13
16
|
const BASE_URL = "http://localhost:5002";
|
|
14
17
|
const PASSWORD = "Podokit3e-Str0ng!pw";
|
|
15
18
|
|
|
16
|
-
//
|
|
19
|
+
// The plugins PodoKit's auth.ts and modules configure. Endpoints of other
|
|
20
|
+
// plugins, such as one an application adds, are not classified below: the
|
|
21
|
+
// audit plugin records their sign-ins by default.
|
|
22
|
+
const PODOKIT_PLUGINS = new Set([
|
|
23
|
+
"podokit-feature-gate",
|
|
24
|
+
"podokit-revoke-every-other-session",
|
|
25
|
+
"two-factor",
|
|
26
|
+
"magic-link",
|
|
27
|
+
"email-otp",
|
|
28
|
+
"username",
|
|
29
|
+
"multi-session",
|
|
30
|
+
"phone-number",
|
|
31
|
+
"api-key",
|
|
32
|
+
"passkey",
|
|
33
|
+
"organization",
|
|
34
|
+
"bearer",
|
|
35
|
+
"jwt",
|
|
36
|
+
"oauth-provider",
|
|
37
|
+
"open-api",
|
|
38
|
+
"have-i-been-pwned",
|
|
39
|
+
"admin",
|
|
40
|
+
"podokit-audit",
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
// Endpoints of Better Auth and of those plugins that never end with a session
|
|
44
|
+
// other than the one the request came with, which some of them set again. A
|
|
45
|
+
// Better Auth upgrade that adds an endpoint fails "classifies every endpoint"
|
|
46
|
+
// until the endpoint is added here, to SIGN_IN_METHODS, or to NOT_SIGN_INS.
|
|
17
47
|
const STARTS_NO_SESSION = new Set([
|
|
18
|
-
"/
|
|
19
|
-
"/
|
|
20
|
-
"/
|
|
21
|
-
"/
|
|
22
|
-
"/
|
|
48
|
+
"/.well-known/oauth-authorization-server", "/.well-known/openid-configuration", "/account-info",
|
|
49
|
+
"/admin/ban-user", "/admin/create-user", "/admin/get-user", "/admin/has-permission",
|
|
50
|
+
"/admin/list-user-sessions", "/admin/list-users", "/admin/oauth2/create-client", "/admin/oauth2/resources",
|
|
51
|
+
"/admin/oauth2/resources/:identifier", "/admin/oauth2/resources/:identifier/clients/:client_id",
|
|
52
|
+
"/admin/oauth2/update-client", "/admin/remove-user", "/admin/revoke-user-session", "/admin/revoke-user-sessions",
|
|
53
|
+
"/admin/set-role", "/admin/set-user-password", "/admin/unban-user", "/admin/update-user",
|
|
54
|
+
"/api-key/create", "/api-key/delete", "/api-key/get", "/api-key/list", "/api-key/update",
|
|
55
|
+
"/change-email", "/delete-user", "/delete-user/callback",
|
|
56
|
+
"/email-otp/change-email", "/email-otp/check-verification-otp", "/email-otp/request-email-change",
|
|
57
|
+
"/email-otp/request-password-reset", "/email-otp/reset-password", "/email-otp/send-verification-otp",
|
|
58
|
+
"/error", "/forget-password/email-otp", "/get-access-token", "/get-session", "/is-username-available", "/jwks",
|
|
59
|
+
"/link-social", "/list-accounts", "/list-sessions", "/multi-session/list-device-sessions",
|
|
60
|
+
"/oauth2/authorize", "/oauth2/client/rotate-keys", "/oauth2/client/rotate-secret", "/oauth2/consent",
|
|
61
|
+
"/oauth2/continue", "/oauth2/create-client", "/oauth2/delete-client", "/oauth2/delete-consent",
|
|
62
|
+
"/oauth2/end-session", "/oauth2/end-session/confirm", "/oauth2/get-client", "/oauth2/get-clients",
|
|
63
|
+
"/oauth2/get-consent", "/oauth2/get-consents", "/oauth2/introspect", "/oauth2/public-client",
|
|
64
|
+
"/oauth2/public-client-prelogin", "/oauth2/register", "/oauth2/revoke", "/oauth2/token", "/oauth2/update-client",
|
|
65
|
+
"/oauth2/update-consent", "/oauth2/userinfo", "/ok", "/open-api/generate-schema",
|
|
66
|
+
"/organization/accept-invitation", "/organization/cancel-invitation", "/organization/check-slug",
|
|
67
|
+
"/organization/create", "/organization/delete", "/organization/get-active-member",
|
|
68
|
+
"/organization/get-active-member-role", "/organization/get-full-organization", "/organization/get-invitation",
|
|
69
|
+
"/organization/get-organization", "/organization/has-permission", "/organization/invite-member",
|
|
70
|
+
"/organization/leave", "/organization/list", "/organization/list-invitations", "/organization/list-members",
|
|
71
|
+
"/organization/list-user-invitations", "/organization/reject-invitation", "/organization/remove-member",
|
|
72
|
+
"/organization/set-active", "/organization/update", "/organization/update-member-role",
|
|
73
|
+
"/passkey/delete-passkey", "/passkey/generate-authenticate-options", "/passkey/generate-register-options",
|
|
74
|
+
"/passkey/list-user-passkeys", "/passkey/update-passkey", "/passkey/verify-registration",
|
|
75
|
+
"/phone-number/request-password-reset", "/phone-number/reset-password", "/phone-number/send-otp",
|
|
76
|
+
"/reference", "/refresh-token", "/request-password-reset", "/reset-password", "/reset-password/:token",
|
|
77
|
+
"/revoke-other-sessions", "/revoke-session", "/revoke-sessions", "/send-verification-email",
|
|
78
|
+
"/sign-in/magic-link", "/sign-out", "/token", "/two-factor/generate-backup-codes", "/two-factor/get-totp-uri",
|
|
79
|
+
"/two-factor/send-otp", "/unlink-account", "/update-session", "/update-user", "/verify-password",
|
|
23
80
|
]);
|
|
24
81
|
|
|
25
82
|
interface Mailbox {
|
|
@@ -52,15 +109,42 @@ afterEach(() => restoreRecorder());
|
|
|
52
109
|
const logins = (): AuditEntry[] => entries.filter((entry) => entry.action === "auth.login");
|
|
53
110
|
const actions = (action: string): AuditEntry[] => entries.filter((entry) => entry.action === action);
|
|
54
111
|
|
|
55
|
-
//
|
|
56
|
-
// the
|
|
57
|
-
|
|
112
|
+
// Like @better-auth/electron's /electron/token, which a desktop app calls with
|
|
113
|
+
// the code its browser sign-in handed over: an endpoint of an application's own
|
|
114
|
+
// plugin that starts a session for a user it looks up.
|
|
115
|
+
function desktopSignIn(): BetterAuthPlugin {
|
|
116
|
+
return {
|
|
117
|
+
id: "desktop-sign-in",
|
|
118
|
+
endpoints: {
|
|
119
|
+
desktopToken: createAuthEndpoint("/desktop/token", { method: "POST" }, async (ctx) => {
|
|
120
|
+
const { code } = (ctx.body ?? {}) as { code?: string };
|
|
121
|
+
const found = code ? await ctx.context.internalAdapter.findUserByEmail(code) : null;
|
|
122
|
+
if (!found) throw new APIError("NOT_FOUND", { message: "Invalid or expired token." });
|
|
123
|
+
const session = await ctx.context.internalAdapter.createSession(found.user.id);
|
|
124
|
+
await setSessionCookie(ctx, { session, user: found.user });
|
|
125
|
+
return ctx.json({ token: session.token });
|
|
126
|
+
}),
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
interface StartOptions {
|
|
132
|
+
enabled?: () => Promise<boolean>;
|
|
133
|
+
session?: BetterAuthOptions["session"];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The sign-in methods PodoKit's auth.ts enables and plugins that set or swap
|
|
137
|
+
// sessions without a sign-in, on an in-memory database, with the audit plugin
|
|
138
|
+
// pushed last as the audit-log module does.
|
|
139
|
+
function startAuth({ enabled = async () => true, session }: StartOptions = {}) {
|
|
58
140
|
const mail: Mailbox = { magicLink: "", otp: "", verification: "" };
|
|
59
141
|
const db: Record<string, Record<string, unknown>[]> = {};
|
|
60
142
|
const options = {
|
|
61
143
|
baseURL: BASE_URL,
|
|
62
144
|
secret: "audit-hook-test-secret-0123456789abcdef",
|
|
63
145
|
database: memoryAdapter(db),
|
|
146
|
+
...(session ? { session } : {}),
|
|
147
|
+
user: { changeEmail: { enabled: true, updateEmailWithoutVerification: true } },
|
|
64
148
|
emailAndPassword: { enabled: true },
|
|
65
149
|
emailVerification: {
|
|
66
150
|
autoSignInAfterVerification: true,
|
|
@@ -103,28 +187,37 @@ function startAuth(enabled: () => Promise<boolean> = async () => true) {
|
|
|
103
187
|
mail.otp = otp;
|
|
104
188
|
},
|
|
105
189
|
}),
|
|
190
|
+
multiSession(),
|
|
191
|
+
apiKey({ enableSessionForAPIKeys: true, rateLimit: { enabled: false } }),
|
|
192
|
+
bearer({ requireSignature: true }),
|
|
106
193
|
jwt({ disableSettingJwtHeader: true }),
|
|
107
194
|
oauthProvider({ loginPage: "/login", consentPage: "/oauth2/consent" }),
|
|
108
195
|
admin(),
|
|
196
|
+
desktopSignIn(),
|
|
109
197
|
auditPlugin({ enabled }),
|
|
110
198
|
],
|
|
111
199
|
};
|
|
112
200
|
for (const table of Object.values(getAuthTables(options))) db[table.modelName] = [];
|
|
113
|
-
return { auth: betterAuth(options), mail };
|
|
201
|
+
return { auth: betterAuth(options), mail, db };
|
|
114
202
|
}
|
|
115
203
|
|
|
116
|
-
// A cookie jar in front of the Better Auth router, like a browser's
|
|
204
|
+
// A cookie jar in front of the Better Auth router, like a browser's, or a client
|
|
205
|
+
// that sends the same headers with every request.
|
|
117
206
|
class Browser {
|
|
118
207
|
private readonly auth: AuthHandler;
|
|
208
|
+
private readonly sent: Record<string, string>;
|
|
119
209
|
private readonly cookies = new Map<string, string>();
|
|
120
210
|
|
|
121
|
-
constructor(auth: AuthHandler) {
|
|
211
|
+
constructor(auth: AuthHandler, sent: Record<string, string> = {}) {
|
|
122
212
|
this.auth = auth;
|
|
213
|
+
this.sent = sent;
|
|
123
214
|
}
|
|
124
215
|
|
|
125
216
|
/** Request headers that carry this browser's cookies, for a server-side auth.api call. */
|
|
126
217
|
headers(): Headers {
|
|
127
|
-
|
|
218
|
+
const headers = new Headers(this.sent);
|
|
219
|
+
if (this.cookies.size > 0) headers.set("cookie", [...this.cookies].map(([name, value]) => `${name}=${value}`).join("; "));
|
|
220
|
+
return headers;
|
|
128
221
|
}
|
|
129
222
|
|
|
130
223
|
get(path: string): Promise<Response> {
|
|
@@ -160,6 +253,10 @@ async function signUp(auth: AuthHandler, email: string): Promise<Browser> {
|
|
|
160
253
|
return browser;
|
|
161
254
|
}
|
|
162
255
|
|
|
256
|
+
async function userId(browser: Browser): Promise<string> {
|
|
257
|
+
return ((await (await browser.get("/get-session")).json()) as { user: { id: string } }).user.id;
|
|
258
|
+
}
|
|
259
|
+
|
|
163
260
|
// The current code for an otpauth:// URI, as an authenticator app computes it.
|
|
164
261
|
function totp(uri: string): string {
|
|
165
262
|
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
@@ -179,6 +276,8 @@ describe("audit plugin", () => {
|
|
|
179
276
|
test("records a password sign-in once and a wrong password not at all", async () => {
|
|
180
277
|
const { auth } = startAuth();
|
|
181
278
|
await signUp(auth, "password@example.com");
|
|
279
|
+
// Signing up starts a session too, recorded as the sign-up only.
|
|
280
|
+
expect(entries.map((entry) => entry.action)).toEqual(["auth.signup"]);
|
|
182
281
|
|
|
183
282
|
const wrong = await new Browser(auth).post("/sign-in/email", { email: "password@example.com", password: "Wrong-password-1" });
|
|
184
283
|
expect(wrong.status).toBe(401);
|
|
@@ -329,6 +428,28 @@ describe("audit plugin", () => {
|
|
|
329
428
|
expect(signedIn.status).toBe(200);
|
|
330
429
|
expect(await signedIn.json()).toMatchObject({ redirect: true, url: expect.stringContaining("code=") });
|
|
331
430
|
expect(logins()).toEqual([expect.objectContaining({ actorEmail: "oauth@example.com", metadata: { method: "password" } })]);
|
|
431
|
+
|
|
432
|
+
// Authorizing again with that session and exchanging the code sign nobody in.
|
|
433
|
+
const again = await browser.get(`/oauth2/authorize?${query}`);
|
|
434
|
+
expect(again.status).toBe(302);
|
|
435
|
+
const code = new URL(again.headers.get("location") ?? "").searchParams.get("code") ?? "";
|
|
436
|
+
expect(code).not.toBe("");
|
|
437
|
+
const exchange = new URLSearchParams({
|
|
438
|
+
grant_type: "authorization_code",
|
|
439
|
+
code,
|
|
440
|
+
redirect_uri: "https://client.example.com/callback",
|
|
441
|
+
code_verifier: verifier,
|
|
442
|
+
});
|
|
443
|
+
const credentials = Buffer.from(`${client.client_id}:${client.client_secret ?? ""}`).toString("base64");
|
|
444
|
+
const token = await auth.handler(
|
|
445
|
+
new Request(`${BASE_URL}/api/auth/oauth2/token`, {
|
|
446
|
+
method: "POST",
|
|
447
|
+
headers: { "content-type": "application/x-www-form-urlencoded", authorization: `Basic ${credentials}` },
|
|
448
|
+
body: exchange,
|
|
449
|
+
}),
|
|
450
|
+
);
|
|
451
|
+
expect(token.status).toBe(200);
|
|
452
|
+
expect(logins()).toHaveLength(1);
|
|
332
453
|
});
|
|
333
454
|
|
|
334
455
|
test("does not record failed account and admin requests", async () => {
|
|
@@ -354,8 +475,108 @@ describe("audit plugin", () => {
|
|
|
354
475
|
expect(logins()).toEqual([]);
|
|
355
476
|
});
|
|
356
477
|
|
|
478
|
+
test("records a sign-in through an endpoint of an application's plugin with its path", async () => {
|
|
479
|
+
const { auth } = startAuth();
|
|
480
|
+
await signUp(auth, "desktop@example.com");
|
|
481
|
+
entries = [];
|
|
482
|
+
|
|
483
|
+
const desktop = new Browser(auth);
|
|
484
|
+
expect((await desktop.post("/desktop/token", { code: "nobody@example.com" })).status).toBe(404);
|
|
485
|
+
expect(logins()).toEqual([]);
|
|
486
|
+
expect((await desktop.post("/desktop/token", { code: "desktop@example.com" })).status).toBe(200);
|
|
487
|
+
expect(logins()).toEqual([
|
|
488
|
+
expect.objectContaining({ actorEmail: "desktop@example.com", metadata: { method: "/desktop/token" } }),
|
|
489
|
+
]);
|
|
490
|
+
// The session it started is then the desktop app's own.
|
|
491
|
+
expect((await desktop.get("/get-session")).status).toBe(200);
|
|
492
|
+
expect((await desktop.post("/update-user", { name: "Desktop" })).status).toBe(200);
|
|
493
|
+
expect(logins()).toHaveLength(1);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("records no sign-in for reads and updates that refresh the session", async () => {
|
|
497
|
+
// Every session read extends the session and the cookie cache expires at once.
|
|
498
|
+
const { auth } = startAuth({ session: { updateAge: 0, cookieCache: { enabled: true, maxAge: 1 } } });
|
|
499
|
+
const owner = await signUp(auth, "refresh@example.com");
|
|
500
|
+
entries = [];
|
|
501
|
+
expect((await owner.get("/get-session")).status).toBe(200);
|
|
502
|
+
expect((await owner.get("/get-session?disableCookieCache=true")).status).toBe(200);
|
|
503
|
+
expect((await owner.post("/update-user", { name: "Refreshed" })).status).toBe(200);
|
|
504
|
+
expect((await owner.post("/change-email", { newEmail: "refreshed@example.com" })).status).toBe(200);
|
|
505
|
+
expect((await owner.post("/update-session", {})).status).toBeLessThan(500);
|
|
506
|
+
expect((await owner.get("/token")).status).toBe(200);
|
|
507
|
+
expect((await owner.get("/list-sessions")).status).toBe(200);
|
|
508
|
+
expect(actions("account.update")).toHaveLength(1);
|
|
509
|
+
expect(actions("auth.email_change")).toHaveLength(1);
|
|
510
|
+
expect(logins()).toEqual([]);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test("records no sign-in for bearer and API key requests", async () => {
|
|
514
|
+
const { auth } = startAuth({ session: { updateAge: 0 } });
|
|
515
|
+
const owner = await signUp(auth, "client@example.com");
|
|
516
|
+
const signIn = await new Browser(auth).post("/sign-in/email", { email: "client@example.com", password: PASSWORD });
|
|
517
|
+
const token = signIn.headers.get("set-auth-token") ?? "";
|
|
518
|
+
expect(token).not.toBe("");
|
|
519
|
+
const created = await owner.post("/api-key/create", {});
|
|
520
|
+
expect(created.status).toBe(200);
|
|
521
|
+
const { key } = (await created.json()) as { key: string };
|
|
522
|
+
entries = [];
|
|
523
|
+
|
|
524
|
+
for (const client of [new Browser(auth, { authorization: `Bearer ${token}` }), new Browser(auth, { "x-api-key": key })]) {
|
|
525
|
+
for (let i = 0; i < 3; i++) expect((await client.get("/get-session")).status).toBe(200);
|
|
526
|
+
expect((await client.post("/update-user", { name: "Client" })).status).toBe(200);
|
|
527
|
+
expect((await client.get("/list-sessions")).status).toBe(200);
|
|
528
|
+
}
|
|
529
|
+
expect(actions("account.update")).toHaveLength(2);
|
|
530
|
+
expect(logins()).toEqual([]);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test("records no sign-in when an administrator impersonates a user or a browser switches accounts", async () => {
|
|
534
|
+
const { auth, db } = startAuth();
|
|
535
|
+
const operator = await signUp(auth, "operator@example.com");
|
|
536
|
+
const operatorId = await userId(operator);
|
|
537
|
+
const row = db.user?.find((user) => user.id === operatorId);
|
|
538
|
+
if (row) row.role = "admin";
|
|
539
|
+
const target = await signUp(auth, "impersonated@example.com");
|
|
540
|
+
const targetId = await userId(target);
|
|
541
|
+
entries = [];
|
|
542
|
+
|
|
543
|
+
expect((await operator.post("/admin/impersonate-user", { userId: targetId })).status).toBe(200);
|
|
544
|
+
expect(await userId(operator)).toBe(targetId);
|
|
545
|
+
expect((await operator.post("/admin/stop-impersonating", {})).status).toBe(200);
|
|
546
|
+
expect(await userId(operator)).toBe(operatorId);
|
|
547
|
+
expect(actions("user.impersonate")).toHaveLength(1);
|
|
548
|
+
expect(actions("user.stop_impersonate")).toHaveLength(1);
|
|
549
|
+
expect(logins()).toEqual([]);
|
|
550
|
+
|
|
551
|
+
// Signing in to a second account in the same browser is a sign-in; switching
|
|
552
|
+
// between the two and signing one out are not.
|
|
553
|
+
const second = await operator.post("/sign-in/email", { email: "impersonated@example.com", password: PASSWORD });
|
|
554
|
+
expect(second.status).toBe(200);
|
|
555
|
+
expect(logins()).toEqual([expect.objectContaining({ actorEmail: "impersonated@example.com" })]);
|
|
556
|
+
const sessions = (await (await operator.get("/multi-session/list-device-sessions")).json()) as Array<{
|
|
557
|
+
session: { token: string };
|
|
558
|
+
user: { id: string };
|
|
559
|
+
}>;
|
|
560
|
+
const tokenOf = (id: string): string => sessions.find((entry) => entry.user.id === id)?.session.token ?? "";
|
|
561
|
+
expect((await operator.post("/multi-session/set-active", { sessionToken: tokenOf(operatorId) })).status).toBe(200);
|
|
562
|
+
expect(await userId(operator)).toBe(operatorId);
|
|
563
|
+
expect((await operator.post("/multi-session/revoke", { sessionToken: tokenOf(operatorId) })).status).toBe(200);
|
|
564
|
+
expect(await userId(operator)).toBe(targetId);
|
|
565
|
+
expect((await operator.post("/revoke-other-sessions", {})).status).toBe(200);
|
|
566
|
+
expect((await operator.post("/revoke-sessions", {})).status).toBe(200);
|
|
567
|
+
expect(logins()).toHaveLength(1);
|
|
568
|
+
|
|
569
|
+
const again = await new Browser(auth).post("/sign-in/email", { email: "operator@example.com", password: PASSWORD });
|
|
570
|
+
expect(again.status).toBe(200);
|
|
571
|
+
const signedOut = new Browser(auth);
|
|
572
|
+
await signedOut.post("/sign-in/email", { email: "operator@example.com", password: PASSWORD });
|
|
573
|
+
expect((await signedOut.post("/sign-out", {})).status).toBe(200);
|
|
574
|
+
expect(actions("auth.logout")).toHaveLength(1);
|
|
575
|
+
expect(logins()).toHaveLength(3);
|
|
576
|
+
});
|
|
577
|
+
|
|
357
578
|
test("records nothing while auditing is off", async () => {
|
|
358
|
-
const { auth } = startAuth(async () => false);
|
|
579
|
+
const { auth } = startAuth({ enabled: async () => false });
|
|
359
580
|
await signUp(auth, "off@example.com");
|
|
360
581
|
expect((await new Browser(auth).post("/sign-in/email", { email: "off@example.com", password: PASSWORD })).status).toBe(200);
|
|
361
582
|
expect(entries).toEqual([]);
|
|
@@ -370,16 +591,19 @@ describe("the app's Better Auth", () => {
|
|
|
370
591
|
expect(instance.options.hooks?.after).toBeUndefined();
|
|
371
592
|
});
|
|
372
593
|
|
|
373
|
-
test("classifies every endpoint
|
|
594
|
+
test("classifies every endpoint of Better Auth and PodoKit's plugins", () => {
|
|
374
595
|
const instance = buildAuth(envAuthConfig());
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
!STARTS_NO_SESSION.has(path),
|
|
596
|
+
type Endpoints = Record<string, { path?: string }> | undefined;
|
|
597
|
+
const pathsOf = (endpoints: Endpoints): string[] =>
|
|
598
|
+
Object.values(endpoints ?? {}).flatMap((endpoint) => (endpoint.path ? [endpoint.path] : []));
|
|
599
|
+
const plugins = instance.options.plugins as Array<{ id: string; endpoints?: Endpoints }>;
|
|
600
|
+
const applications = new Set(
|
|
601
|
+
plugins.filter((plugin) => !PODOKIT_PLUGINS.has(plugin.id)).flatMap((plugin) => pathsOf(plugin.endpoints)),
|
|
382
602
|
);
|
|
383
|
-
|
|
603
|
+
const paths = new Set(pathsOf(instance.api as Endpoints).filter((path) => !applications.has(path)));
|
|
604
|
+
expect(Object.keys(SIGN_IN_METHODS).filter((path) => !paths.has(path))).toEqual([]);
|
|
605
|
+
const classified = (path: string): boolean =>
|
|
606
|
+
SIGN_IN_METHODS[path] !== undefined || NOT_SIGN_INS.has(path) || STARTS_NO_SESSION.has(path);
|
|
607
|
+
expect([...paths].filter((path) => !classified(path))).toEqual([]);
|
|
384
608
|
});
|
|
385
609
|
});
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import type { BetterAuthPlugin } from "better-auth";
|
|
2
2
|
import { createAuthMiddleware, isAPIError } from "better-auth/api";
|
|
3
|
+
import { parseSetCookieHeader } from "better-auth/cookies";
|
|
3
4
|
import { clientAddressOf } from "../core/client-address";
|
|
4
5
|
import { recordAudit } from "./audit-events";
|
|
5
6
|
import { auditEnabled } from "./audit-enabled";
|
|
6
7
|
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
8
|
+
// The method that auth.login records for Better Auth's sign-in endpoints. A
|
|
9
|
+
// sign-in through any other endpoint, such as one of a plugin the application
|
|
10
|
+
// adds, is recorded too, with the endpoint's path as its method. ctx.path is
|
|
11
|
+
// the route pattern an endpoint was registered with, not the request URL, so
|
|
12
|
+
// every social provider's callback arrives as "/callback/:id".
|
|
10
13
|
export const SIGN_IN_METHODS: Readonly<Record<string, string>> = {
|
|
11
14
|
"/sign-in/email": "password",
|
|
12
15
|
"/sign-in/username": "username",
|
|
@@ -24,6 +27,21 @@ export const SIGN_IN_METHODS: Readonly<Record<string, string>> = {
|
|
|
24
27
|
"/email-otp/verify-email": "email-otp-verification",
|
|
25
28
|
};
|
|
26
29
|
|
|
30
|
+
// Endpoints that can end with a session the request did not come with without
|
|
31
|
+
// signing anyone in: a sign-up, recorded as auth.signup; an administrator
|
|
32
|
+
// starting or ending an impersonation; a switch to another session the browser
|
|
33
|
+
// already holds; and requests that replace the signed-in user's own session.
|
|
34
|
+
export const NOT_SIGN_INS: ReadonlySet<string> = new Set([
|
|
35
|
+
"/sign-up/email",
|
|
36
|
+
"/admin/impersonate-user",
|
|
37
|
+
"/admin/stop-impersonating",
|
|
38
|
+
"/multi-session/set-active",
|
|
39
|
+
"/multi-session/revoke",
|
|
40
|
+
"/two-factor/enable",
|
|
41
|
+
"/two-factor/disable",
|
|
42
|
+
"/change-password",
|
|
43
|
+
]);
|
|
44
|
+
|
|
27
45
|
// Other auth and admin endpoints, with the action each records once it succeeds.
|
|
28
46
|
const ACTIONS: Readonly<Record<string, string>> = {
|
|
29
47
|
"/sign-up/email": "auth.signup",
|
|
@@ -58,6 +76,18 @@ interface SessionRecord {
|
|
|
58
76
|
user: { id: string; name?: string | null; email?: string | null };
|
|
59
77
|
}
|
|
60
78
|
|
|
79
|
+
interface SignInContext {
|
|
80
|
+
path: string;
|
|
81
|
+
context: {
|
|
82
|
+
session: SessionRecord | null;
|
|
83
|
+
responseHeaders?: Headers;
|
|
84
|
+
secret: string;
|
|
85
|
+
authCookies: { sessionToken: { name: string } };
|
|
86
|
+
internalAdapter: { findSession: (token: string) => Promise<unknown> };
|
|
87
|
+
};
|
|
88
|
+
getSignedCookie: (name: string, secret: string) => Promise<string | null | false>;
|
|
89
|
+
}
|
|
90
|
+
|
|
61
91
|
// After hooks run for failed requests too, with the APIError the endpoint threw
|
|
62
92
|
// in ctx.context.returned. Redirects are APIErrors as well: a 3xx status, and an
|
|
63
93
|
// `error` query parameter on the location when the redirect reports a failure.
|
|
@@ -73,19 +103,33 @@ function failed(returned: unknown): boolean {
|
|
|
73
103
|
}
|
|
74
104
|
}
|
|
75
105
|
|
|
76
|
-
// A sign-in ends with a session the request did not come with
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
106
|
+
// A sign-in ends with a session the request did not come with, on any endpoint
|
|
107
|
+
// but those in NOT_SIGN_INS.
|
|
108
|
+
// - Its response sets the session cookie to the new session. An endpoint that
|
|
109
|
+
// another one dispatches, as oauthProvider() runs /oauth2/authorize when a
|
|
110
|
+
// sign-in resumes an authorization request, inherits that request's new
|
|
111
|
+
// session without setting it.
|
|
112
|
+
// - A request that refreshes its own session, such as a session read that
|
|
113
|
+
// extends it or a profile update, sets it again: the session cookie it sent,
|
|
114
|
+
// or the one bearer() made of its Authorization header, has the same token.
|
|
115
|
+
// - Confirming two-factor replaces the signed-in user's session, which the
|
|
116
|
+
// endpoint loaded into ctx.context.session.
|
|
117
|
+
// - When the session the endpoint loaded is the new one, a stored session is a
|
|
118
|
+
// sign-in: oauthProvider() loads it when a sign-in resumes an authorization
|
|
119
|
+
// request. The session of an API key request (apiKey's
|
|
120
|
+
// enableSessionForAPIKeys) has the key as its token and is not stored.
|
|
121
|
+
async function signedIn(ctx: SignInContext, created: SessionRecord): Promise<boolean> {
|
|
122
|
+
if (NOT_SIGN_INS.has(ctx.path)) return false;
|
|
123
|
+
const { token } = created.session;
|
|
124
|
+
const set = parseSetCookieHeader(ctx.context.responseHeaders?.get("set-cookie") ?? "");
|
|
125
|
+
const cookie = set.get(ctx.context.authCookies.sessionToken.name)?.value;
|
|
126
|
+
if (decodeURIComponent(cookie ?? "").split(".")[0] !== token) return false;
|
|
127
|
+
const presented = await ctx.getSignedCookie(ctx.context.authCookies.sessionToken.name, ctx.context.secret);
|
|
128
|
+
if (token === presented) return false;
|
|
129
|
+
const current = ctx.context.session;
|
|
130
|
+
if (!current) return true;
|
|
131
|
+
if (current.session.token !== token) return current.user.id !== created.user.id;
|
|
132
|
+
return Boolean(await ctx.context.internalAdapter.findSession(token));
|
|
89
133
|
}
|
|
90
134
|
|
|
91
135
|
export interface AuditPluginOptions {
|
|
@@ -96,7 +140,9 @@ export interface AuditPluginOptions {
|
|
|
96
140
|
// Records auth and admin actions through the application audit pipeline. Pushed
|
|
97
141
|
// after every other plugin, because after hooks run in plugin order and earlier
|
|
98
142
|
// ones change the outcome: two-factor turns a password sign-in into a pending
|
|
99
|
-
// challenge by clearing ctx.context.newSession.
|
|
143
|
+
// challenge by clearing ctx.context.newSession. It runs for every endpoint, the
|
|
144
|
+
// server-side session lookup of each API request included, so a request with
|
|
145
|
+
// neither an action nor a new session returns before any other work.
|
|
100
146
|
export function auditPlugin(options: AuditPluginOptions = {}): BetterAuthPlugin & { id: "podokit-audit" } {
|
|
101
147
|
const enabled = options.enabled ?? auditEnabled;
|
|
102
148
|
return {
|
|
@@ -104,15 +150,19 @@ export function auditPlugin(options: AuditPluginOptions = {}): BetterAuthPlugin
|
|
|
104
150
|
hooks: {
|
|
105
151
|
after: [
|
|
106
152
|
{
|
|
107
|
-
matcher: (context) => Boolean(context.path
|
|
153
|
+
matcher: (context) => Boolean(context.path),
|
|
108
154
|
handler: createAuthMiddleware(async (ctx) => {
|
|
155
|
+
const action = ACTIONS[ctx.path];
|
|
156
|
+
const created = ctx.context.newSession;
|
|
157
|
+
if (!action && !created) return;
|
|
109
158
|
if (failed(ctx.context.returned)) return;
|
|
159
|
+
const login = created && (await signedIn(ctx, created)) ? created : null;
|
|
160
|
+
if (!action && !login) return;
|
|
110
161
|
// AuditService checks the toggle again on write; checking here as well
|
|
111
162
|
// skips the lookups below when auditing is off.
|
|
112
163
|
if (!(await enabled())) return;
|
|
113
164
|
const ip = clientAddressOf(ctx.headers) ?? null;
|
|
114
165
|
|
|
115
|
-
const action = ACTIONS[ctx.path];
|
|
116
166
|
if (action) {
|
|
117
167
|
const actor = ctx.context.session?.user ?? ctx.context.newSession?.user;
|
|
118
168
|
const body = (ctx.body ?? {}) as { email?: string; userId?: string };
|
|
@@ -140,21 +190,18 @@ export function auditPlugin(options: AuditPluginOptions = {}): BetterAuthPlugin
|
|
|
140
190
|
});
|
|
141
191
|
}
|
|
142
192
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const created = ctx.context.newSession;
|
|
146
|
-
const presented = await ctx.getSignedCookie(ctx.context.authCookies.sessionToken.name, ctx.context.secret);
|
|
147
|
-
if (!signedIn(created, ctx.context.session, presented)) return;
|
|
193
|
+
if (!login) return;
|
|
194
|
+
const method = SIGN_IN_METHODS[ctx.path] ?? ctx.path;
|
|
148
195
|
const params = (ctx.params ?? {}) as { id?: string };
|
|
149
196
|
const body = (ctx.body ?? {}) as { provider?: string };
|
|
150
197
|
const provider = method === "social" ? (params.id ?? body.provider) : undefined;
|
|
151
198
|
await recordAudit({
|
|
152
199
|
action: "auth.login",
|
|
153
|
-
actorId:
|
|
154
|
-
actorName:
|
|
155
|
-
actorEmail:
|
|
156
|
-
targetId:
|
|
157
|
-
targetLabel:
|
|
200
|
+
actorId: login.user.id,
|
|
201
|
+
actorName: login.user.name ?? null,
|
|
202
|
+
actorEmail: login.user.email ?? null,
|
|
203
|
+
targetId: login.user.id,
|
|
204
|
+
targetLabel: login.user.email ?? null,
|
|
158
205
|
ip,
|
|
159
206
|
metadata: provider ? { method, provider } : { method },
|
|
160
207
|
});
|
|
@@ -20,11 +20,41 @@ const origin = { origin: base };
|
|
|
20
20
|
// RateLimit-Limit and RateLimit-Remaining headers. The spec reads the limits from
|
|
21
21
|
// them, so it follows whatever limits the stack runs with. When the runner's
|
|
22
22
|
// environment sets RATE_LIMIT_MAX, RATE_LIMIT_AUTH_MAX, or RATE_LIMIT_RUNTIME_MAX,
|
|
23
|
-
// the spec also expects the stack to use that value.
|
|
24
|
-
//
|
|
25
|
-
//
|
|
23
|
+
// the spec also expects the stack to use that value. A test that sends a budget's
|
|
24
|
+
// worth of requests times the first ones and skips, with the reason, when the
|
|
25
|
+
// rest would not fit in its time, as with raised local limits. A run that should
|
|
26
|
+
// not send them at all sets E2E_SKIP_RATE_LIMIT=true to skip the file.
|
|
26
27
|
test.skip(process.env.E2E_SKIP_RATE_LIMIT === "true", "E2E_SKIP_RATE_LIMIT is true");
|
|
27
28
|
|
|
29
|
+
/** Requests a test times before it judges whether the rest fit in its time. */
|
|
30
|
+
const PACE_SAMPLE = 50;
|
|
31
|
+
|
|
32
|
+
let testStarted = Date.now();
|
|
33
|
+
test.beforeEach(() => {
|
|
34
|
+
testStarted = Date.now();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
function seconds(ms: number): string {
|
|
38
|
+
return `${(ms / 1000).toFixed(1)} s`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Why `left` more requests, sent at the pace of the `sent` ones since `since`,
|
|
43
|
+
* would not end within half of the time this test has left, or undefined when
|
|
44
|
+
* they would. The other half leaves room for a slower stretch.
|
|
45
|
+
*/
|
|
46
|
+
function outOfTime(sent: number, since: number, left: number): string | undefined {
|
|
47
|
+
const { timeout } = test.info();
|
|
48
|
+
if (timeout === 0) return undefined;
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
const perRequest = (now - since) / sent;
|
|
51
|
+
const needed = perRequest * left;
|
|
52
|
+
const available = timeout - (now - testStarted);
|
|
53
|
+
if (needed <= available / 2) return undefined;
|
|
54
|
+
const pace = Math.round(1000 / perRequest);
|
|
55
|
+
return `${left} more requests at ${pace} per second take about ${seconds(needed)}, more than half of the ${seconds(available)} this test has left`;
|
|
56
|
+
}
|
|
57
|
+
|
|
28
58
|
/** Requires a session, so a session's requests count under its user from the first one. */
|
|
29
59
|
const PROBE = "/api/account/me";
|
|
30
60
|
const HEALTH = ["/api/health", "/api/health/ready"];
|
|
@@ -64,11 +94,14 @@ async function inOneWindow(check: () => Promise<void>): Promise<void> {
|
|
|
64
94
|
}
|
|
65
95
|
|
|
66
96
|
/**
|
|
67
|
-
* Requests the probe until the limiter answers 429, or
|
|
68
|
-
* window
|
|
69
|
-
*
|
|
97
|
+
* Requests the probe until the limiter answers 429, or returns why it stopped
|
|
98
|
+
* first: the general window kept ending first, or the requests left to the
|
|
99
|
+
* limit would not fit in the test's time. More remaining than on the request
|
|
100
|
+
* before means a new window started, which starts the count over.
|
|
70
101
|
*/
|
|
71
|
-
async function untilLimited(ctx: APIRequestContext, limit: number): Promise<APIResponse |
|
|
102
|
+
async function untilLimited(ctx: APIRequestContext, limit: number): Promise<APIResponse | string> {
|
|
103
|
+
const started = Date.now();
|
|
104
|
+
let sent = 0;
|
|
72
105
|
let windows = 1;
|
|
73
106
|
let counted = 0;
|
|
74
107
|
let previous = Number.POSITIVE_INFINITY;
|
|
@@ -77,15 +110,21 @@ async function untilLimited(ctx: APIRequestContext, limit: number): Promise<APIR
|
|
|
77
110
|
if (response.status() === 429) return response;
|
|
78
111
|
expect(response.status(), `${PROBE} request ${counted + 1} of window ${windows}`).toBe(200);
|
|
79
112
|
const { remaining } = budget(response);
|
|
113
|
+
sent += 1;
|
|
80
114
|
if (remaining >= previous) {
|
|
81
115
|
windows += 1;
|
|
82
116
|
counted = 0;
|
|
83
|
-
if (windows > 3) return
|
|
117
|
+
if (windows > 3) return `the general window ends before this client sends ${limit} requests`;
|
|
84
118
|
}
|
|
85
119
|
previous = remaining;
|
|
86
120
|
counted += 1;
|
|
87
121
|
// A window holds at most `limit` served requests.
|
|
88
122
|
expect(counted, `requests served in one general window`).toBeLessThanOrEqual(limit);
|
|
123
|
+
// Judged in each window, since a new one needs the whole limit again.
|
|
124
|
+
if (counted === PACE_SAMPLE) {
|
|
125
|
+
const late = outOfTime(sent, started, remaining + 1);
|
|
126
|
+
if (late) return `reaching the general limit (${limit}): ${late}`;
|
|
127
|
+
}
|
|
89
128
|
}
|
|
90
129
|
}
|
|
91
130
|
|
|
@@ -139,7 +178,6 @@ test("rate limit: health probes stay available while ordinary routes return 429
|
|
|
139
178
|
expect(probe.status()).toBe(200);
|
|
140
179
|
const { limit } = budget(probe);
|
|
141
180
|
expectConfigured("RATE_LIMIT_MAX", limit);
|
|
142
|
-
test.skip(limit > 10_000, `the general budget (${limit}) is too large to exhaust here`);
|
|
143
181
|
|
|
144
182
|
// Health probes are not counted: they carry no budget and leave the general one as it was.
|
|
145
183
|
await inOneWindow(async () => {
|
|
@@ -156,9 +194,12 @@ test("rate limit: health probes stay available while ordinary routes return 429
|
|
|
156
194
|
|
|
157
195
|
// A window serves `limit` requests and rejects the next one.
|
|
158
196
|
const limited = await untilLimited(ctx, limit);
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
197
|
+
if (typeof limited === "string") {
|
|
198
|
+
test.skip(true, limited);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
expect(limited.headers()["retry-after"]).toBeTruthy();
|
|
202
|
+
expect(await limited.json()).toMatchObject({
|
|
162
203
|
success: false,
|
|
163
204
|
error: { code: "RATE_LIMIT_EXCEEDED", statusCode: 429 },
|
|
164
205
|
});
|
|
@@ -239,10 +280,14 @@ test("rate limit: session reads do not consume the strict authentication budget"
|
|
|
239
280
|
reads + 1 > runtimeLimit,
|
|
240
281
|
`the authentication budget (${authLimit}) is not far enough below the runtime budget (${runtimeLimit})`,
|
|
241
282
|
);
|
|
242
|
-
|
|
243
|
-
for (let i =
|
|
283
|
+
const started = Date.now();
|
|
284
|
+
for (let i = 1; i <= reads; i++) {
|
|
244
285
|
const response = await ctx.get("/api/auth/get-session");
|
|
245
|
-
expect(response.status(), `session request ${i
|
|
286
|
+
expect(response.status(), `session request ${i}`).toBe(200);
|
|
287
|
+
if (i === PACE_SAMPLE) {
|
|
288
|
+
const late = outOfTime(i, started, reads - i);
|
|
289
|
+
test.skip(late !== undefined, `reading past the authentication budget (${authLimit}): ${late}`);
|
|
290
|
+
}
|
|
246
291
|
}
|
|
247
292
|
await ctx.dispose();
|
|
248
293
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@podosoft/podokit",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.3",
|
|
4
4
|
"description": "An opinionated Bun and Elysia full-stack TypeScript starter toolkit and CLI with SvelteKit, TailwindCSS, shadcn-svelte, Docker, and k3s.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|