@podosoft/podokit 1.6.2 → 1.6.4

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.
Files changed (28) hide show
  1. package/dist/deploy-checks.d.ts +20 -2
  2. package/dist/deploy-checks.js +83 -4
  3. package/dist/deploy-compose-render.js +15 -4
  4. package/dist/deploy-compose.js +211 -27
  5. package/dist/deploy.js +16 -0
  6. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +50 -4
  7. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +3 -2
  8. package/dist/templates/fullstack/tests/README.md +11 -0
  9. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +28 -2
  10. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +24 -0
  11. package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +5 -5
  12. package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +61 -12
  13. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +16 -4
  14. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +2 -2
  15. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +2 -2
  16. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -2
  17. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +2 -2
  18. package/dist/templates/modules/admin-dashboard/module.manifest.json +4 -2
  19. package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +31 -8
  20. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.spec.ts +531 -29
  21. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +253 -60
  22. package/dist/templates/modules/auth/files/apps/api/src/auth/admin-emails.spec.ts +232 -0
  23. package/dist/templates/modules/auth/files/apps/api/src/auth/admin-emails.ts +68 -0
  24. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +53 -47
  25. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/SKILL.md +1 -1
  26. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +9 -2
  27. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +60 -15
  28. package/package.json +1 -1
@@ -1,31 +1,90 @@
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, phoneNumber, 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 {
26
83
  magicLink: string;
27
84
  otp: string;
28
85
  verification: string;
86
+ reset: string;
87
+ sms: string;
29
88
  }
30
89
 
