@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.
@@ -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
- // .invalid never resolves (RFC 2606). A resolver that does not answer at all
270
- // fails with ETIMEOUT after about 16 seconds, which is recognized too.
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
- }, 30_000);
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 { mailpitReachable, waitForOtp } from "../helpers/mailpit";
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
- test.skip(!(await mailpitReachable()), "Mailpit not available");
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 { mailpitReachable, waitForLink, waitForOtp } from "../helpers/mailpit";
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
- test.skip(!(await mailpitReachable()), "Mailpit not available");
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
- test.skip(!(await mailpitReachable()), "Mailpit not available");
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
- test.skip(!(await mailpitReachable()), "Mailpit not available");
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
- test.skip(!(await mailpitReachable()), "Mailpit not available");
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
- // Helpers for the local Mailpit catcher (docker-compose / CI service). Tests that
2
- // exercise email flows skip when it isn't reachable (see MAILPIT_URL). Each test
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
- export async function mailpitReachable(): Promise<boolean> {
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
- return (await fetch(`${MAILPIT}/readyz`)).ok;
26
+ if (!(await fetch(`${MAILPIT}/readyz`)).ok) return `Mailpit at ${MAILPIT} is not ready`;
10
27
  } catch {
11
- return false;
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
- type MailpitList = { messages: Array<{ ID: string; To: Array<{ Address: string }> }> };
16
- type MailpitMessage = { Text?: string; HTML?: string };
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 list = (await (await fetch(`${MAILPIT}/api/v1/messages`)).json()) as MailpitList;
24
- const msg = list.messages?.find((m) => m.To?.some((t) => t.Address === to));
25
- if (msg) {
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 new Promise((r) => setTimeout(r, 300));
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 { mailpitReachable, waitForLink } from "../helpers/mailpit";
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
- test.skip(!(await mailpitReachable()), "Mailpit not available");
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 { mailpitReachable, waitForLink } from "../helpers/mailpit";
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
- test.skip(!(await mailpitReachable()), "Mailpit not available");
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 { mailpitReachable, waitForOtp } from "../helpers/mailpit";
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
- test.skip(!(await mailpitReachable()), "mailpit unreachable");
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
- // Point at the same Mailpit the env already uses, so saving can't re-route mail
61
- // for the other email specs.
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 two-factor sign-in is recorded when the second
20
- factor completes it, not at the password step. `auth.ts` pushes the plugin after
21
- every other plugin: Better Auth runs after hooks in plugin order, and two-factor
22
- changes the outcome of a password sign-in in its own after hook.
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, and `SIGN_IN_METHODS` lists the endpoints that sign a user in.
100
- The `audit-hook.spec.ts` test fails when the app's Better Auth gains a sign-in
101
- endpoint that neither list classifies.
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
- // Endpoints that look like a sign-in but never start a session of their own.
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
- "/sign-in/magic-link", // emails the link that /magic-link/verify signs in with
19
- "/passkey/verify-registration", // adds a passkey for the signed-in user
20
- "/verify-password", // checks the signed-in user's password again
21
- "/delete-user/callback", // confirms an account deletion
22
- "/email-otp/check-verification-otp", // checks a code without using it
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
- // The sign-in methods PodoKit's auth.ts enables, on an in-memory database, with
56
- // the audit plugin pushed last as the audit-log module does.
57
- function startAuth(enabled: () => Promise<boolean> = async () => true) {
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
- return new Headers({ cookie: [...this.cookies].map(([name, value]) => `${name}=${value}`).join("; ") });
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 that can sign a user in", () => {
594
+ test("classifies every endpoint of Better Auth and PodoKit's plugins", () => {
374
595
  const instance = buildAuth(envAuthConfig());
375
- const paths = new Set(Object.values(instance.api as Record<string, { path?: string }>).map((endpoint) => endpoint.path ?? ""));
376
- expect(Object.keys(SIGN_IN_METHODS).filter((path) => !paths.has(path))).toEqual([]);
377
- const unclassified = [...paths].filter(
378
- (path) =>
379
- (path.startsWith("/sign-in/") || /callback|verify/.test(path)) &&
380
- SIGN_IN_METHODS[path] === undefined &&
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
- expect(unclassified).toEqual([]);
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
- // Endpoints that sign a user in, with the method their auth.login entry records.
8
- // ctx.path is the route pattern an endpoint was registered with, not the request
9
- // URL, so every social provider's callback arrives as "/callback/:id".
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. Two requests end
77
- // with a new session without signing anyone in: confirming two-factor replaces
78
- // the signed-in user's session, which two-factor loaded into ctx.context.session,
79
- // and verifying the signed-in user's email sets the cookie of the session the
80
- // request came with again. oauthProvider() also puts the new session into
81
- // ctx.context.session when a sign-in resumes an authorization request.
82
- function signedIn(
83
- created: SessionRecord | null | undefined,
84
- current: SessionRecord | null | undefined,
85
- presentedToken: string | null | false,
86
- ): created is SessionRecord {
87
- if (!created || created.session.token === presentedToken) return false;
88
- return !(current && current.user.id === created.user.id && current.session.token !== created.session.token);
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 && (SIGN_IN_METHODS[context.path] || ACTIONS[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
- const method = SIGN_IN_METHODS[ctx.path];
144
- if (!method) return;
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: created.user.id,
154
- actorName: created.user.name ?? null,
155
- actorEmail: created.user.email ?? null,
156
- targetId: created.user.id,
157
- targetLabel: created.user.email ?? null,
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. It sends one more request
24
- // than the general limit, so a run that should not spend that many on a raised
25
- // limit sets E2E_SKIP_RATE_LIMIT=true to skip the file.
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 null when the general
68
- * window keeps ending first. More remaining than on the request before means a
69
- * new window started, which starts the count over.
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 | null> {
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 null;
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
- test.skip(limited === null, `the general window ends before this client sends ${limit} requests`);
160
- expect(limited?.headers()["retry-after"]).toBeTruthy();
161
- expect(await limited?.json()).toMatchObject({
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
- test.skip(authLimit > 1000, `the authentication budget (${authLimit}) is too large to exhaust here`);
243
- for (let i = 0; i < reads; 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 + 1}`).toBe(200);
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.2",
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": {