@podosoft/podokit 1.6.3 → 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.
@@ -1,13 +1,13 @@
1
- import type { BetterAuthPlugin } from "better-auth";
1
+ import type { BetterAuthOptions, BetterAuthPlugin } from "better-auth";
2
2
  import { createAuthMiddleware, isAPIError } from "better-auth/api";
3
3
  import { parseSetCookieHeader } from "better-auth/cookies";
4
4
  import { clientAddressOf } from "../core/client-address";
5
- import { recordAudit } from "./audit-events";
5
+ import { recordAudit, type AuditEntry } from "./audit-events";
6
6
  import { auditEnabled } from "./audit-enabled";
7
7
 
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
8
+ // The method that auth.login and auth.signup record for Better Auth's sign-in
9
+ // endpoints. A sign-in through any other endpoint, such as one of a plugin the
10
+ // application adds, is recorded too, with the endpoint's path as its method. ctx.path is
11
11
  // the route pattern an endpoint was registered with, not the request URL, so
12
12
  // every social provider's callback arrives as "/callback/:id".
13
13
  export const SIGN_IN_METHODS: Readonly<Record<string, string>> = {
@@ -43,14 +43,19 @@ export const NOT_SIGN_INS: ReadonlySet<string> = new Set([
43
43
  ]);
44
44
 
45
45
  // Other auth and admin endpoints, with the action each records once it succeeds.
46
+ // A new account records auth.signup from the plugin's database hook instead, so
47
+ // every endpoint that creates one is covered.
46
48
  const ACTIONS: Readonly<Record<string, string>> = {
47
- "/sign-up/email": "auth.signup",
48
49
  "/sign-out": "auth.logout",
49
50
  "/change-password": "auth.password_change",
50
51
  "/change-email": "auth.email_change",
52
+ "/email-otp/change-email": "auth.email_change",
51
53
  "/reset-password": "auth.password_reset",
54
+ "/email-otp/reset-password": "auth.password_reset",
55
+ "/phone-number/reset-password": "auth.password_reset",
52
56
  "/send-verification-email": "auth.verification_sent",
53
57
  "/verify-email": "auth.email_verified",
58
+ "/email-otp/verify-email": "auth.email_verified",
54
59
  "/update-user": "account.update",
55
60
  "/delete-user": "account.delete",
56
61
  "/revoke-session": "session.revoke",
@@ -71,9 +76,56 @@ const ACTIONS: Readonly<Record<string, string>> = {
71
76
  "/admin/stop-impersonating": "user.stop_impersonate",
72
77
  };
73
78
 
79
+ interface AuditUser {
80
+ id: string;
81
+ name?: string | null;
82
+ email?: string | null;
83
+ }
84
+
74
85
  interface SessionRecord {
75
- session: { token: string };
76
- user: { id: string; name?: string | null; email?: string | null };
86
+ session: { token: string; impersonatedBy?: string | null };
87
+ user: AuditUser;
88
+ }
89
+
90
+ // What the plugin's database hooks saw one request do. Better Auth copies its
91
+ // context for every endpoint call and hands that copy, ctx.context, to the
92
+ // endpoint, to the database hooks the endpoint triggers, and to its own hooks,
93
+ // so the copy identifies the request.
94
+ interface Observed {
95
+ /** The user /admin/create-user created. */
96
+ created?: AuditUser;
97
+ /** The user the request last updated, such as the one an email link verified. */
98
+ updated?: AuditUser;
99
+ /** The user whose password-reset link the request presented. */
100
+ resetUserId?: string;
101
+ /** The owners of the sessions the request ended, in order. */
102
+ endedSessions: string[];
103
+ }
104
+
105
+ const observations = new WeakMap<object, Observed>();
106
+
107
+ function observe(context: object): Observed {
108
+ let observed = observations.get(context);
109
+ if (!observed) {
110
+ observed = { endedSessions: [] };
111
+ observations.set(context, observed);
112
+ }
113
+ return observed;
114
+ }
115
+
116
+ interface RequestShape {
117
+ path?: string;
118
+ params?: unknown;
119
+ body?: unknown;
120
+ }
121
+
122
+ // How a request signed someone in or up: the name in SIGN_IN_METHODS, otherwise
123
+ // the endpoint's path, and the provider of a social sign-in.
124
+ function methodOf({ path = "", params, body }: RequestShape): Record<string, string> {
125
+ const method = path === "/sign-up/email" ? "password" : (SIGN_IN_METHODS[path] ?? path);
126
+ if (method !== "social") return { method };
127
+ const provider = (params as { id?: string } | undefined)?.id ?? (body as { provider?: string } | undefined)?.provider;
128
+ return provider ? { method, provider } : { method };
77
129
  }
78
130
 
79
131
  interface SignInContext {
@@ -137,17 +189,87 @@ export interface AuditPluginOptions {
137
189
  enabled?: () => Promise<boolean>;
138
190
  }
139
191
 
192
+ type DatabaseHooks = NonNullable<BetterAuthOptions["databaseHooks"]>;
193
+ type HookContext = Parameters<NonNullable<NonNullable<NonNullable<DatabaseHooks["user"]>["create"]>["after"]>>[1];
194
+
195
+ const who = (user: AuditUser | null | undefined): Pick<AuditEntry, "actorId" | "actorName" | "actorEmail"> => ({
196
+ actorId: user?.id ?? null,
197
+ actorName: user?.name ?? null,
198
+ actorEmail: user?.email ?? null,
199
+ });
200
+
140
201
  // Records auth and admin actions through the application audit pipeline. Pushed
141
202
  // after every other plugin, because after hooks run in plugin order and earlier
142
203
  // ones change the outcome: two-factor turns a password sign-in into a pending
143
204
  // challenge by clearing ctx.context.newSession. It runs for every endpoint, the
144
205
  // server-side session lookup of each API request included, so a request with
145
206
  // neither an action nor a new session returns before any other work.
207
+ //
208
+ // Its database hooks see what a request did to users and sessions, so entries
209
+ // name the user even when the request carries no session: a new account is
210
+ // recorded when it is stored, whichever endpoint creates it and even when the
211
+ // request then fails (a social sign-up that waits for approval gets no
212
+ // session), and a sign-out names the owner of the session it ended.
146
213
  export function auditPlugin(options: AuditPluginOptions = {}): BetterAuthPlugin & { id: "podokit-audit" } {
147
214
  const enabled = options.enabled ?? auditEnabled;
215
+
216
+ async function recordSignUp(user: AuditUser, context: HookContext): Promise<void> {
217
+ if (!(await enabled())) return;
218
+ if (context?.path === "/admin/create-user") {
219
+ // Recorded as user.create by the request's after hook.
220
+ observe(context.context).created = user;
221
+ return;
222
+ }
223
+ await recordAudit({
224
+ action: "auth.signup",
225
+ ...(context ? who(user) : {}),
226
+ targetId: user.id,
227
+ targetLabel: user.email ?? null,
228
+ ip: clientAddressOf(context?.headers) ?? null,
229
+ ...(context ? { metadata: methodOf(context) } : {}),
230
+ });
231
+ }
232
+
233
+ const databaseHooks: DatabaseHooks = {
234
+ user: {
235
+ create: { after: async (user, context) => recordSignUp(user, context) },
236
+ update: {
237
+ after: async (user, context) => {
238
+ if (context) observe(context.context).updated = user;
239
+ },
240
+ },
241
+ },
242
+ session: {
243
+ delete: {
244
+ // deleteWithHooks reads the row before it deletes it, so this costs no query.
245
+ before: async (session, context) => {
246
+ if (context) observe(context.context).endedSessions.push(session.userId);
247
+ },
248
+ },
249
+ },
250
+ };
251
+
148
252
  return {
149
253
  id: "podokit-audit",
254
+ init: () => ({ options: { databaseHooks } }),
150
255
  hooks: {
256
+ before: [
257
+ {
258
+ // The endpoint consumes the link's token, so find its user first.
259
+ matcher: (context) => context.path === "/reset-password",
260
+ handler: createAuthMiddleware(async (ctx) => {
261
+ const token =
262
+ (ctx.body as { token?: string } | undefined)?.token ?? (ctx.query as { token?: string } | undefined)?.token;
263
+ if (!token || !(await enabled())) return;
264
+ try {
265
+ const verification = await ctx.context.internalAdapter.findVerificationValue(`reset-password:${token}`);
266
+ if (verification?.value) observe(ctx.context).resetUserId = verification.value;
267
+ } catch {
268
+ // The entry then names no account; the reset itself goes ahead.
269
+ }
270
+ }),
271
+ },
272
+ ],
151
273
  after: [
152
274
  {
153
275
  matcher: (context) => Boolean(context.path),
@@ -162,48 +284,72 @@ export function auditPlugin(options: AuditPluginOptions = {}): BetterAuthPlugin
162
284
  // skips the lookups below when auditing is off.
163
285
  if (!(await enabled())) return;
164
286
  const ip = clientAddressOf(ctx.headers) ?? null;
287
+ const seen = observations.get(ctx.context);
288
+ // A failed lookup leaves the entry without that user; it never fails the request.
289
+ const quietly = async (find: () => Promise<AuditUser | null | undefined>): Promise<AuditUser | null> => {
290
+ try {
291
+ return (await find()) ?? null;
292
+ } catch {
293
+ return null;
294
+ }
295
+ };
296
+ const userById = async (id: string | null | undefined): Promise<AuditUser | null> =>
297
+ id ? quietly(() => ctx.context.internalAdapter.findUserById(id)) : null;
165
298
 
166
- if (action) {
167
- const actor = ctx.context.session?.user ?? ctx.context.newSession?.user;
168
- const body = (ctx.body ?? {}) as { email?: string; userId?: string };
299
+ if (action === "auth.logout") {
300
+ // One entry per account whose session ended: multiSession() signs
301
+ // out every account of the browser. A sign-out without a session
302
+ // ends none and records nothing.
303
+ for (const id of new Set(seen?.endedSessions)) {
304
+ const user = (await userById(id)) ?? { id };
305
+ await recordAudit({ action, ...who(user), targetId: id, targetLabel: user.email ?? null, ip });
306
+ }
307
+ } else if (action) {
308
+ const session = ctx.context.session;
309
+ const body = (ctx.body ?? {}) as { email?: string; userId?: string; phoneNumber?: string };
310
+ let actor: AuditUser | null = session?.user ?? ctx.context.newSession?.user ?? null;
311
+ // The user the request acted on when its body does not name one.
312
+ let subject: AuditUser | null = null;
313
+ if (action === "user.create") subject = seen?.created ?? null;
314
+ else if (action === "user.session_revoke") subject = await userById(seen?.endedSessions[0]);
315
+ else if (action === "user.stop_impersonate" && session?.session.impersonatedBy) {
316
+ // The session is the impersonated user's; the administrator ends it.
317
+ subject = session.user;
318
+ actor = (await userById(session.session.impersonatedBy)) ?? actor;
319
+ } else if (action === "auth.email_verified") {
320
+ subject = seen?.updated ?? null;
321
+ actor ??= subject;
322
+ } else if (action === "auth.password_reset") {
323
+ // Whoever holds the link or code acts as the account's owner.
324
+ if (ctx.path === "/reset-password") subject = await userById(seen?.resetUserId);
325
+ else if (ctx.path === "/email-otp/reset-password" && body.email) {
326
+ const email = body.email;
327
+ subject = await quietly(async () => (await ctx.context.internalAdapter.findUserByEmail(email))?.user);
328
+ } else if (ctx.path === "/phone-number/reset-password" && body.phoneNumber) {
329
+ const phoneNumber = body.phoneNumber;
330
+ subject = await quietly(() =>
331
+ ctx.context.adapter.findOne<AuditUser>({ model: "user", where: [{ field: "phoneNumber", value: phoneNumber }] }),
332
+ );
333
+ }
334
+ actor ??= subject;
335
+ }
169
336
 
170
337
  // Prefer a human-readable target: the email in the body, otherwise
171
338
  // the target user's email by id (the id for already-deleted users).
172
- const targetId = body.userId ?? null;
173
- let targetLabel = body.email ?? null;
174
- if (!targetLabel && targetId) {
175
- try {
176
- const target = await ctx.context.internalAdapter.findUserById(targetId);
177
- targetLabel = target?.email ?? targetId;
178
- } catch {
179
- targetLabel = targetId;
180
- }
181
- }
182
- await recordAudit({
183
- action,
184
- actorId: actor?.id ?? null,
185
- actorName: actor?.name ?? null,
186
- actorEmail: actor?.email ?? null,
187
- targetId,
188
- targetLabel,
189
- ip,
190
- });
339
+ const targetId = body.userId ?? subject?.id ?? null;
340
+ let targetLabel = body.email ?? subject?.email ?? body.phoneNumber ?? null;
341
+ if (!targetLabel && targetId) targetLabel = (await userById(targetId))?.email ?? targetId;
342
+ await recordAudit({ action, ...who(actor), targetId, targetLabel, ip });
191
343
  }
192
344
 
193
345
  if (!login) return;
194
- const method = SIGN_IN_METHODS[ctx.path] ?? ctx.path;
195
- const params = (ctx.params ?? {}) as { id?: string };
196
- const body = (ctx.body ?? {}) as { provider?: string };
197
- const provider = method === "social" ? (params.id ?? body.provider) : undefined;
198
346
  await recordAudit({
199
347
  action: "auth.login",
200
- actorId: login.user.id,
201
- actorName: login.user.name ?? null,
202
- actorEmail: login.user.email ?? null,
348
+ ...who(login.user),
203
349
  targetId: login.user.id,
204
350
  targetLabel: login.user.email ?? null,
205
351
  ip,
206
- metadata: provider ? { method, provider } : { method },
352
+ metadata: methodOf(ctx),
207
353
  });
208
354
  }),
209
355
  },
@@ -0,0 +1,232 @@
1
+ import { describe, expect, spyOn, test } from "bun:test";
2
+ import { join } from "node:path";
3
+ import { betterAuth } from "better-auth";
4
+ import { memoryAdapter } from "better-auth/adapters/memory";
5
+ import { getAuthTables } from "better-auth/db";
6
+ import { admin, magicLink } from "better-auth/plugins";
7
+ import { adminEmailProblem, adminEmailsFrom, becomesAdministrator } from "./admin-emails";
8
+ import { databaseHooksFor } from "./auth";
9
+ import { PUBLIC_SIGNUP_DISABLED } from "./feature-gate";
10
+
11
+ const BASE_URL = "http://localhost:5002";
12
+ const PASSWORD = "Podokit3e-Str0ng!pw";
13
+ const ADMIN_EMAIL = "owner@podokit.dev";
14
+
15
+ describe("ADMIN_EMAILS", () => {
16
+ test("keeps placeholders outside production and refuses them in production", () => {
17
+ expect([...adminEmailsFrom(" Admin@Example.com, ,ops@podokit.dev ", false)]).toEqual([
18
+ "admin@example.com",
19
+ "ops@podokit.dev",
20
+ ]);
21
+ expect([...adminEmailsFrom("owner@podokit.dev,ops@mail.podokit.dev", true)]).toHaveLength(2);
22
+ expect(adminEmailsFrom(undefined, true).size).toBe(0);
23
+ expect(adminEmailsFrom("", true).size).toBe(0);
24
+ for (const entry of [
25
+ "admin@example.com",
26
+ "a@mail.example.org",
27
+ "a@example.net",
28
+ "a@host.test",
29
+ "a@host.example",
30
+ "a@host.invalid",
31
+ "a@localhost",
32
+ "admin",
33
+ "a@b",
34
+ ]) {
35
+ expect(() => adminEmailsFrom(`owner@podokit.dev,${entry}`, true)).toThrow(`"${entry}"`);
36
+ }
37
+ });
38
+
39
+ test("stops a production API whose ADMIN_EMAILS holds a placeholder", () => {
40
+ const load = (adminEmails: string): { exitCode: number; stderr: string } => {
41
+ const result = Bun.spawnSync(
42
+ [process.execPath, "--eval", `await import(${JSON.stringify(join(__dirname, "auth.ts"))}); process.exit(0);`],
43
+ {
44
+ env: {
45
+ PATH: process.env.PATH ?? "",
46
+ NODE_ENV: "production",
47
+ ADMIN_EMAILS: adminEmails,
48
+ BETTER_AUTH_SECRET: "admin-emails-production-secret-0123456789",
49
+ BETTER_AUTH_URL: "https://app.podokit.dev",
50
+ },
51
+ stderr: "pipe",
52
+ },
53
+ );
54
+ return { exitCode: result.exitCode, stderr: result.stderr.toString() };
55
+ };
56
+ const placeholder = load("admin@example.com");
57
+ expect(placeholder.exitCode).not.toBe(0);
58
+ expect(placeholder.stderr).toContain('ADMIN_EMAILS must list real administrator addresses in production');
59
+ expect(load("owner@podokit.dev").exitCode).toBe(0);
60
+ });
61
+
62
+ test("names why an entry is no administrator's address", () => {
63
+ expect(adminEmailProblem("admin@example.com")).toBe("a placeholder on a reserved domain");
64
+ expect(adminEmailProblem("someone@example.company")).toBeNull();
65
+ expect(adminEmailProblem("admin")).toBe("not an email address");
66
+ expect(adminEmailProblem("owner@podokit.dev")).toBeNull();
67
+ });
68
+
69
+ test("promotes a listed address only once it is proven", () => {
70
+ const listed = new Set([ADMIN_EMAIL]);
71
+ expect(becomesAdministrator({ email: "Owner@Podokit.dev", emailVerified: true }, false, listed)).toBe(true);
72
+ expect(becomesAdministrator({ email: ADMIN_EMAIL, emailVerified: false }, true, listed)).toBe(true);
73
+ expect(becomesAdministrator({ email: "else@podokit.dev", emailVerified: false }, false, listed)).toBe(false);
74
+ expect(() => becomesAdministrator({ email: ADMIN_EMAIL, emailVerified: false }, false, listed)).toThrow(
75
+ "cannot be registered",
76
+ );
77
+ });
78
+ });
79
+
80
+ interface StartOptions {
81
+ requireSignupApproval?: boolean;
82
+ googleEmailVerified?: boolean;
83
+ }
84
+
85
+ // The account rules of the generated auth.ts on an in-memory database, with
86
+ // sign-up open and ADMIN_EMAILS naming ADMIN_EMAIL.
87
+ function startAuth({ requireSignupApproval = false, googleEmailVerified = true }: StartOptions = {}) {
88
+ const mail = { magicLink: "" };
89
+ const db: Record<string, Record<string, unknown>[]> = {};
90
+ const options = {
91
+ baseURL: BASE_URL,
92
+ secret: "admin-emails-test-secret-0123456789abcdef",
93
+ database: memoryAdapter(db),
94
+ emailAndPassword: { enabled: true, autoSignIn: !requireSignupApproval },
95
+ user: { additionalFields: { signupApproved: { type: "boolean" as const, required: false, defaultValue: true, input: false } } },
96
+ socialProviders: {
97
+ google: {
98
+ clientId: "client",
99
+ clientSecret: "secret",
100
+ getUserInfo: async () => ({
101
+ user: { email: ADMIN_EMAIL, name: "Owner", emailVerified: googleEmailVerified },
102
+ data: {
103
+ sub: "google-1",
104
+ email: ADMIN_EMAIL,
105
+ email_verified: googleEmailVerified,
106
+ name: "Owner",
107
+ given_name: "Owner",
108
+ family_name: "Podokit",
109
+ picture: "https://podokit.dev/owner.png",
110
+ aud: "client",
111
+ azp: "client",
112
+ iss: "https://accounts.google.com",
113
+ iat: 0,
114
+ exp: 0,
115
+ },
116
+ }),
117
+ },
118
+ },
119
+ plugins: [
120
+ magicLink({
121
+ sendMagicLink: async ({ url }) => {
122
+ mail.magicLink = url;
123
+ },
124
+ }),
125
+ admin(),
126
+ ],
127
+ databaseHooks: databaseHooksFor({ requireSignupApproval }, new Set([ADMIN_EMAIL]), async () => true),
128
+ };
129
+ for (const table of Object.values(getAuthTables(options))) db[table.modelName] = [];
130
+ const auth = betterAuth(options);
131
+ const users = (): Array<{ email: string; role?: string; signupApproved?: boolean; emailVerified?: boolean }> =>
132
+ (db.user ?? []) as Array<{ email: string; role?: string; signupApproved?: boolean; emailVerified?: boolean }>;
133
+ const request = (path: string, init: { method?: string; body?: unknown; cookie?: string } = {}): Promise<Response> =>
134
+ auth.handler(
135
+ new Request(path.startsWith("http") ? path : `${BASE_URL}/api/auth${path}`, {
136
+ method: init.method ?? (init.body === undefined ? "GET" : "POST"),
137
+ headers: {
138
+ origin: BASE_URL,
139
+ ...(init.body === undefined ? {} : { "content-type": "application/json" }),
140
+ ...(init.cookie ? { cookie: init.cookie } : {}),
141
+ },
142
+ body: init.body === undefined ? undefined : JSON.stringify(init.body),
143
+ redirect: "manual",
144
+ }),
145
+ );
146
+ return { auth, mail, users, request };
147
+ }
148
+
149
+ const cookiesOf = (response: Response): string =>
150
+ response.headers
151
+ .getSetCookie()
152
+ .map((cookie) => cookie.split(";")[0])
153
+ .join("; ");
154
+
155
+ // Signs in with Google, answering the provider's token request.
156
+ async function googleSignIn(started: ReturnType<typeof startAuth>): Promise<Response> {
157
+ const realFetch = globalThis.fetch;
158
+ const tokenEndpoint = spyOn(globalThis, "fetch").mockImplementation(((input: RequestInfo | URL, init?: RequestInit) => {
159
+ const url = input instanceof Request ? input.url : String(input);
160
+ if (url.startsWith("https://oauth2.googleapis.com/token")) {
161
+ return Promise.resolve(Response.json({ access_token: "access", expires_in: 3600, token_type: "Bearer" }));
162
+ }
163
+ return realFetch(input, init);
164
+ }) as typeof fetch);
165
+ try {
166
+ const begin = await started.request("/sign-in/social", { body: { provider: "google", callbackURL: `${BASE_URL}/` } });
167
+ const state = new URL(((await begin.json()) as { url: string }).url).searchParams.get("state") ?? "";
168
+ return await started.request(`/callback/google?code=code&state=${encodeURIComponent(state)}`, {
169
+ cookie: cookiesOf(begin),
170
+ });
171
+ } finally {
172
+ tokenEndpoint.mockRestore();
173
+ }
174
+ }
175
+
176
+ describe("accounts with an ADMIN_EMAILS address", () => {
177
+ test("refuses an unverified email sign-up instead of making an administrator", async () => {
178
+ const open = startAuth();
179
+ const refused = await open.request("/sign-up/email", { body: { email: ADMIN_EMAIL, password: PASSWORD, name: "Claim" } });
180
+ expect(refused.status).toBe(403);
181
+ expect(((await refused.json()) as { code?: string }).code).toBe(PUBLIC_SIGNUP_DISABLED);
182
+ expect(open.users()).toEqual([]);
183
+
184
+ // With sign-up approval on, Better Auth answers a refused sign-up like any
185
+ // other, so the response does not reveal the address; still no account.
186
+ const pending = startAuth({ requireSignupApproval: true });
187
+ const hidden = await pending.request("/sign-up/email", { body: { email: ADMIN_EMAIL, password: PASSWORD, name: "Claim" } });
188
+ expect(hidden.status).toBe(200);
189
+ expect(pending.users()).toEqual([]);
190
+ const signIn = await pending.request("/sign-in/email", { body: { email: ADMIN_EMAIL, password: PASSWORD } });
191
+ expect(signIn.status).toBe(401);
192
+
193
+ // Other addresses sign up as before: pending under the approval policy.
194
+ expect((await pending.request("/sign-up/email", { body: { email: "member@podokit.dev", password: PASSWORD, name: "M" } })).status).toBe(200);
195
+ expect(pending.users()).toEqual([expect.objectContaining({ email: "member@podokit.dev", role: "user", signupApproved: false })]);
196
+ });
197
+
198
+ test("makes an approved administrator of a social sign-in with a verified address", async () => {
199
+ const started = startAuth({ requireSignupApproval: true });
200
+ const callback = await googleSignIn(started);
201
+ expect(callback.status).toBe(302);
202
+ expect(new URL(callback.headers.get("location") ?? "", BASE_URL).searchParams.get("error")).toBeNull();
203
+ expect(started.users()).toEqual([
204
+ expect.objectContaining({ email: ADMIN_EMAIL, role: "admin", signupApproved: true, emailVerified: true }),
205
+ ]);
206
+
207
+ const unverified = startAuth({ requireSignupApproval: true, googleEmailVerified: false });
208
+ const refused = await googleSignIn(unverified);
209
+ expect(refused.status).toBe(302);
210
+ expect(new URL(refused.headers.get("location") ?? "", BASE_URL).searchParams.get("error")).toBeTruthy();
211
+ expect(unverified.users()).toEqual([]);
212
+ });
213
+
214
+ test("makes an administrator of a magic link sign-in, which proves the address", async () => {
215
+ const started = startAuth({ requireSignupApproval: true });
216
+ await started.request("/sign-in/magic-link", { body: { email: ADMIN_EMAIL, callbackURL: `${BASE_URL}/` } });
217
+ expect((await started.request(started.mail.magicLink)).status).toBe(302);
218
+ expect(started.users()).toEqual([expect.objectContaining({ email: ADMIN_EMAIL, role: "admin", signupApproved: true })]);
219
+ });
220
+
221
+ test("creates the administrator that admin:bootstrap asks for", async () => {
222
+ const started = startAuth({ requireSignupApproval: true });
223
+ // scripts/bootstrap-admin.mjs creates the account through this call.
224
+ await started.auth.api.createUser({
225
+ body: { email: ADMIN_EMAIL, password: PASSWORD, name: "Administrator", role: "admin", data: { emailVerified: true, signupApproved: true } },
226
+ });
227
+ expect(started.users()).toEqual([
228
+ expect.objectContaining({ email: ADMIN_EMAIL, role: "admin", signupApproved: true, emailVerified: true }),
229
+ ]);
230
+ expect((await started.request("/sign-in/email", { body: { email: ADMIN_EMAIL, password: PASSWORD } })).status).toBe(200);
231
+ });
232
+ });
@@ -0,0 +1,68 @@
1
+ import { APIError } from "better-auth/api";
2
+ import { PUBLIC_SIGNUP_DISABLED } from "./feature-gate";
3
+
4
+ // Domains reserved for documentation and testing (RFC 2606, RFC 6761). They
5
+ // receive no mail, so an address on one, such as admin@example.com, is a
6
+ // placeholder that nobody can prove.
7
+ const RESERVED_DOMAINS = ["example.com", "example.net", "example.org"];
8
+ const RESERVED_TOP_LEVEL = new Set(["example", "test", "invalid", "localhost"]);
9
+
10
+ /** Why an ADMIN_EMAILS entry cannot name a real administrator, or null when it can. */
11
+ export function adminEmailProblem(entry: string): string | null {
12
+ const domain = entry.slice(entry.lastIndexOf("@") + 1);
13
+ if (
14
+ RESERVED_DOMAINS.some((name) => domain === name || domain.endsWith(`.${name}`)) ||
15
+ RESERVED_TOP_LEVEL.has(domain.slice(domain.lastIndexOf(".") + 1))
16
+ ) {
17
+ return "a placeholder on a reserved domain";
18
+ }
19
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(entry) ? null : "not an email address";
20
+ }
21
+
22
+ /**
23
+ * The administrator addresses in ADMIN_EMAILS, lowercased. In production an
24
+ * entry that is not an address, or a placeholder such as admin@example.com,
25
+ * stops the API: a placeholder means the administrators were never configured.
26
+ */
27
+ export function adminEmailsFrom(
28
+ value: string | undefined,
29
+ production = process.env.NODE_ENV === "production",
30
+ ): ReadonlySet<string> {
31
+ const entries = (value ?? "")
32
+ .split(",")
33
+ .map((email) => email.trim().toLowerCase())
34
+ .filter(Boolean);
35
+ if (production) {
36
+ const problems = entries.flatMap((entry) => {
37
+ const problem = adminEmailProblem(entry);
38
+ return problem ? [`"${entry}" is ${problem}`] : [];
39
+ });
40
+ if (problems.length > 0) {
41
+ throw new Error(
42
+ `ADMIN_EMAILS must list real administrator addresses in production, or stay empty: ${problems.join("; ")}.`,
43
+ );
44
+ }
45
+ }
46
+ return new Set(entries);
47
+ }
48
+
49
+ /**
50
+ * Whether a new account becomes an administrator through ADMIN_EMAILS. Only an
51
+ * account that has proven its address does: one created with a verified email
52
+ * (a social provider's verified address, a magic link or email code, or
53
+ * admin:bootstrap) or one an administrator creates. Any other account with a
54
+ * listed address is refused, so nobody can register an administrator's address
55
+ * before its owner and take the role, the approval, or the address itself.
56
+ */
57
+ export function becomesAdministrator(
58
+ user: { email: string; emailVerified?: boolean | null },
59
+ createdByAdmin: boolean,
60
+ administrators: ReadonlySet<string>,
61
+ ): boolean {
62
+ if (!administrators.has(user.email.toLowerCase())) return false;
63
+ if (user.emailVerified === true || createdByAdmin) return true;
64
+ throw APIError.from("FORBIDDEN", {
65
+ code: PUBLIC_SIGNUP_DISABLED,
66
+ message: "This email address cannot be registered by sign-up.",
67
+ });
68
+ }