31
90
  interface AuthHandler {
@@ -52,18 +111,77 @@ afterEach(() => restoreRecorder());
52
111
  const logins = (): AuditEntry[] => entries.filter((entry) => entry.action === "auth.login");
53
112
  const actions = (action: string): AuditEntry[] => entries.filter((entry) => entry.action === action);
54
113
 
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) {
58
- const mail: Mailbox = { magicLink: "", otp: "", verification: "" };
114
+ // Like @better-auth/electron's /electron/token, which a desktop app calls with
115
+ // the code its browser sign-in handed over: an endpoint of an application's own
116
+ // plugin that starts a session for a user it looks up.
117
+ function desktopSignIn(): BetterAuthPlugin {
118
+ return {
119
+ id: "desktop-sign-in",
120
+ endpoints: {
121
+ desktopToken: createAuthEndpoint("/desktop/token", { method: "POST" }, async (ctx) => {
122
+ const { code } = (ctx.body ?? {}) as { code?: string };
123
+ const found = code ? await ctx.context.internalAdapter.findUserByEmail(code) : null;
124
+ if (!found) throw new APIError("NOT_FOUND", { message: "Invalid or expired token." });
125
+ const session = await ctx.context.internalAdapter.createSession(found.user.id);
126
+ await setSessionCookie(ctx, { session, user: found.user });
127
+ return ctx.json({ token: session.token });
128
+ }),
129
+ },
130
+ };
131
+ }
132
+
133
+ interface StartOptions {
134
+ enabled?: () => Promise<boolean>;
135
+ session?: BetterAuthOptions["session"];
136
+ /** Self-registered accounts wait for approval without a session, as auth.ts does. */
137
+ signupApproval?: boolean;
138
+ /** Whether an email verification link signs its user in. */
139
+ verificationSignIn?: boolean;
140
+ }
141
+
142
+ // Like auth.ts with sign-up approval on: a self-registered account starts
143
+ // pending and gets no session until an administrator approves it.
144
+ const signupApprovalHooks: BetterAuthOptions["databaseHooks"] = {
145
+ user: {
146
+ create: {
147
+ before: async (user, context) => ({ data: { ...user, signupApproved: context?.path === "/admin/create-user" } }),
148
+ },
149
+ },
150
+ session: {
151
+ create: {
152
+ before: async (session, context) => {
153
+ const user = (await context?.context.internalAdapter.findUserById(session.userId)) as { signupApproved?: boolean } | null;
154
+ if (user?.signupApproved === false) throw new APIError("FORBIDDEN", { message: "Waiting for approval." });
155
+ },
156
+ },
157
+ },
158
+ };
159
+
160
+ // The sign-in methods PodoKit's auth.ts enables and plugins that set or swap
161
+ // sessions without a sign-in, on an in-memory database, with the audit plugin
162
+ // pushed last as the audit-log module does.
163
+ function startAuth({ enabled = async () => true, session, signupApproval = false, verificationSignIn = true }: StartOptions = {}) {
164
+ const mail: Mailbox = { magicLink: "", otp: "", verification: "", reset: "", sms: "" };
59
165
  const db: Record<string, Record<string, unknown>[]> = {};
60
166
  const options = {
61
167
  baseURL: BASE_URL,
62
168
  secret: "audit-hook-test-secret-0123456789abcdef",
63
169
  database: memoryAdapter(db),
64
- emailAndPassword: { enabled: true },
170
+ ...(session ? { session } : {}),
171
+ ...(signupApproval ? { databaseHooks: signupApprovalHooks } : {}),
172
+ user: {
173
+ changeEmail: { enabled: true, updateEmailWithoutVerification: true },
174
+ additionalFields: { signupApproved: { type: "boolean" as const, required: false, defaultValue: true, input: false } },
175
+ },
176
+ emailAndPassword: {
177
+ enabled: true,
178
+ autoSignIn: !signupApproval,
179
+ sendResetPassword: async ({ token }: { token: string }) => {
180
+ mail.reset = token;
181
+ },
182
+ },
65
183
  emailVerification: {
66
- autoSignInAfterVerification: true,
184
+ autoSignInAfterVerification: verificationSignIn,
67
185
  sendVerificationEmail: async ({ url }: { url: string }) => {
68
186
  mail.verification = url;
69
187
  },
@@ -99,32 +217,50 @@ function startAuth(enabled: () => Promise<boolean> = async () => true) {
99
217
  },
100
218
  }),
101
219
  emailOTP({
220
+ changeEmail: { enabled: true },
102
221
  sendVerificationOTP: async ({ otp }) => {
103
222
  mail.otp = otp;
104
223
  },
105
224
  }),
225
+ phoneNumber({
226
+ sendOTP: async ({ code }) => {
227
+ mail.sms = code;
228
+ },
229
+ sendPasswordResetOTP: async ({ code }) => {
230
+ mail.sms = code;
231
+ },
232
+ }),
233
+ multiSession(),
234
+ apiKey({ enableSessionForAPIKeys: true, rateLimit: { enabled: false } }),
235
+ bearer({ requireSignature: true }),
106
236
  jwt({ disableSettingJwtHeader: true }),
107
237
  oauthProvider({ loginPage: "/login", consentPage: "/oauth2/consent" }),
108
238
  admin(),
239
+ desktopSignIn(),
109
240
  auditPlugin({ enabled }),
110
241
  ],
111
242
  };
112
243
  for (const table of Object.values(getAuthTables(options))) db[table.modelName] = [];
113
- return { auth: betterAuth(options), mail };
244
+ return { auth: betterAuth(options), mail, db };
114
245
  }
115
246
 
