@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.
- package/dist/deploy-checks.d.ts +20 -2
- package/dist/deploy-checks.js +83 -4
- package/dist/deploy-compose-render.js +15 -4
- package/dist/deploy-compose.js +211 -27
- package/dist/deploy.js +16 -0
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +3 -2
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +26 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +16 -4
- package/dist/templates/modules/admin-dashboard/module.manifest.json +4 -2
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +14 -1
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.spec.ts +284 -6
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +184 -38
- package/dist/templates/modules/auth/files/apps/api/src/auth/admin-emails.spec.ts +232 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/admin-emails.ts +68 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +53 -47
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/SKILL.md +1 -1
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +9 -2
- package/package.json +1 -1
|
@@ -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
|
|
9
|
-
// sign-in through any other endpoint, such as one of a plugin the
|
|
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:
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
+
}
|