@podosoft/podokit 1.6.0 → 1.6.2
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 +16 -5
- package/dist/deploy-checks.js +17 -9
- package/dist/deploy-render.js +28 -3
- package/dist/deploy-schema.d.ts +7 -3
- package/dist/deploy-schema.js +16 -5
- package/dist/deploy.d.ts +9 -0
- package/dist/deploy.js +83 -17
- package/dist/index.js +9 -2
- package/dist/templates/fullstack/AGENTS.md +3 -1
- package/dist/templates/fullstack/apps/api/package.json +1 -1
- package/dist/templates/fullstack/apps/api/scripts/verify-api-contract.ts +2 -1
- package/dist/templates/fullstack/apps/api/src/build.spec.ts +5 -2
- package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +43 -4
- package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +39 -4
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +1 -1
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +5 -2
- package/dist/templates/fullstack/tests/README.md +20 -10
- package/dist/templates/fullstack/tests/helpers/paths.ts +13 -2
- package/dist/templates/fullstack/tests/helpers/seeded-admin.ts +15 -0
- package/dist/templates/fullstack/tests/package.json +3 -3
- package/dist/templates/fullstack/tests/playwright.config.ts +8 -6
- package/dist/templates/fullstack/tests/playwright.extensions.ts +73 -12
- package/dist/templates/fullstack/tests/run-e2e.ts +28 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.test.ts +17 -3
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/theme-toggle.svelte +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +96 -7
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/hydration.ts +13 -4
- package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +8 -6
- package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +16 -10
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +14 -2
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.spec.ts +385 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +132 -43
- package/dist/templates/modules/audit-log/module.manifest.json +2 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +3 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +13 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/empty-lists.spec.ts +67 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/empty-lists.ts +54 -0
- package/dist/templates/modules/auth/module.manifest.json +1 -0
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +50 -0
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +23 -3
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +87 -0
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +60 -3
- package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +3 -6
- package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +3 -6
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +163 -62
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +3 -6
- package/dist/update.d.ts +25 -5
- package/dist/update.js +86 -10
- package/package.json +2 -2
|
@@ -8,10 +8,19 @@ actor / action / target model. Every entry flows through one pipeline
|
|
|
8
8
|
|
|
9
9
|
Nothing is recorded automatically except the built-in **auth/admin actions**
|
|
10
10
|
(login, user create/update/delete, ban, role change, session revoke, ...),
|
|
11
|
-
which the
|
|
11
|
+
which the Better Auth plugin in `audit-hook.ts` maps to semantic action codes like
|
|
12
12
|
`user.create` and `auth.login`. Everything else is **opt-in** — you decide what
|
|
13
13
|
matters.
|
|
14
14
|
|
|
15
|
+
The plugin records an action only when the request succeeded, so a wrong
|
|
16
|
+
password or a forbidden admin call leaves no entry. Every sign-in method records
|
|
17
|
+
`auth.login` with the method in `metadata.method` (`password`, `email-otp`,
|
|
18
|
+
`magic-link`, `passkey`, `two-factor-totp`, ...) and, for social sign-in, the
|
|
19
|
+
provider in `metadata.provider`. A two-factor sign-in is recorded when the second
|
|
20
|
+
factor completes it, not at the password step. `auth.ts` pushes the plugin after
|
|
21
|
+
every other plugin: Better Auth runs after hooks in plugin order, and two-factor
|
|
22
|
+
changes the outcome of a password sign-in in its own after hook.
|
|
23
|
+
|
|
15
24
|
## Audit your own Elysia routes
|
|
16
25
|
|
|
17
26
|
```ts
|
|
@@ -86,7 +95,10 @@ survives renames/deletes), `targetType/targetId/targetLabel`, `ip`, `metadata`
|
|
|
86
95
|
|
|
87
96
|
## Customize
|
|
88
97
|
|
|
89
|
-
- **Auth actions:**
|
|
98
|
+
- **Auth actions:** `ACTIONS` in `audit-hook.ts` maps Better Auth endpoints to
|
|
99
|
+
action codes, and `SIGN_IN_METHODS` lists the endpoints that sign a user in.
|
|
100
|
+
The `audit-hook.spec.ts` test fails when the app's Better Auth gains a sign-in
|
|
101
|
+
endpoint that neither list classifies.
|
|
90
102
|
- **Your routes:** call `AuditService.recordRequest()` after successful operations;
|
|
91
103
|
pass `{ defer: true }` only for high-volume, non-security events.
|
|
92
104
|
- **Schema:** add columns with a migration, and keep the
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { createHash, createHmac } from "node:crypto";
|
|
3
|
+
import { oauthProvider } from "@better-auth/oauth-provider";
|
|
4
|
+
import { envAuthConfig } from "@podosoft/podokit-auth";
|
|
5
|
+
import { betterAuth } from "better-auth";
|
|
6
|
+
import { memoryAdapter } from "better-auth/adapters/memory";
|
|
7
|
+
import { getAuthTables } from "better-auth/db";
|
|
8
|
+
import { admin, emailOTP, jwt, magicLink, twoFactor } from "better-auth/plugins";
|
|
9
|
+
import { buildAuth } from "../auth/auth";
|
|
10
|
+
import { setAuditRecorder, type AuditEntry } from "./audit-events";
|
|
11
|
+
import { auditPlugin, SIGN_IN_METHODS } from "./audit-hook";
|
|
12
|
+
|
|
13
|
+
const BASE_URL = "http://localhost:5002";
|
|
14
|
+
const PASSWORD = "Podokit3e-Str0ng!pw";
|
|
15
|
+
|
|
16
|
+
// Endpoints that look like a sign-in but never start a session of their own.
|
|
17
|
+
const STARTS_NO_SESSION = new Set([
|
|
18
|
+
"/sign-in/magic-link", // emails the link that /magic-link/verify signs in with
|
|
19
|
+
"/passkey/verify-registration", // adds a passkey for the signed-in user
|
|
20
|
+
"/verify-password", // checks the signed-in user's password again
|
|
21
|
+
"/delete-user/callback", // confirms an account deletion
|
|
22
|
+
"/email-otp/check-verification-otp", // checks a code without using it
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
interface Mailbox {
|
|
26
|
+
magicLink: string;
|
|
27
|
+
otp: string;
|
|
28
|
+
verification: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface AuthHandler {
|
|
32
|
+
handler: (request: Request) => Promise<Response>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let entries: AuditEntry[] = [];
|
|
36
|
+
let restoreRecorder = (): void => undefined;
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
entries = [];
|
|
40
|
+
restoreRecorder = setAuditRecorder({
|
|
41
|
+
record: async (entry) => {
|
|
42
|
+
entries.push(entry);
|
|
43
|
+
},
|
|
44
|
+
recordLater: (entry) => {
|
|
45
|
+
entries.push(entry);
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(() => restoreRecorder());
|
|
51
|
+
|
|
52
|
+
const logins = (): AuditEntry[] => entries.filter((entry) => entry.action === "auth.login");
|
|
53
|
+
const actions = (action: string): AuditEntry[] => entries.filter((entry) => entry.action === action);
|
|
54
|
+
|
|
55
|
+
// The sign-in methods PodoKit's auth.ts enables, on an in-memory database, with
|
|
56
|
+
// the audit plugin pushed last as the audit-log module does.
|
|
57
|
+
function startAuth(enabled: () => Promise<boolean> = async () => true) {
|
|
58
|
+
const mail: Mailbox = { magicLink: "", otp: "", verification: "" };
|
|
59
|
+
const db: Record<string, Record<string, unknown>[]> = {};
|
|
60
|
+
const options = {
|
|
61
|
+
baseURL: BASE_URL,
|
|
62
|
+
secret: "audit-hook-test-secret-0123456789abcdef",
|
|
63
|
+
database: memoryAdapter(db),
|
|
64
|
+
emailAndPassword: { enabled: true },
|
|
65
|
+
emailVerification: {
|
|
66
|
+
autoSignInAfterVerification: true,
|
|
67
|
+
sendVerificationEmail: async ({ url }: { url: string }) => {
|
|
68
|
+
mail.verification = url;
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
socialProviders: {
|
|
72
|
+
google: {
|
|
73
|
+
clientId: "client",
|
|
74
|
+
clientSecret: "secret",
|
|
75
|
+
getUserInfo: async () => ({
|
|
76
|
+
user: { email: "social@example.com", name: "Social", emailVerified: true },
|
|
77
|
+
data: {
|
|
78
|
+
sub: "google-1",
|
|
79
|
+
email: "social@example.com",
|
|
80
|
+
email_verified: true,
|
|
81
|
+
name: "Social",
|
|
82
|
+
given_name: "Social",
|
|
83
|
+
family_name: "User",
|
|
84
|
+
picture: "https://example.com/social.png",
|
|
85
|
+
aud: "client",
|
|
86
|
+
azp: "client",
|
|
87
|
+
iss: "https://accounts.google.com",
|
|
88
|
+
iat: 0,
|
|
89
|
+
exp: 0,
|
|
90
|
+
},
|
|
91
|
+
}),
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
plugins: [
|
|
95
|
+
twoFactor({ issuer: "podokit" }),
|
|
96
|
+
magicLink({
|
|
97
|
+
sendMagicLink: async ({ url }) => {
|
|
98
|
+
mail.magicLink = url;
|
|
99
|
+
},
|
|
100
|
+
}),
|
|
101
|
+
emailOTP({
|
|
102
|
+
sendVerificationOTP: async ({ otp }) => {
|
|
103
|
+
mail.otp = otp;
|
|
104
|
+
},
|
|
105
|
+
}),
|
|
106
|
+
jwt({ disableSettingJwtHeader: true }),
|
|
107
|
+
oauthProvider({ loginPage: "/login", consentPage: "/oauth2/consent" }),
|
|
108
|
+
admin(),
|
|
109
|
+
auditPlugin({ enabled }),
|
|
110
|
+
],
|
|
111
|
+
};
|
|
112
|
+
for (const table of Object.values(getAuthTables(options))) db[table.modelName] = [];
|
|
113
|
+
return { auth: betterAuth(options), mail };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// A cookie jar in front of the Better Auth router, like a browser's.
|
|
117
|
+
class Browser {
|
|
118
|
+
private readonly auth: AuthHandler;
|
|
119
|
+
private readonly cookies = new Map<string, string>();
|
|
120
|
+
|
|
121
|
+
constructor(auth: AuthHandler) {
|
|
122
|
+
this.auth = auth;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Request headers that carry this browser's cookies, for a server-side auth.api call. */
|
|
126
|
+
headers(): Headers {
|
|
127
|
+
return new Headers({ cookie: [...this.cookies].map(([name, value]) => `${name}=${value}`).join("; ") });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
get(path: string): Promise<Response> {
|
|
131
|
+
return this.send("GET", path);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
post(path: string, body: unknown): Promise<Response> {
|
|
135
|
+
return this.send("POST", path, body);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private async send(method: string, path: string, body?: unknown): Promise<Response> {
|
|
139
|
+
const headers = this.headers();
|
|
140
|
+
headers.set("origin", BASE_URL);
|
|
141
|
+
if (body !== undefined) headers.set("content-type", "application/json");
|
|
142
|
+
const url = path.startsWith("http") ? path : `${BASE_URL}/api/auth${path}`;
|
|
143
|
+
const response = await this.auth.handler(
|
|
144
|
+
new Request(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), redirect: "manual" }),
|
|
145
|
+
);
|
|
146
|
+
for (const cookie of response.headers.getSetCookie()) {
|
|
147
|
+
const [pair = ""] = cookie.split(";");
|
|
148
|
+
const name = pair.slice(0, pair.indexOf("="));
|
|
149
|
+
const value = pair.slice(pair.indexOf("=") + 1);
|
|
150
|
+
if (/max-age=0/i.test(cookie) || value === "") this.cookies.delete(name);
|
|
151
|
+
else this.cookies.set(name, value);
|
|
152
|
+
}
|
|
153
|
+
return response;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function signUp(auth: AuthHandler, email: string): Promise<Browser> {
|
|
158
|
+
const browser = new Browser(auth);
|
|
159
|
+
expect((await browser.post("/sign-up/email", { email, password: PASSWORD, name: "Audit" })).status).toBe(200);
|
|
160
|
+
return browser;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// The current code for an otpauth:// URI, as an authenticator app computes it.
|
|
164
|
+
function totp(uri: string): string {
|
|
165
|
+
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
166
|
+
const secret = (new URL(uri).searchParams.get("secret") ?? "").replace(/=+$/, "").toUpperCase();
|
|
167
|
+
const bits = [...secret].map((char) => alphabet.indexOf(char).toString(2).padStart(5, "0")).join("");
|
|
168
|
+
const key = Buffer.from((bits.match(/.{8}/g) ?? []).map((byte) => Number.parseInt(byte, 2)));
|
|
169
|
+
const counter = Buffer.alloc(8);
|
|
170
|
+
counter.writeBigUInt64BE(BigInt(Math.floor(Date.now() / 30_000)));
|
|
171
|
+
const digest = createHmac("sha1", key).update(counter).digest();
|
|
172
|
+
const offset = (digest[digest.length - 1] ?? 0) & 0x0f;
|
|
173
|
+
return String((digest.readUInt32BE(offset) & 0x7fffffff) % 1_000_000).padStart(6, "0");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const otherCode = (code: string): string => String((Number(code) + 1) % 1_000_000).padStart(6, "0");
|
|
177
|
+
|
|
178
|
+
describe("audit plugin", () => {
|
|
179
|
+
test("records a password sign-in once and a wrong password not at all", async () => {
|
|
180
|
+
const { auth } = startAuth();
|
|
181
|
+
await signUp(auth, "password@example.com");
|
|
182
|
+
|
|
183
|
+
const wrong = await new Browser(auth).post("/sign-in/email", { email: "password@example.com", password: "Wrong-password-1" });
|
|
184
|
+
expect(wrong.status).toBe(401);
|
|
185
|
+
expect(logins()).toEqual([]);
|
|
186
|
+
|
|
187
|
+
const right = await new Browser(auth).post("/sign-in/email", { email: "password@example.com", password: PASSWORD });
|
|
188
|
+
expect(right.status).toBe(200);
|
|
189
|
+
expect(logins()).toEqual([
|
|
190
|
+
expect.objectContaining({
|
|
191
|
+
actorEmail: "password@example.com",
|
|
192
|
+
targetLabel: "password@example.com",
|
|
193
|
+
metadata: { method: "password" },
|
|
194
|
+
}),
|
|
195
|
+
]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("records a Google sign-in with its provider and not a callback with a forged state", async () => {
|
|
199
|
+
const { auth } = startAuth();
|
|
200
|
+
const realFetch = globalThis.fetch;
|
|
201
|
+
const tokenEndpoint = spyOn(globalThis, "fetch").mockImplementation(((input: RequestInfo | URL, init?: RequestInit) => {
|
|
202
|
+
const url = input instanceof Request ? input.url : String(input);
|
|
203
|
+
if (url.startsWith("https://oauth2.googleapis.com/token")) {
|
|
204
|
+
return Promise.resolve(Response.json({ access_token: "access", expires_in: 3600, token_type: "Bearer" }));
|
|
205
|
+
}
|
|
206
|
+
return realFetch(input, init);
|
|
207
|
+
}) as typeof fetch);
|
|
208
|
+
try {
|
|
209
|
+
const browser = new Browser(auth);
|
|
210
|
+
const started = await browser.post("/sign-in/social", { provider: "google", callbackURL: `${BASE_URL}/` });
|
|
211
|
+
const state = new URL(((await started.json()) as { url: string }).url).searchParams.get("state") ?? "";
|
|
212
|
+
expect(logins()).toEqual([]);
|
|
213
|
+
|
|
214
|
+
const forged = await new Browser(auth).get("/callback/google?code=code&state=forged");
|
|
215
|
+
expect(forged.status).toBe(302);
|
|
216
|
+
expect(logins()).toEqual([]);
|
|
217
|
+
|
|
218
|
+
const callback = await browser.get(`/callback/google?code=code&state=${encodeURIComponent(state)}`);
|
|
219
|
+
expect(callback.status).toBe(302);
|
|
220
|
+
expect(logins()).toEqual([
|
|
221
|
+
expect.objectContaining({ actorEmail: "social@example.com", metadata: { method: "social", provider: "google" } }),
|
|
222
|
+
]);
|
|
223
|
+
} finally {
|
|
224
|
+
tokenEndpoint.mockRestore();
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("records magic link and email code sign-ins, and not a used link or a wrong code", async () => {
|
|
229
|
+
const { auth, mail } = startAuth();
|
|
230
|
+
const browser = new Browser(auth);
|
|
231
|
+
await browser.post("/sign-in/magic-link", { email: "magic@example.com", callbackURL: `${BASE_URL}/` });
|
|
232
|
+
expect(logins()).toEqual([]);
|
|
233
|
+
expect((await browser.get(mail.magicLink)).status).toBe(302);
|
|
234
|
+
expect((await new Browser(auth).get(mail.magicLink)).status).toBe(302);
|
|
235
|
+
expect(logins()).toEqual([expect.objectContaining({ actorEmail: "magic@example.com", metadata: { method: "magic-link" } })]);
|
|
236
|
+
|
|
237
|
+
entries = [];
|
|
238
|
+
const coded = new Browser(auth);
|
|
239
|
+
await coded.post("/email-otp/send-verification-otp", { email: "code@example.com", type: "sign-in" });
|
|
240
|
+
const wrong = await coded.post("/sign-in/email-otp", { email: "code@example.com", otp: otherCode(mail.otp) });
|
|
241
|
+
expect(wrong.status).toBeGreaterThanOrEqual(400);
|
|
242
|
+
expect(logins()).toEqual([]);
|
|
243
|
+
expect((await coded.post("/sign-in/email-otp", { email: "code@example.com", otp: mail.otp })).status).toBe(200);
|
|
244
|
+
expect(logins()).toEqual([expect.objectContaining({ actorEmail: "code@example.com", metadata: { method: "email-otp" } })]);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("records a two-factor sign-in when the second factor completes it", async () => {
|
|
248
|
+
const { auth } = startAuth();
|
|
249
|
+
const email = "two-factor@example.com";
|
|
250
|
+
const owner = await signUp(auth, email);
|
|
251
|
+
const enabled = (await (await owner.post("/two-factor/enable", { password: PASSWORD })).json()) as {
|
|
252
|
+
totpURI: string;
|
|
253
|
+
backupCodes: string[];
|
|
254
|
+
};
|
|
255
|
+
// Confirming the authenticator replaces the owner's session; nobody signs in.
|
|
256
|
+
expect((await owner.post("/two-factor/verify-totp", { code: totp(enabled.totpURI) })).status).toBe(200);
|
|
257
|
+
expect(actions("2fa.enable")).toHaveLength(1);
|
|
258
|
+
expect(logins()).toEqual([]);
|
|
259
|
+
|
|
260
|
+
const challenged = new Browser(auth);
|
|
261
|
+
const pending = await challenged.post("/sign-in/email", { email, password: PASSWORD });
|
|
262
|
+
expect(await pending.json()).toMatchObject({ twoFactorRedirect: true });
|
|
263
|
+
expect(logins()).toEqual([]);
|
|
264
|
+
const code = totp(enabled.totpURI);
|
|
265
|
+
expect((await challenged.post("/two-factor/verify-totp", { code: otherCode(code) })).status).toBe(401);
|
|
266
|
+
expect(logins()).toEqual([]);
|
|
267
|
+
expect((await challenged.post("/two-factor/verify-totp", { code })).status).toBe(200);
|
|
268
|
+
expect(logins()).toEqual([expect.objectContaining({ actorEmail: email, metadata: { method: "two-factor-totp" } })]);
|
|
269
|
+
|
|
270
|
+
const backup = new Browser(auth);
|
|
271
|
+
await backup.post("/sign-in/email", { email, password: PASSWORD });
|
|
272
|
+
expect((await backup.post("/two-factor/verify-backup-code", { code: enabled.backupCodes[0] })).status).toBe(200);
|
|
273
|
+
expect(logins().map((entry) => entry.metadata)).toEqual([
|
|
274
|
+
{ method: "two-factor-totp" },
|
|
275
|
+
{ method: "two-factor-backup-code" },
|
|
276
|
+
]);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("records an email verification link as a sign-in only when it starts a session", async () => {
|
|
280
|
+
const { auth, mail } = startAuth();
|
|
281
|
+
const owner = await signUp(auth, "verified@example.com");
|
|
282
|
+
await owner.post("/send-verification-email", { email: "verified@example.com", callbackURL: `${BASE_URL}/` });
|
|
283
|
+
expect((await owner.get(mail.verification)).status).toBe(302);
|
|
284
|
+
expect(actions("auth.email_verified")).toHaveLength(1);
|
|
285
|
+
expect(logins()).toEqual([]);
|
|
286
|
+
|
|
287
|
+
const sender = await signUp(auth, "elsewhere@example.com");
|
|
288
|
+
await sender.post("/send-verification-email", { email: "elsewhere@example.com", callbackURL: `${BASE_URL}/` });
|
|
289
|
+
expect((await new Browser(auth).get(mail.verification)).status).toBe(302);
|
|
290
|
+
expect(actions("auth.email_verified")).toHaveLength(2);
|
|
291
|
+
expect(logins()).toEqual([
|
|
292
|
+
expect.objectContaining({ actorEmail: "elsewhere@example.com", metadata: { method: "email-verification" } }),
|
|
293
|
+
]);
|
|
294
|
+
|
|
295
|
+
const forged = await new Browser(auth).get(`/verify-email?token=forged&callbackURL=${encodeURIComponent(`${BASE_URL}/`)}`);
|
|
296
|
+
expect(forged.status).toBe(302);
|
|
297
|
+
expect(new URL(forged.headers.get("location") ?? "", BASE_URL).searchParams.get("error")).toBeTruthy();
|
|
298
|
+
expect(actions("auth.email_verified")).toHaveLength(2);
|
|
299
|
+
expect(logins()).toHaveLength(1);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("records a sign-in that resumes an OAuth authorization request", async () => {
|
|
303
|
+
const { auth } = startAuth();
|
|
304
|
+
const owner = await signUp(auth, "oauth@example.com");
|
|
305
|
+
const client = await auth.api.adminCreateOAuthClient({
|
|
306
|
+
headers: owner.headers(),
|
|
307
|
+
body: { redirect_uris: ["https://client.example.com/callback"], skip_consent: true },
|
|
308
|
+
});
|
|
309
|
+
const verifier = "audit-hook-code-verifier-0123456789abcdefghijklmnop";
|
|
310
|
+
const query = new URLSearchParams({
|
|
311
|
+
response_type: "code",
|
|
312
|
+
client_id: client.client_id,
|
|
313
|
+
redirect_uri: "https://client.example.com/callback",
|
|
314
|
+
scope: "openid",
|
|
315
|
+
state: "state",
|
|
316
|
+
code_challenge: createHash("sha256").update(verifier).digest("base64url"),
|
|
317
|
+
code_challenge_method: "S256",
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const browser = new Browser(auth);
|
|
321
|
+
const authorize = await browser.get(`/oauth2/authorize?${query}`);
|
|
322
|
+
expect(authorize.status).toBe(302);
|
|
323
|
+
const login = new URL(authorize.headers.get("location") ?? "", BASE_URL);
|
|
324
|
+
const signedIn = await browser.post("/sign-in/email", {
|
|
325
|
+
email: "oauth@example.com",
|
|
326
|
+
password: PASSWORD,
|
|
327
|
+
oauth_query: login.search.slice(1),
|
|
328
|
+
});
|
|
329
|
+
expect(signedIn.status).toBe(200);
|
|
330
|
+
expect(await signedIn.json()).toMatchObject({ redirect: true, url: expect.stringContaining("code=") });
|
|
331
|
+
expect(logins()).toEqual([expect.objectContaining({ actorEmail: "oauth@example.com", metadata: { method: "password" } })]);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("does not record failed account and admin requests", async () => {
|
|
335
|
+
const { auth } = startAuth();
|
|
336
|
+
const target = await signUp(auth, "target@example.com");
|
|
337
|
+
const targetId = ((await (await target.get("/get-session")).json()) as { user: { id: string } }).user.id;
|
|
338
|
+
const member = await signUp(auth, "member@example.com");
|
|
339
|
+
|
|
340
|
+
expect((await member.post("/admin/ban-user", { userId: targetId })).status).toBe(403);
|
|
341
|
+
expect(actions("user.ban")).toEqual([]);
|
|
342
|
+
|
|
343
|
+
const wrong = await member.post("/change-password", { currentPassword: "Wrong-password-1", newPassword: "N3w-Str0ng!password" });
|
|
344
|
+
expect(wrong.status).toBe(400);
|
|
345
|
+
expect(actions("auth.password_change")).toEqual([]);
|
|
346
|
+
|
|
347
|
+
const changed = await member.post("/change-password", {
|
|
348
|
+
currentPassword: PASSWORD,
|
|
349
|
+
newPassword: "N3w-Str0ng!password",
|
|
350
|
+
revokeOtherSessions: true,
|
|
351
|
+
});
|
|
352
|
+
expect(changed.status).toBe(200);
|
|
353
|
+
expect(actions("auth.password_change")).toEqual([expect.objectContaining({ actorEmail: "member@example.com" })]);
|
|
354
|
+
expect(logins()).toEqual([]);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("records nothing while auditing is off", async () => {
|
|
358
|
+
const { auth } = startAuth(async () => false);
|
|
359
|
+
await signUp(auth, "off@example.com");
|
|
360
|
+
expect((await new Browser(auth).post("/sign-in/email", { email: "off@example.com", password: PASSWORD })).status).toBe(200);
|
|
361
|
+
expect(entries).toEqual([]);
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
describe("the app's Better Auth", () => {
|
|
366
|
+
test("runs the audit hook after every plugin's after hooks", () => {
|
|
367
|
+
const instance = buildAuth(envAuthConfig());
|
|
368
|
+
const plugins = instance.options.plugins as Array<{ id: string }>;
|
|
369
|
+
expect(plugins.at(-1)?.id).toBe("podokit-audit");
|
|
370
|
+
expect(instance.options.hooks?.after).toBeUndefined();
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("classifies every endpoint that can sign a user in", () => {
|
|
374
|
+
const instance = buildAuth(envAuthConfig());
|
|
375
|
+
const paths = new Set(Object.values(instance.api as Record<string, { path?: string }>).map((endpoint) => endpoint.path ?? ""));
|
|
376
|
+
expect(Object.keys(SIGN_IN_METHODS).filter((path) => !paths.has(path))).toEqual([]);
|
|
377
|
+
const unclassified = [...paths].filter(
|
|
378
|
+
(path) =>
|
|
379
|
+
(path.startsWith("/sign-in/") || /callback|verify/.test(path)) &&
|
|
380
|
+
SIGN_IN_METHODS[path] === undefined &&
|
|
381
|
+
!STARTS_NO_SESSION.has(path),
|
|
382
|
+
);
|
|
383
|
+
expect(unclassified).toEqual([]);
|
|
384
|
+
});
|
|
385
|
+
});
|
|
@@ -1,13 +1,31 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { BetterAuthPlugin } from "better-auth";
|
|
2
|
+
import { createAuthMiddleware, isAPIError } from "better-auth/api";
|
|
2
3
|
import { clientAddressOf } from "../core/client-address";
|
|
3
4
|
import { recordAudit } from "./audit-events";
|
|
4
5
|
import { auditEnabled } from "./audit-enabled";
|
|
5
6
|
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
const
|
|
10
|
-
"/sign-in/email": "
|
|
7
|
+
// Endpoints that sign a user in, with the method their auth.login entry records.
|
|
8
|
+
// ctx.path is the route pattern an endpoint was registered with, not the request
|
|
9
|
+
// URL, so every social provider's callback arrives as "/callback/:id".
|
|
10
|
+
export const SIGN_IN_METHODS: Readonly<Record<string, string>> = {
|
|
11
|
+
"/sign-in/email": "password",
|
|
12
|
+
"/sign-in/username": "username",
|
|
13
|
+
"/sign-in/phone-number": "phone-number",
|
|
14
|
+
"/sign-in/email-otp": "email-otp",
|
|
15
|
+
"/sign-in/social": "social",
|
|
16
|
+
"/callback/:id": "social",
|
|
17
|
+
"/magic-link/verify": "magic-link",
|
|
18
|
+
"/passkey/verify-authentication": "passkey",
|
|
19
|
+
"/phone-number/verify": "phone-number-otp",
|
|
20
|
+
"/two-factor/verify-totp": "two-factor-totp",
|
|
21
|
+
"/two-factor/verify-otp": "two-factor-otp",
|
|
22
|
+
"/two-factor/verify-backup-code": "two-factor-backup-code",
|
|
23
|
+
"/verify-email": "email-verification",
|
|
24
|
+
"/email-otp/verify-email": "email-otp-verification",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Other auth and admin endpoints, with the action each records once it succeeds.
|
|
28
|
+
const ACTIONS: Readonly<Record<string, string>> = {
|
|
11
29
|
"/sign-up/email": "auth.signup",
|
|
12
30
|
"/sign-out": "auth.logout",
|
|
13
31
|
"/change-password": "auth.password_change",
|
|
@@ -35,43 +53,114 @@ const ACTIONS: Record<string, string> = {
|
|
|
35
53
|
"/admin/stop-impersonating": "user.stop_impersonate",
|
|
36
54
|
};
|
|
37
55
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (!action) return;
|
|
43
|
-
// AuditService checks the toggle again on write; checking here as well skips
|
|
44
|
-
// the target lookup below when auditing is off.
|
|
45
|
-
if (!(await auditEnabled())) return;
|
|
46
|
-
const context = ctx.context as {
|
|
47
|
-
session?: { user?: { id?: string; name?: string; email?: string } };
|
|
48
|
-
newSession?: { user?: { id?: string; name?: string; email?: string } };
|
|
49
|
-
};
|
|
50
|
-
const actor = context.session?.user ?? context.newSession?.user;
|
|
51
|
-
const body = (ctx.body ?? {}) as { email?: string; userId?: string };
|
|
56
|
+
interface SessionRecord {
|
|
57
|
+
session: { token: string };
|
|
58
|
+
user: { id: string; name?: string | null; email?: string | null };
|
|
59
|
+
}
|
|
52
60
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (!
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
61
|
+
// After hooks run for failed requests too, with the APIError the endpoint threw
|
|
62
|
+
// in ctx.context.returned. Redirects are APIErrors as well: a 3xx status, and an
|
|
63
|
+
// `error` query parameter on the location when the redirect reports a failure.
|
|
64
|
+
function failed(returned: unknown): boolean {
|
|
65
|
+
if (!isAPIError(returned)) return false;
|
|
66
|
+
if (returned.statusCode >= 400) return true;
|
|
67
|
+
const location = new Headers(returned.headers).get("location");
|
|
68
|
+
if (!location) return false;
|
|
69
|
+
try {
|
|
70
|
+
return new URL(location, "http://localhost").searchParams.has("error");
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
66
73
|
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A sign-in ends with a session the request did not come with. Two requests end
|
|
77
|
+
// with a new session without signing anyone in: confirming two-factor replaces
|
|
78
|
+
// the signed-in user's session, which two-factor loaded into ctx.context.session,
|
|
79
|
+
// and verifying the signed-in user's email sets the cookie of the session the
|
|
80
|
+
// request came with again. oauthProvider() also puts the new session into
|
|
81
|
+
// ctx.context.session when a sign-in resumes an authorization request.
|
|
82
|
+
function signedIn(
|
|
83
|
+
created: SessionRecord | null | undefined,
|
|
84
|
+
current: SessionRecord | null | undefined,
|
|
85
|
+
presentedToken: string | null | false,
|
|
86
|
+
): created is SessionRecord {
|
|
87
|
+
if (!created || created.session.token === presentedToken) return false;
|
|
88
|
+
return !(current && current.user.id === created.user.id && current.session.token !== created.session.token);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface AuditPluginOptions {
|
|
92
|
+
/** Whether auditing is on. Defaults to the admin Settings toggle. */
|
|
93
|
+
enabled?: () => Promise<boolean>;
|
|
94
|
+
}
|
|
67
95
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
96
|
+
// Records auth and admin actions through the application audit pipeline. Pushed
|
|
97
|
+
// after every other plugin, because after hooks run in plugin order and earlier
|
|
98
|
+
// ones change the outcome: two-factor turns a password sign-in into a pending
|
|
99
|
+
// challenge by clearing ctx.context.newSession.
|
|
100
|
+
export function auditPlugin(options: AuditPluginOptions = {}): BetterAuthPlugin & { id: "podokit-audit" } {
|
|
101
|
+
const enabled = options.enabled ?? auditEnabled;
|
|
102
|
+
return {
|
|
103
|
+
id: "podokit-audit",
|
|
104
|
+
hooks: {
|
|
105
|
+
after: [
|
|
106
|
+
{
|
|
107
|
+
matcher: (context) => Boolean(context.path && (SIGN_IN_METHODS[context.path] || ACTIONS[context.path])),
|
|
108
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
109
|
+
if (failed(ctx.context.returned)) return;
|
|
110
|
+
// AuditService checks the toggle again on write; checking here as well
|
|
111
|
+
// skips the lookups below when auditing is off.
|
|
112
|
+
if (!(await enabled())) return;
|
|
113
|
+
const ip = clientAddressOf(ctx.headers) ?? null;
|
|
114
|
+
|
|
115
|
+
const action = ACTIONS[ctx.path];
|
|
116
|
+
if (action) {
|
|
117
|
+
const actor = ctx.context.session?.user ?? ctx.context.newSession?.user;
|
|
118
|
+
const body = (ctx.body ?? {}) as { email?: string; userId?: string };
|
|
119
|
+
|
|
120
|
+
// Prefer a human-readable target: the email in the body, otherwise
|
|
121
|
+
// 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
|
+
try {
|
|
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
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const method = SIGN_IN_METHODS[ctx.path];
|
|
144
|
+
if (!method) return;
|
|
145
|
+
const created = ctx.context.newSession;
|
|
146
|
+
const presented = await ctx.getSignedCookie(ctx.context.authCookies.sessionToken.name, ctx.context.secret);
|
|
147
|
+
if (!signedIn(created, ctx.context.session, presented)) return;
|
|
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;
|
|
151
|
+
await recordAudit({
|
|
152
|
+
action: "auth.login",
|
|
153
|
+
actorId: created.user.id,
|
|
154
|
+
actorName: created.user.name ?? null,
|
|
155
|
+
actorEmail: created.user.email ?? null,
|
|
156
|
+
targetId: created.user.id,
|
|
157
|
+
targetLabel: created.user.email ?? null,
|
|
158
|
+
ip,
|
|
159
|
+
metadata: provider ? { method, provider } : { method },
|
|
160
|
+
});
|
|
161
|
+
}),
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
@@ -26,12 +26,12 @@
|
|
|
26
26
|
{
|
|
27
27
|
"file": "apps/api/src/auth/auth.ts",
|
|
28
28
|
"marker": "// podokit:end:auth-imports",
|
|
29
|
-
"text": "import {
|
|
29
|
+
"text": "import { auditPlugin } from \"../audit/audit-hook\";"
|
|
30
30
|
},
|
|
31
31
|
{
|
|
32
32
|
"file": "apps/api/src/auth/auth.ts",
|
|
33
33
|
"marker": "// podokit:end:auth-hooks",
|
|
34
|
-
"text": "
|
|
34
|
+
"text": "plugins.push(auditPlugin());"
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
37
|
"file": "infra/k3s/configmap.yaml",
|
|
@@ -192,7 +192,9 @@ export function buildAuth(config: AuthConfig) {
|
|
|
192
192
|
// Request-time hooks. The feature gate turns the admin Settings toggles into a
|
|
193
193
|
// real server boundary (disabled features 404) for endpoint calls, including
|
|
194
194
|
// in-process auth.api calls the plugin above never sees. Other PodoKit modules
|
|
195
|
-
// add their own hooks below the marker.
|
|
195
|
+
// add their own hooks below the marker. The top-level `hooks.after` runs before
|
|
196
|
+
// every plugin's after hooks, so a hook that needs a request's final outcome is
|
|
197
|
+
// pushed below the marker as a plugin, after all the plugins above.
|
|
196
198
|
const hooks: NonNullable<BetterAuthOptions["hooks"]> = {
|
|
197
199
|
before: createFeatureGate(gateSettings),
|
|
198
200
|
};
|