@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.
- 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/apps/api/src/database/database.spec.ts +50 -4
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +3 -2
- package/dist/templates/fullstack/tests/README.md +11 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +28 -2
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +5 -5
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +61 -12
- package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +16 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/module.manifest.json +4 -2
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +31 -8
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.spec.ts +531 -29
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +253 -60
- 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/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +60 -15
- package/package.json +1 -1
|
@@ -1,12 +1,15 @@
|
|
|
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
|
+
import { parseSetCookieHeader } from "better-auth/cookies";
|
|
3
4
|
import { clientAddressOf } from "../core/client-address";
|
|
4
|
-
import { recordAudit } from "./audit-events";
|
|
5
|
+
import { recordAudit, type AuditEntry } from "./audit-events";
|
|
5
6
|
import { auditEnabled } from "./audit-enabled";
|
|
6
7
|
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
+
// the route pattern an endpoint was registered with, not the request URL, so
|
|
12
|
+
// every social provider's callback arrives as "/callback/:id".
|
|
10
13
|
export const SIGN_IN_METHODS: Readonly<Record<string, string>> = {
|
|
11
14
|
"/sign-in/email": "password",
|
|
12
15
|
"/sign-in/username": "username",
|
|
@@ -24,15 +27,35 @@ export const SIGN_IN_METHODS: Readonly<Record<string, string>> = {
|
|
|
24
27
|
"/email-otp/verify-email": "email-otp-verification",
|
|
25
28
|
};
|
|
26
29
|
|
|
30
|
+
// Endpoints that can end with a session the request did not come with without
|
|
31
|
+
// signing anyone in: a sign-up, recorded as auth.signup; an administrator
|
|
32
|
+
// starting or ending an impersonation; a switch to another session the browser
|
|
33
|
+
// already holds; and requests that replace the signed-in user's own session.
|
|
34
|
+
export const NOT_SIGN_INS: ReadonlySet<string> = new Set([
|
|
35
|
+
"/sign-up/email",
|
|
36
|
+
"/admin/impersonate-user",
|
|
37
|
+
"/admin/stop-impersonating",
|
|
38
|
+
"/multi-session/set-active",
|
|
39
|
+
"/multi-session/revoke",
|
|
40
|
+
"/two-factor/enable",
|
|
41
|
+
"/two-factor/disable",
|
|
42
|
+
"/change-password",
|
|
43
|
+
]);
|
|
44
|
+
|
|
27
45
|
// Other auth and admin endpoints, with the action each records once it succeeds.
|
|
46
|
+
// A new account records auth.signup from the plugin's database hook instead, so
|
|
47
|
+
// every endpoint that creates one is covered.
|
|
28
48
|
const ACTIONS: Readonly<Record<string, string>> = {
|
|
29
|
-
"/sign-up/email": "auth.signup",
|
|
30
49
|
"/sign-out": "auth.logout",
|
|
31
50
|
"/change-password": "auth.password_change",
|
|
32
51
|
"/change-email": "auth.email_change",
|
|
52
|
+
"/email-otp/change-email": "auth.email_change",
|
|
33
53
|
"/reset-password": "auth.password_reset",
|
|
54
|
+
"/email-otp/reset-password": "auth.password_reset",
|
|
55
|
+
"/phone-number/reset-password": "auth.password_reset",
|
|
34
56
|
"/send-verification-email": "auth.verification_sent",
|
|
35
57
|
"/verify-email": "auth.email_verified",
|
|
58
|
+
"/email-otp/verify-email": "auth.email_verified",
|
|
36
59
|
"/update-user": "account.update",
|
|
37
60
|
"/delete-user": "account.delete",
|
|
38
61
|
"/revoke-session": "session.revoke",
|
|
@@ -53,9 +76,68 @@ const ACTIONS: Readonly<Record<string, string>> = {
|
|
|
53
76
|
"/admin/stop-impersonating": "user.stop_impersonate",
|
|
54
77
|
};
|
|
55
78
|
|
|
79
|
+
interface AuditUser {
|
|
80
|
+
id: string;
|
|
81
|
+
name?: string | null;
|
|
82
|
+
email?: string | null;
|
|
83
|
+
}
|
|
84
|
+
|
|
56
85
|
interface SessionRecord {
|
|
57
|
-
session: { token: string };
|
|
58
|
-
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 };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
interface SignInContext {
|
|
132
|
+
path: string;
|
|
133
|
+
context: {
|
|
134
|
+
session: SessionRecord | null;
|
|
135
|
+
responseHeaders?: Headers;
|
|
136
|
+
secret: string;
|
|
137
|
+
authCookies: { sessionToken: { name: string } };
|
|
138
|
+
internalAdapter: { findSession: (token: string) => Promise<unknown> };
|
|
139
|
+
};
|
|
140
|
+
getSignedCookie: (name: string, secret: string) => Promise<string | null | false>;
|
|
59
141
|
}
|
|
60
142
|
|
|
61
143
|
// After hooks run for failed requests too, with the APIError the endpoint threw
|
|
@@ -73,19 +155,33 @@ function failed(returned: unknown): boolean {
|
|
|
73
155
|
}
|
|
74
156
|
}
|
|
75
157
|
|
|
76
|
-
// A sign-in ends with a session the request did not come with
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
158
|
+
// A sign-in ends with a session the request did not come with, on any endpoint
|
|
159
|
+
// but those in NOT_SIGN_INS.
|
|
160
|
+
// - Its response sets the session cookie to the new session. An endpoint that
|
|
161
|
+
// another one dispatches, as oauthProvider() runs /oauth2/authorize when a
|
|
162
|
+
// sign-in resumes an authorization request, inherits that request's new
|
|
163
|
+
// session without setting it.
|
|
164
|
+
// - A request that refreshes its own session, such as a session read that
|
|
165
|
+
// extends it or a profile update, sets it again: the session cookie it sent,
|
|
166
|
+
// or the one bearer() made of its Authorization header, has the same token.
|
|
167
|
+
// - Confirming two-factor replaces the signed-in user's session, which the
|
|
168
|
+
// endpoint loaded into ctx.context.session.
|
|
169
|
+
// - When the session the endpoint loaded is the new one, a stored session is a
|
|
170
|
+
// sign-in: oauthProvider() loads it when a sign-in resumes an authorization
|
|
171
|
+
// request. The session of an API key request (apiKey's
|
|
172
|
+
// enableSessionForAPIKeys) has the key as its token and is not stored.
|
|
173
|
+
async function signedIn(ctx: SignInContext, created: SessionRecord): Promise<boolean> {
|
|
174
|
+
if (NOT_SIGN_INS.has(ctx.path)) return false;
|
|
175
|
+
const { token } = created.session;
|
|
176
|
+
const set = parseSetCookieHeader(ctx.context.responseHeaders?.get("set-cookie") ?? "");
|
|
177
|
+
const cookie = set.get(ctx.context.authCookies.sessionToken.name)?.value;
|
|
178
|
+
if (decodeURIComponent(cookie ?? "").split(".")[0] !== token) return false;
|
|
179
|
+
const presented = await ctx.getSignedCookie(ctx.context.authCookies.sessionToken.name, ctx.context.secret);
|
|
180
|
+
if (token === presented) return false;
|
|
181
|
+
const current = ctx.context.session;
|
|
182
|
+
if (!current) return true;
|
|
183
|
+
if (current.session.token !== token) return current.user.id !== created.user.id;
|
|
184
|
+
return Boolean(await ctx.context.internalAdapter.findSession(token));
|
|
89
185
|
}
|
|
90
186
|
|
|
91
187
|
export interface AuditPluginOptions {
|
|
@@ -93,70 +189,167 @@ export interface AuditPluginOptions {
|
|
|
93
189
|
enabled?: () => Promise<boolean>;
|
|
94
190
|
}
|
|
95
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
|
+
|
|
96
201
|
// Records auth and admin actions through the application audit pipeline. Pushed
|
|
97
202
|
// after every other plugin, because after hooks run in plugin order and earlier
|
|
98
203
|
// ones change the outcome: two-factor turns a password sign-in into a pending
|
|
99
|
-
// challenge by clearing ctx.context.newSession.
|
|
204
|
+
// challenge by clearing ctx.context.newSession. It runs for every endpoint, the
|
|
205
|
+
// server-side session lookup of each API request included, so a request with
|
|
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.
|
|
100
213
|
export function auditPlugin(options: AuditPluginOptions = {}): BetterAuthPlugin & { id: "podokit-audit" } {
|
|
101
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
|
+
|
|
102
252
|
return {
|
|
103
253
|
id: "podokit-audit",
|
|
254
|
+
init: () => ({ options: { databaseHooks } }),
|
|
104
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
|
+
],
|
|
105
273
|
after: [
|
|
106
274
|
{
|
|
107
|
-
matcher: (context) => Boolean(context.path
|
|
275
|
+
matcher: (context) => Boolean(context.path),
|
|
108
276
|
handler: createAuthMiddleware(async (ctx) => {
|
|
277
|
+
const action = ACTIONS[ctx.path];
|
|
278
|
+
const created = ctx.context.newSession;
|
|
279
|
+
if (!action && !created) return;
|
|
109
280
|
if (failed(ctx.context.returned)) return;
|
|
281
|
+
const login = created && (await signedIn(ctx, created)) ? created : null;
|
|
282
|
+
if (!action && !login) return;
|
|
110
283
|
// AuditService checks the toggle again on write; checking here as well
|
|
111
284
|
// skips the lookups below when auditing is off.
|
|
112
285
|
if (!(await enabled())) return;
|
|
113
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;
|
|
114
298
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
+
}
|
|
119
336
|
|
|
120
337
|
// Prefer a human-readable target: the email in the body, otherwise
|
|
121
338
|
// the target user's email by id (the id for already-deleted users).
|
|
122
|
-
const targetId = body.userId ?? null;
|
|
123
|
-
let targetLabel = body.email ?? null;
|
|
124
|
-
if (!targetLabel && targetId)
|
|
125
|
-
|
|
126
|
-
const target = await ctx.context.internalAdapter.findUserById(targetId);
|
|
127
|
-
targetLabel = target?.email ?? targetId;
|
|
128
|
-
} catch {
|
|
129
|
-
targetLabel = targetId;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
await recordAudit({
|
|
133
|
-
action,
|
|
134
|
-
actorId: actor?.id ?? null,
|
|
135
|
-
actorName: actor?.name ?? null,
|
|
136
|
-
actorEmail: actor?.email ?? null,
|
|
137
|
-
targetId,
|
|
138
|
-
targetLabel,
|
|
139
|
-
ip,
|
|
140
|
-
});
|
|
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 });
|
|
141
343
|
}
|
|
142
344
|
|
|
143
|
-
|
|
144
|
-
if (!method) return;
|
|
145
|
-
const created = ctx.context.newSession;
|
|
146
|
-
const presented = await ctx.getSignedCookie(ctx.context.authCookies.sessionToken.name, ctx.context.secret);
|
|
147
|
-
if (!signedIn(created, ctx.context.session, presented)) return;
|
|
148
|
-
const params = (ctx.params ?? {}) as { id?: string };
|
|
149
|
-
const body = (ctx.body ?? {}) as { provider?: string };
|
|
150
|
-
const provider = method === "social" ? (params.id ?? body.provider) : undefined;
|
|
345
|
+
if (!login) return;
|
|
151
346
|
await recordAudit({
|
|
152
347
|
action: "auth.login",
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
targetId: created.user.id,
|
|
157
|
-
targetLabel: created.user.email ?? null,
|
|
348
|
+
...who(login.user),
|
|
349
|
+
targetId: login.user.id,
|
|
350
|
+
targetLabel: login.user.email ?? null,
|
|
158
351
|
ip,
|
|
159
|
-
metadata:
|
|
352
|
+
metadata: methodOf(ctx),
|
|
160
353
|
});
|
|
161
354
|
}),
|
|
162
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
|
+
});
|