116
- // A cookie jar in front of the Better Auth router, like a browser's.
247
+ // A cookie jar in front of the Better Auth router, like a browser's, or a client
248
+ // that sends the same headers with every request.
117
249
  class Browser {
118
250
  private readonly auth: AuthHandler;
251
+ private readonly sent: Record<string, string>;
119
252
  private readonly cookies = new Map<string, string>();
120
253
 
121
- constructor(auth: AuthHandler) {
254
+ constructor(auth: AuthHandler, sent: Record<string, string> = {}) {
122
255
  this.auth = auth;
256
+ this.sent = sent;
123
257
  }
124
258
 
125
259
  /** Request headers that carry this browser's cookies, for a server-side auth.api call. */
126
260
  headers(): Headers {
127
- return new Headers({ cookie: [...this.cookies].map(([name, value]) => `${name}=${value}`).join("; ") });
261
+ const headers = new Headers(this.sent);
262
+ if (this.cookies.size > 0) headers.set("cookie", [...this.cookies].map(([name, value]) => `${name}=${value}`).join("; "));
263
+ return headers;
128
264
  }
129
265
 
130
266
  get(path: string): Promise<Response> {
@@ -160,6 +296,10 @@ async function signUp(auth: AuthHandler, email: string): Promise<Browser> {
160
296
  return browser;
161
297
  }
162
298
 
299
+ async function userId(browser: Browser): Promise<string> {
300
+ return ((await (await browser.get("/get-session")).json()) as { user: { id: string } }).user.id;
301
+ }
302
+
163
303
  // The current code for an otpauth:// URI, as an authenticator app computes it.
164
304
  function totp(uri: string): string {
165
305
  const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
@@ -175,10 +315,39 @@ function totp(uri: string): string {
175
315
 
176
316
  const otherCode = (code: string): string => String((Number(code) + 1) % 1_000_000).padStart(6, "0");
177
317
 
318
+ // A Google sign-in through the provider's callback, with the token endpoint
319
+ // stubbed; the provider reports social@example.com, verified.
320
+ async function googleSignIn(browser: Browser): Promise<Response> {
321
+ const realFetch = globalThis.fetch;
322
+ const tokenEndpoint = spyOn(globalThis, "fetch").mockImplementation(((input: RequestInfo | URL, init?: RequestInit) => {
323
+ const url = input instanceof Request ? input.url : String(input);
324
+ if (url.startsWith("https://oauth2.googleapis.com/token")) {
325
+ return Promise.resolve(Response.json({ access_token: "access", expires_in: 3600, token_type: "Bearer" }));
326
+ }
327
+ return realFetch(input, init);
328
+ }) as typeof fetch);
329
+ try {
330
+ const started = await browser.post("/sign-in/social", { provider: "google", callbackURL: `${BASE_URL}/` });
331
+ const state = new URL(((await started.json()) as { url: string }).url).searchParams.get("state") ?? "";
332
+ return await browser.get(`/callback/google?code=code&state=${encodeURIComponent(state)}`);
333
+ } finally {
334
+ tokenEndpoint.mockRestore();
335
+ }
336
+ }
337
+
338
+ async function makeAdmin(db: Record<string, Record<string, unknown>[]>, browser: Browser): Promise<string> {
339
+ const id = await userId(browser);
340
+ const row = db.user?.find((user) => user.id === id);
341
+ if (row) row.role = "admin";
342
+ return id;
343
+ }
344
+
178
345
  describe("audit plugin", () => {
179
346
  test("records a password sign-in once and a wrong password not at all", async () => {
180
347
  const { auth } = startAuth();
181
348
  await signUp(auth, "password@example.com");
349
+ // Signing up starts a session too, recorded as the sign-up only.
350
+ expect(entries.map((entry) => entry.action)).toEqual(["auth.signup"]);
182
351
 
183
352
  const wrong = await new Browser(auth).post("/sign-in/email", { email: "password@example.com", password: "Wrong-password-1" });
184
353
  expect(wrong.status).toBe(401);
@@ -329,6 +498,28 @@ describe("audit plugin", () => {
329
498
  expect(signedIn.status).toBe(200);
330
499
  expect(await signedIn.json()).toMatchObject({ redirect: true, url: expect.stringContaining("code=") });
331
500
  expect(logins()).toEqual([expect.objectContaining({ actorEmail: "oauth@example.com", metadata: { method: "password" } })]);
501
+
502
+ // Authorizing again with that session and exchanging the code sign nobody in.
503
+ const again = await browser.get(`/oauth2/authorize?${query}`);
504
+ expect(again.status).toBe(302);
505
+ const code = new URL(again.headers.get("location") ?? "").searchParams.get("code") ?? "";
506
+ expect(code).not.toBe("");
507
+ const exchange = new URLSearchParams({
508
+ grant_type: "authorization_code",
509
+ code,
510
+ redirect_uri: "https://client.example.com/callback",
511
+ code_verifier: verifier,
512
+ });
513
+ const credentials = Buffer.from(`${client.client_id}:${client.client_secret ?? ""}`).toString("base64");
514
+ const token = await auth.handler(
515
+ new Request(`${BASE_URL}/api/auth/oauth2/token`, {
516
+ method: "POST",
517
+ headers: { "content-type": "application/x-www-form-urlencoded", authorization: `Basic ${credentials}` },
518
+ body: exchange,
519
+ }),
520
+ );
521
+ expect(token.status).toBe(200);
522
+ expect(logins()).toHaveLength(1);
332
523
  });
333
524
 
334
525
  test("does not record failed account and admin requests", async () => {
@@ -354,14 +545,322 @@ describe("audit plugin", () => {
354
545
  expect(logins()).toEqual([]);
355
546
  });
356
547
 
548
+ test("records a sign-in through an endpoint of an application's plugin with its path", async () => {
549
+ const { auth } = startAuth();
550
+ await signUp(auth, "desktop@example.com");
551
+ entries = [];
552
+
553
+ const desktop = new Browser(auth);
554
+ expect((await desktop.post("/desktop/token", { code: "nobody@example.com" })).status).toBe(404);
555
+ expect(logins()).toEqual([]);
556
+ expect((await desktop.post("/desktop/token", { code: "desktop@example.com" })).status).toBe(200);
557
+ expect(logins()).toEqual([
558
+ expect.objectContaining({ actorEmail: "desktop@example.com", metadata: { method: "/desktop/token" } }),
559
+ ]);
560
+ // The session it started is then the desktop app's own.
561
+ expect((await desktop.get("/get-session")).status).toBe(200);
562
+ expect((await desktop.post("/update-user", { name: "Desktop" })).status).toBe(200);
563
+ expect(logins()).toHaveLength(1);
564
+ });
565
+
566
+ test("records no sign-in for reads and updates that refresh the session", async () => {
567
+ // Every session read extends the session and the cookie cache expires at once.
568
+ const { auth } = startAuth({ session: { updateAge: 0, cookieCache: { enabled: true, maxAge: 1 } } });
569
+ const owner = await signUp(auth, "refresh@example.com");
570
+ entries = [];
571
+ expect((await owner.get("/get-session")).status).toBe(200);
572
+ expect((await owner.get("/get-session?disableCookieCache=true")).status).toBe(200);
573
+ expect((await owner.post("/update-user", { name: "Refreshed" })).status).toBe(200);
574
+ expect((await owner.post("/change-email", { newEmail: "refreshed@example.com" })).status).toBe(200);
575
+ expect((await owner.post("/update-session", {})).status).toBeLessThan(500);
576
+ expect((await owner.get("/token")).status).toBe(200);
577
+ expect((await owner.get("/list-sessions")).status).toBe(200);
578
+ expect(actions("account.update")).toHaveLength(1);
579
+ expect(actions("auth.email_change")).toHaveLength(1);
580
+ expect(logins()).toEqual([]);
581
+ });
582
+
583
+ test("records no sign-in for bearer and API key requests", async () => {
584
+ const { auth } = startAuth({ session: { updateAge: 0 } });
585
+ const owner = await signUp(auth, "client@example.com");
586
+ const signIn = await new Browser(auth).post("/sign-in/email", { email: "client@example.com", password: PASSWORD });
587
+ const token = signIn.headers.get("set-auth-token") ?? "";
588
+ expect(token).not.toBe("");
589
+ const created = await owner.post("/api-key/create", {});
590
+ expect(created.status).toBe(200);
591
+ const { key } = (await created.json()) as { key: string };
592
+ entries = [];
593
+
594
+ for (const client of [new Browser(auth, { authorization: `Bearer ${token}` }), new Browser(auth, { "x-api-key": key })]) {
595
+ for (let i = 0; i < 3; i++) expect((await client.get("/get-session")).status).toBe(200);
596
+ expect((await client.post("/update-user", { name: "Client" })).status).toBe(200);
597
+ expect((await client.get("/list-sessions")).status).toBe(200);
598
+ }
599
+ expect(actions("account.update")).toHaveLength(2);
600
+ expect(logins()).toEqual([]);
601
+ });
602
+
603
+ test("records no sign-in when an administrator impersonates a user or a browser switches accounts", async () => {
604
+ const { auth, db } = startAuth();
605
+ const operator = await signUp(auth, "operator@example.com");
606
+ const operatorId = await userId(operator);
607
+ const row = db.user?.find((user) => user.id === operatorId);
608
+ if (row) row.role = "admin";
609
+ const target = await signUp(auth, "impersonated@example.com");
610
+ const targetId = await userId(target);
611
+ entries = [];
612
+
613
+ expect((await operator.post("/admin/impersonate-user", { userId: targetId })).status).toBe(200);
614
+ expect(await userId(operator)).toBe(targetId);
615
+ expect((await operator.post("/admin/stop-impersonating", {})).status).toBe(200);
616
+ expect(await userId(operator)).toBe(operatorId);
617
+ expect(actions("user.impersonate")).toHaveLength(1);
618
+ expect(actions("user.stop_impersonate")).toHaveLength(1);
619
+ expect(logins()).toEqual([]);
620
+
621
+ // Signing in to a second account in the same browser is a sign-in; switching
622
+ // between the two and signing one out are not.
623
+ const second = await operator.post("/sign-in/email", { email: "impersonated@example.com", password: PASSWORD });
624
+ expect(second.status).toBe(200);
625
+ expect(logins()).toEqual([expect.objectContaining({ actorEmail: "impersonated@example.com" })]);
626
+ const sessions = (await (await operator.get("/multi-session/list-device-sessions")).json()) as Array<{
627
+ session: { token: string };
628
+ user: { id: string };
629
+ }>;
630
+ const tokenOf = (id: string): string => sessions.find((entry) => entry.user.id === id)?.session.token ?? "";
631
+ expect((await operator.post("/multi-session/set-active", { sessionToken: tokenOf(operatorId) })).status).toBe(200);
632
+ expect(await userId(operator)).toBe(operatorId);
633
+ expect((await operator.post("/multi-session/revoke", { sessionToken: tokenOf(operatorId) })).status).toBe(200);
634
+ expect(await userId(operator)).toBe(targetId);
635
+ expect((await operator.post("/revoke-other-sessions", {})).status).toBe(200);
636
+ expect((await operator.post("/revoke-sessions", {})).status).toBe(200);
637
+ expect(logins()).toHaveLength(1);
638
+
639
+ const again = await new Browser(auth).post("/sign-in/email", { email: "operator@example.com", password: PASSWORD });
640
+ expect(again.status).toBe(200);
641
+ const signedOut = new Browser(auth);
642
+ await signedOut.post("/sign-in/email", { email: "operator@example.com", password: PASSWORD });
643
+ expect((await signedOut.post("/sign-out", {})).status).toBe(200);
644
+ expect(actions("auth.logout")).toHaveLength(1);
645
+ expect(logins()).toHaveLength(3);
646
+ });
647
+
357
648
  test("records nothing while auditing is off", async () => {
358
- const { auth } = startAuth(async () => false);
649
+ const { auth } = startAuth({ enabled: async () => false });
359
650
  await signUp(auth, "off@example.com");
360
651
  expect((await new Browser(auth).post("/sign-in/email", { email: "off@example.com", password: PASSWORD })).status).toBe(200);
361
652
  expect(entries).toEqual([]);
362
653
  });
363
654
  });
364
655
 
656
+ describe("audit plugin accounts and sessions", () => {
657
+ test("records an email sign-up once, and not a duplicate answered as if it succeeded", async () => {
658
+ const { auth } = startAuth({ signupApproval: true });
659
+ const first = await new Browser(auth).post("/sign-up/email", { email: "pending@example.com", password: PASSWORD, name: "P" });
660
+ expect(first.status).toBe(200);
661
+ const id = (await first.json()) as { user: { id: string } };
662
+ // With auto sign-in off, Better Auth answers a taken address with the same 200.
663
+ const again = await new Browser(auth).post("/sign-up/email", { email: "pending@example.com", password: PASSWORD, name: "P" });
664
+ expect(again.status).toBe(200);
665
+ expect(entries).toEqual([
666
+ expect.objectContaining({
667
+ action: "auth.signup",
668
+ actorEmail: "pending@example.com",
669
+ targetId: id.user.id,
670
+ targetLabel: "pending@example.com",
671
+ metadata: { method: "password" },
672
+ }),
673
+ ]);
674
+ });
675
+
676
+ test("records a social sign-up that waits for approval, without a sign-in", async () => {
677
+ const { auth, db } = startAuth({ signupApproval: true });
678
+ // The approval hook refuses the session, so the callback fails after the
679
+ // account is stored.
680
+ expect((await googleSignIn(new Browser(auth))).status).toBe(403);
681
+ expect(db.session).toEqual([]);
682
+ expect(db.user).toHaveLength(1);
683
+ expect(entries).toEqual([
684
+ expect.objectContaining({
685
+ action: "auth.signup",
686
+ actorEmail: "social@example.com",
687
+ targetId: db.user?.[0]?.id,
688
+ metadata: { method: "social", provider: "google" },
689
+ }),
690
+ ]);
691
+ });
692
+
693
+ test("records an approved social sign-up and its sign-in, and a later sign-in alone", async () => {
694
+ const { auth } = startAuth();
695
+ expect((await googleSignIn(new Browser(auth))).status).toBe(302);
696
+ expect(entries.map((entry) => [entry.action, entry.metadata])).toEqual([
697
+ ["auth.signup", { method: "social", provider: "google" }],
698
+ ["auth.login", { method: "social", provider: "google" }],
699
+ ]);
700
+ expect((await googleSignIn(new Browser(auth))).status).toBe(302);
701
+ expect(entries.map((entry) => entry.action)).toEqual(["auth.signup", "auth.login", "auth.login"]);
702
+ });
703
+
704
+ test("records the account a magic link or an email code creates as a sign-up", async () => {
705
+ const { auth, mail } = startAuth();
706
+ const linked = new Browser(auth);
707
+ await linked.post("/sign-in/magic-link", { email: "new-magic@example.com", callbackURL: `${BASE_URL}/` });
708
+ expect((await linked.get(mail.magicLink)).status).toBe(302);
709
+ const coded = new Browser(auth);
710
+ await coded.post("/email-otp/send-verification-otp", { email: "new-code@example.com", type: "sign-in" });
711
+ expect((await coded.post("/sign-in/email-otp", { email: "new-code@example.com", otp: mail.otp })).status).toBe(200);
712
+ expect(entries.map((entry) => [entry.action, entry.actorEmail, entry.metadata])).toEqual([
713
+ ["auth.signup", "new-magic@example.com", { method: "magic-link" }],
714
+ ["auth.login", "new-magic@example.com", { method: "magic-link" }],
715
+ ["auth.signup", "new-code@example.com", { method: "email-otp" }],
716
+ ["auth.login", "new-code@example.com", { method: "email-otp" }],
717
+ ]);
718
+ });
719
+
720
+ test("records an administrator's new user as user.create only, with its id", async () => {
721
+ const { auth, db } = startAuth({ signupApproval: true });
722
+ await new Browser(auth).post("/sign-up/email", { email: "creator@example.com", password: PASSWORD, name: "C" });
723
+ const creator = db.user?.[0];
724
+ if (creator) Object.assign(creator, { role: "admin", signupApproved: true });
725
+ const operator = new Browser(auth);
726
+ expect((await operator.post("/sign-in/email", { email: "creator@example.com", password: PASSWORD })).status).toBe(200);
727
+ entries = [];
728
+
729
+ const created = await operator.post("/admin/create-user", { email: "made@example.com", password: PASSWORD, name: "M" });
730
+ expect(created.status).toBe(200);
731
+ const { user } = (await created.json()) as { user: { id: string } };
732
+ expect(entries).toEqual([
733
+ expect.objectContaining({ action: "user.create", actorEmail: "creator@example.com", targetId: user.id, targetLabel: "made@example.com" }),
734
+ ]);
735
+ });
736
+
737
+ test("records an account created outside a request without an actor", async () => {
738
+ const { auth } = startAuth();
739
+ const context = await auth.$context;
740
+ const user = await context.internalAdapter.createUser(
741
+ { email: "seeded@example.com", name: "Seeded", emailVerified: true },
742
+ { method: "seed" },
743
+ );
744
+ expect(entries).toEqual([
745
+ { action: "auth.signup", targetId: user.id, targetLabel: "seeded@example.com", ip: null },
746
+ ]);
747
+ });
748
+
749
+ test("names the user who signs out, and records nothing for a sign-out without a session", async () => {
750
+ const { auth } = startAuth();
751
+ const owner = await signUp(auth, "leaving@example.com");
752
+ const id = await userId(owner);
753
+ entries = [];
754
+ expect((await new Browser(auth).post("/sign-out", {})).status).toBe(200);
755
+ const forged = new Browser(auth, { cookie: "better-auth.session_token=forged.signature" });
756
+ expect((await forged.post("/sign-out", {})).status).toBe(200);
757
+ expect(entries).toEqual([]);
758
+
759
+ expect((await owner.post("/sign-out", {})).status).toBe(200);
760
+ expect(entries).toEqual([
761
+ expect.objectContaining({ action: "auth.logout", actorId: id, actorEmail: "leaving@example.com", targetId: id }),
762
+ ]);
763
+ // The session is gone, so signing out again ends nothing.
764
+ expect((await owner.post("/sign-out", {})).status).toBe(200);
765
+ expect(actions("auth.logout")).toHaveLength(1);
766
+ });
767
+
768
+ test("records a sign-out of every account a browser holds", async () => {
769
+ const { auth } = startAuth();
770
+ await signUp(auth, "first@example.com");
771
+ await signUp(auth, "second@example.com");
772
+ const browser = new Browser(auth);
773
+ await browser.post("/sign-in/email", { email: "first@example.com", password: PASSWORD });
774
+ await browser.post("/sign-in/email", { email: "second@example.com", password: PASSWORD });
775
+ entries = [];
776
+ expect((await browser.post("/sign-out", {})).status).toBe(200);
777
+ expect(actions("auth.logout").map((entry) => entry.actorEmail).sort()).toEqual(["first@example.com", "second@example.com"]);
778
+ });
779
+
780
+ test("names the owner of a session an administrator revokes, and the administrator who stops impersonating", async () => {
781
+ const { auth, db } = startAuth();
782
+ const operator = await signUp(auth, "revoker@example.com");
783
+ await makeAdmin(db, operator);
784
+ const target = await signUp(auth, "revoked@example.com");
785
+ const targetId = await userId(target);
786
+ const { session } = (await (await target.get("/get-session")).json()) as { session: { token: string } };
787
+ entries = [];
788
+
789
+ expect((await operator.post("/admin/revoke-user-session", { sessionToken: session.token })).status).toBe(200);
790
+ expect(actions("user.session_revoke")).toEqual([
791
+ expect.objectContaining({ actorEmail: "revoker@example.com", targetId, targetLabel: "revoked@example.com" }),
792
+ ]);
793
+
794
+ expect((await operator.post("/admin/impersonate-user", { userId: targetId })).status).toBe(200);
795
+ expect((await operator.post("/admin/stop-impersonating", {})).status).toBe(200);
796
+ expect(actions("user.stop_impersonate")).toEqual([
797
+ expect.objectContaining({ actorEmail: "revoker@example.com", targetId, targetLabel: "revoked@example.com" }),
798
+ ]);
799
+ // Ending the impersonation session is not a sign-out.
800
+ expect(actions("auth.logout")).toEqual([]);
801
+ });
802
+
803
+ test("names the user a password reset link, email code, or text message code resets", async () => {
804
+ const { auth, mail, db } = startAuth();
805
+ await signUp(auth, "reset@example.com");
806
+ const id = db.user?.[0]?.id;
807
+ entries = [];
808
+
809
+ const anonymous = new Browser(auth);
810
+ await anonymous.post("/request-password-reset", { email: "reset@example.com" });
811
+ const wrong = await anonymous.post("/reset-password", { token: "forged", newPassword: "N3w-Str0ng!password" });
812
+ expect(wrong.status).toBe(400);
813
+ expect((await anonymous.post("/reset-password", { token: mail.reset, newPassword: "N3w-Str0ng!password" })).status).toBe(200);
814
+
815
+ await anonymous.post("/email-otp/request-password-reset", { email: "reset@example.com" });
816
+ const coded = await anonymous.post("/email-otp/reset-password", { email: "reset@example.com", otp: mail.otp, password: PASSWORD });
817
+ expect(coded.status).toBe(200);
818
+
819
+ const row = db.user?.[0];
820
+ if (row) Object.assign(row, { phoneNumber: "+15550100", phoneNumberVerified: true });
821
+ await anonymous.post("/phone-number/request-password-reset", { phoneNumber: "+15550100" });
822
+ const texted = await anonymous.post("/phone-number/reset-password", {
823
+ phoneNumber: "+15550100",
824
+ otp: mail.sms,
825
+ newPassword: "Th1rd-Str0ng!password",
826
+ });
827
+ expect(texted.status).toBe(200);
828
+
829
+ expect(actions("auth.password_reset")).toEqual([
830
+ expect.objectContaining({ actorId: id, actorEmail: "reset@example.com", targetId: id, targetLabel: "reset@example.com" }),
831
+ expect.objectContaining({ actorId: id, actorEmail: "reset@example.com", targetId: id, targetLabel: "reset@example.com" }),
832
+ expect.objectContaining({ actorId: id, actorEmail: "reset@example.com", targetId: id, targetLabel: "reset@example.com" }),
833
+ ]);
834
+ });
835
+
836
+ test("names the user an email verification link or code verifies", async () => {
837
+ const { auth, mail } = startAuth({ verificationSignIn: false });
838
+ const owner = await signUp(auth, "verify-me@example.com");
839
+ const id = await userId(owner);
840
+ await owner.post("/send-verification-email", { email: "verify-me@example.com", callbackURL: `${BASE_URL}/` });
841
+ expect((await new Browser(auth).get(mail.verification)).status).toBe(302);
842
+
843
+ const coded = await signUp(auth, "verify-code@example.com");
844
+ const codedId = await userId(coded);
845
+ const anonymous = new Browser(auth);
846
+ await anonymous.post("/email-otp/send-verification-otp", { email: "verify-code@example.com", type: "email-verification" });
847
+ expect((await anonymous.post("/email-otp/verify-email", { email: "verify-code@example.com", otp: mail.otp })).status).toBe(200);
848
+
849
+ expect(actions("auth.email_verified")).toEqual([
850
+ expect.objectContaining({ actorId: id, actorEmail: "verify-me@example.com", targetId: id, targetLabel: "verify-me@example.com" }),
851
+ expect.objectContaining({ actorId: codedId, targetId: codedId, targetLabel: "verify-code@example.com" }),
852
+ ]);
853
+ });
854
+
855
+ test("records an email change by code", async () => {
856
+ const { auth, mail } = startAuth();
857
+ const owner = await signUp(auth, "old-address@example.com");
858
+ expect((await owner.post("/email-otp/request-email-change", { newEmail: "new-address@example.com" })).status).toBe(200);
859
+ expect((await owner.post("/email-otp/change-email", { newEmail: "new-address@example.com", otp: mail.otp })).status).toBe(200);
860
+ expect(actions("auth.email_change")).toEqual([expect.objectContaining({ actorEmail: "old-address@example.com" })]);
861
+ });
862
+ });
863
+
365
864
  describe("the app's Better Auth", () => {
366
865
  test("runs the audit hook after every plugin's after hooks", () => {
367
866
  const instance = buildAuth(envAuthConfig());
@@ -370,16 +869,19 @@ describe("the app's Better Auth", () => {
370
869
  expect(instance.options.hooks?.after).toBeUndefined();
371
870
  });
372
871
 
373
- test("classifies every endpoint that can sign a user in", () => {
872
+ test("classifies every endpoint of Better Auth and PodoKit's plugins", () => {
374
873
  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),
874
+ type Endpoints = Record<string, { path?: string }> | undefined;
875
+ const pathsOf = (endpoints: Endpoints): string[] =>
876
+ Object.values(endpoints ?? {}).flatMap((endpoint) => (endpoint.path ? [endpoint.path] : []));
877
+ const plugins = instance.options.plugins as Array<{ id: string; endpoints?: Endpoints }>;
878
+ const applications = new Set(
879
+ plugins.filter((plugin) => !PODOKIT_PLUGINS.has(plugin.id)).flatMap((plugin) => pathsOf(plugin.endpoints)),
382
880
  );
383
- expect(unclassified).toEqual([]);
881
+ const paths = new Set(pathsOf(instance.api as Endpoints).filter((path) => !applications.has(path)));
882
+ expect(Object.keys(SIGN_IN_METHODS).filter((path) => !paths.has(path))).toEqual([]);
883
+ const classified = (path: string): boolean =>
884
+ SIGN_IN_METHODS[path] !== undefined || NOT_SIGN_INS.has(path) || STARTS_NO_SESSION.has(path);
885
+ expect([...paths].filter((path) => !classified(path))).toEqual([]);
384
886
  });
385
887
  });