@podosoft/podokit 1.6.1 → 1.6.3
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 +14 -6
- package/dist/deploy-render.js +28 -3
- package/dist/deploy.d.ts +9 -0
- package/dist/deploy.js +83 -17
- package/dist/index.js +9 -2
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +50 -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 +3 -1
- package/dist/templates/fullstack/tests/README.md +11 -0
- package/dist/templates/fullstack/tests/helpers/paths.ts +13 -2
- 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/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/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/files/tests/ui/theme-mode.ui.spec.ts +3 -3
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +24 -2
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.spec.ts +609 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +178 -42
- 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/rate-limit/files/tests/api/rate-limit.api.spec.ts +208 -62
- package/dist/update.d.ts +25 -5
- package/dist/update.js +86 -10
- package/package.json +1 -1
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { createHash, createHmac } from "node:crypto";
|
|
3
|
+
import { apiKey } from "@better-auth/api-key";
|
|
4
|
+
import { oauthProvider } from "@better-auth/oauth-provider";
|
|
5
|
+
import { envAuthConfig } from "@podosoft/podokit-auth";
|
|
6
|
+
import { betterAuth, type BetterAuthOptions, type BetterAuthPlugin } from "better-auth";
|
|
7
|
+
import { memoryAdapter } from "better-auth/adapters/memory";
|
|
8
|
+
import { APIError, createAuthEndpoint } from "better-auth/api";
|
|
9
|
+
import { setSessionCookie } from "better-auth/cookies";
|
|
10
|
+
import { getAuthTables } from "better-auth/db";
|
|
11
|
+
import { admin, bearer, emailOTP, jwt, magicLink, multiSession, twoFactor } from "better-auth/plugins";
|
|
12
|
+
import { buildAuth } from "../auth/auth";
|
|
13
|
+
import { setAuditRecorder, type AuditEntry } from "./audit-events";
|
|
14
|
+
import { auditPlugin, NOT_SIGN_INS, SIGN_IN_METHODS } from "./audit-hook";
|
|
15
|
+
|
|
16
|
+
const BASE_URL = "http://localhost:5002";
|
|
17
|
+
const PASSWORD = "Podokit3e-Str0ng!pw";
|
|
18
|
+
|
|
19
|
+
// The plugins PodoKit's auth.ts and modules configure. Endpoints of other
|
|
20
|
+
// plugins, such as one an application adds, are not classified below: the
|
|
21
|
+
// audit plugin records their sign-ins by default.
|
|
22
|
+
const PODOKIT_PLUGINS = new Set([
|
|
23
|
+
"podokit-feature-gate",
|
|
24
|
+
"podokit-revoke-every-other-session",
|
|
25
|
+
"two-factor",
|
|
26
|
+
"magic-link",
|
|
27
|
+
"email-otp",
|
|
28
|
+
"username",
|
|
29
|
+
"multi-session",
|
|
30
|
+
"phone-number",
|
|
31
|
+
"api-key",
|
|
32
|
+
"passkey",
|
|
33
|
+
"organization",
|
|
34
|
+
"bearer",
|
|
35
|
+
"jwt",
|
|
36
|
+
"oauth-provider",
|
|
37
|
+
"open-api",
|
|
38
|
+
"have-i-been-pwned",
|
|
39
|
+
"admin",
|
|
40
|
+
"podokit-audit",
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
// Endpoints of Better Auth and of those plugins that never end with a session
|
|
44
|
+
// other than the one the request came with, which some of them set again. A
|
|
45
|
+
// Better Auth upgrade that adds an endpoint fails "classifies every endpoint"
|
|
46
|
+
// until the endpoint is added here, to SIGN_IN_METHODS, or to NOT_SIGN_INS.
|
|
47
|
+
const STARTS_NO_SESSION = new Set([
|
|
48
|
+
"/.well-known/oauth-authorization-server", "/.well-known/openid-configuration", "/account-info",
|
|
49
|
+
"/admin/ban-user", "/admin/create-user", "/admin/get-user", "/admin/has-permission",
|
|
50
|
+
"/admin/list-user-sessions", "/admin/list-users", "/admin/oauth2/create-client", "/admin/oauth2/resources",
|
|
51
|
+
"/admin/oauth2/resources/:identifier", "/admin/oauth2/resources/:identifier/clients/:client_id",
|
|
52
|
+
"/admin/oauth2/update-client", "/admin/remove-user", "/admin/revoke-user-session", "/admin/revoke-user-sessions",
|
|
53
|
+
"/admin/set-role", "/admin/set-user-password", "/admin/unban-user", "/admin/update-user",
|
|
54
|
+
"/api-key/create", "/api-key/delete", "/api-key/get", "/api-key/list", "/api-key/update",
|
|
55
|
+
"/change-email", "/delete-user", "/delete-user/callback",
|
|
56
|
+
"/email-otp/change-email", "/email-otp/check-verification-otp", "/email-otp/request-email-change",
|
|
57
|
+
"/email-otp/request-password-reset", "/email-otp/reset-password", "/email-otp/send-verification-otp",
|
|
58
|
+
"/error", "/forget-password/email-otp", "/get-access-token", "/get-session", "/is-username-available", "/jwks",
|
|
59
|
+
"/link-social", "/list-accounts", "/list-sessions", "/multi-session/list-device-sessions",
|
|
60
|
+
"/oauth2/authorize", "/oauth2/client/rotate-keys", "/oauth2/client/rotate-secret", "/oauth2/consent",
|
|
61
|
+
"/oauth2/continue", "/oauth2/create-client", "/oauth2/delete-client", "/oauth2/delete-consent",
|
|
62
|
+
"/oauth2/end-session", "/oauth2/end-session/confirm", "/oauth2/get-client", "/oauth2/get-clients",
|
|
63
|
+
"/oauth2/get-consent", "/oauth2/get-consents", "/oauth2/introspect", "/oauth2/public-client",
|
|
64
|
+
"/oauth2/public-client-prelogin", "/oauth2/register", "/oauth2/revoke", "/oauth2/token", "/oauth2/update-client",
|
|
65
|
+
"/oauth2/update-consent", "/oauth2/userinfo", "/ok", "/open-api/generate-schema",
|
|
66
|
+
"/organization/accept-invitation", "/organization/cancel-invitation", "/organization/check-slug",
|
|
67
|
+
"/organization/create", "/organization/delete", "/organization/get-active-member",
|
|
68
|
+
"/organization/get-active-member-role", "/organization/get-full-organization", "/organization/get-invitation",
|
|
69
|
+
"/organization/get-organization", "/organization/has-permission", "/organization/invite-member",
|
|
70
|
+
"/organization/leave", "/organization/list", "/organization/list-invitations", "/organization/list-members",
|
|
71
|
+
"/organization/list-user-invitations", "/organization/reject-invitation", "/organization/remove-member",
|
|
72
|
+
"/organization/set-active", "/organization/update", "/organization/update-member-role",
|
|
73
|
+
"/passkey/delete-passkey", "/passkey/generate-authenticate-options", "/passkey/generate-register-options",
|
|
74
|
+
"/passkey/list-user-passkeys", "/passkey/update-passkey", "/passkey/verify-registration",
|
|
75
|
+
"/phone-number/request-password-reset", "/phone-number/reset-password", "/phone-number/send-otp",
|
|
76
|
+
"/reference", "/refresh-token", "/request-password-reset", "/reset-password", "/reset-password/:token",
|
|
77
|
+
"/revoke-other-sessions", "/revoke-session", "/revoke-sessions", "/send-verification-email",
|
|
78
|
+
"/sign-in/magic-link", "/sign-out", "/token", "/two-factor/generate-backup-codes", "/two-factor/get-totp-uri",
|
|
79
|
+
"/two-factor/send-otp", "/unlink-account", "/update-session", "/update-user", "/verify-password",
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
interface Mailbox {
|
|
83
|
+
magicLink: string;
|
|
84
|
+
otp: string;
|
|
85
|
+
verification: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface AuthHandler {
|
|
89
|
+
handler: (request: Request) => Promise<Response>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let entries: AuditEntry[] = [];
|
|
93
|
+
let restoreRecorder = (): void => undefined;
|
|
94
|
+
|
|
95
|
+
beforeEach(() => {
|
|
96
|
+
entries = [];
|
|
97
|
+
restoreRecorder = setAuditRecorder({
|
|
98
|
+
record: async (entry) => {
|
|
99
|
+
entries.push(entry);
|
|
100
|
+
},
|
|
101
|
+
recordLater: (entry) => {
|
|
102
|
+
entries.push(entry);
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
afterEach(() => restoreRecorder());
|
|
108
|
+
|
|
109
|
+
const logins = (): AuditEntry[] => entries.filter((entry) => entry.action === "auth.login");
|
|
110
|
+
const actions = (action: string): AuditEntry[] => entries.filter((entry) => entry.action === action);
|
|
111
|
+
|
|
112
|
+
// Like @better-auth/electron's /electron/token, which a desktop app calls with
|
|
113
|
+
// the code its browser sign-in handed over: an endpoint of an application's own
|
|
114
|
+
// plugin that starts a session for a user it looks up.
|
|
115
|
+
function desktopSignIn(): BetterAuthPlugin {
|
|
116
|
+
return {
|
|
117
|
+
id: "desktop-sign-in",
|
|
118
|
+
endpoints: {
|
|
119
|
+
desktopToken: createAuthEndpoint("/desktop/token", { method: "POST" }, async (ctx) => {
|
|
120
|
+
const { code } = (ctx.body ?? {}) as { code?: string };
|
|
121
|
+
const found = code ? await ctx.context.internalAdapter.findUserByEmail(code) : null;
|
|
122
|
+
if (!found) throw new APIError("NOT_FOUND", { message: "Invalid or expired token." });
|
|
123
|
+
const session = await ctx.context.internalAdapter.createSession(found.user.id);
|
|
124
|
+
await setSessionCookie(ctx, { session, user: found.user });
|
|
125
|
+
return ctx.json({ token: session.token });
|
|
126
|
+
}),
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
interface StartOptions {
|
|
132
|
+
enabled?: () => Promise<boolean>;
|
|
133
|
+
session?: BetterAuthOptions["session"];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The sign-in methods PodoKit's auth.ts enables and plugins that set or swap
|
|
137
|
+
// sessions without a sign-in, on an in-memory database, with the audit plugin
|
|
138
|
+
// pushed last as the audit-log module does.
|
|
139
|
+
function startAuth({ enabled = async () => true, session }: StartOptions = {}) {
|
|
140
|
+
const mail: Mailbox = { magicLink: "", otp: "", verification: "" };
|
|
141
|
+
const db: Record<string, Record<string, unknown>[]> = {};
|
|
142
|
+
const options = {
|
|
143
|
+
baseURL: BASE_URL,
|
|
144
|
+
secret: "audit-hook-test-secret-0123456789abcdef",
|
|
145
|
+
database: memoryAdapter(db),
|
|
146
|
+
...(session ? { session } : {}),
|
|
147
|
+
user: { changeEmail: { enabled: true, updateEmailWithoutVerification: true } },
|
|
148
|
+
emailAndPassword: { enabled: true },
|
|
149
|
+
emailVerification: {
|
|
150
|
+
autoSignInAfterVerification: true,
|
|
151
|
+
sendVerificationEmail: async ({ url }: { url: string }) => {
|
|
152
|
+
mail.verification = url;
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
socialProviders: {
|
|
156
|
+
google: {
|
|
157
|
+
clientId: "client",
|
|
158
|
+
clientSecret: "secret",
|
|
159
|
+
getUserInfo: async () => ({
|
|
160
|
+
user: { email: "social@example.com", name: "Social", emailVerified: true },
|
|
161
|
+
data: {
|
|
162
|
+
sub: "google-1",
|
|
163
|
+
email: "social@example.com",
|
|
164
|
+
email_verified: true,
|
|
165
|
+
name: "Social",
|
|
166
|
+
given_name: "Social",
|
|
167
|
+
family_name: "User",
|
|
168
|
+
picture: "https://example.com/social.png",
|
|
169
|
+
aud: "client",
|
|
170
|
+
azp: "client",
|
|
171
|
+
iss: "https://accounts.google.com",
|
|
172
|
+
iat: 0,
|
|
173
|
+
exp: 0,
|
|
174
|
+
},
|
|
175
|
+
}),
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
plugins: [
|
|
179
|
+
twoFactor({ issuer: "podokit" }),
|
|
180
|
+
magicLink({
|
|
181
|
+
sendMagicLink: async ({ url }) => {
|
|
182
|
+
mail.magicLink = url;
|
|
183
|
+
},
|
|
184
|
+
}),
|
|
185
|
+
emailOTP({
|
|
186
|
+
sendVerificationOTP: async ({ otp }) => {
|
|
187
|
+
mail.otp = otp;
|
|
188
|
+
},
|
|
189
|
+
}),
|
|
190
|
+
multiSession(),
|
|
191
|
+
apiKey({ enableSessionForAPIKeys: true, rateLimit: { enabled: false } }),
|
|
192
|
+
bearer({ requireSignature: true }),
|
|
193
|
+
jwt({ disableSettingJwtHeader: true }),
|
|
194
|
+
oauthProvider({ loginPage: "/login", consentPage: "/oauth2/consent" }),
|
|
195
|
+
admin(),
|
|
196
|
+
desktopSignIn(),
|
|
197
|
+
auditPlugin({ enabled }),
|
|
198
|
+
],
|
|
199
|
+
};
|
|
200
|
+
for (const table of Object.values(getAuthTables(options))) db[table.modelName] = [];
|
|
201
|
+
return { auth: betterAuth(options), mail, db };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// A cookie jar in front of the Better Auth router, like a browser's, or a client
|
|
205
|
+
// that sends the same headers with every request.
|
|
206
|
+
class Browser {
|
|
207
|
+
private readonly auth: AuthHandler;
|
|
208
|
+
private readonly sent: Record<string, string>;
|
|
209
|
+
private readonly cookies = new Map<string, string>();
|
|
210
|
+
|
|
211
|
+
constructor(auth: AuthHandler, sent: Record<string, string> = {}) {
|
|
212
|
+
this.auth = auth;
|
|
213
|
+
this.sent = sent;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Request headers that carry this browser's cookies, for a server-side auth.api call. */
|
|
217
|
+
headers(): Headers {
|
|
218
|
+
const headers = new Headers(this.sent);
|
|
219
|
+
if (this.cookies.size > 0) headers.set("cookie", [...this.cookies].map(([name, value]) => `${name}=${value}`).join("; "));
|
|
220
|
+
return headers;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
get(path: string): Promise<Response> {
|
|
224
|
+
return this.send("GET", path);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
post(path: string, body: unknown): Promise<Response> {
|
|
228
|
+
return this.send("POST", path, body);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private async send(method: string, path: string, body?: unknown): Promise<Response> {
|
|
232
|
+
const headers = this.headers();
|
|
233
|
+
headers.set("origin", BASE_URL);
|
|
234
|
+
if (body !== undefined) headers.set("content-type", "application/json");
|
|
235
|
+
const url = path.startsWith("http") ? path : `${BASE_URL}/api/auth${path}`;
|
|
236
|
+
const response = await this.auth.handler(
|
|
237
|
+
new Request(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), redirect: "manual" }),
|
|
238
|
+
);
|
|
239
|
+
for (const cookie of response.headers.getSetCookie()) {
|
|
240
|
+
const [pair = ""] = cookie.split(";");
|
|
241
|
+
const name = pair.slice(0, pair.indexOf("="));
|
|
242
|
+
const value = pair.slice(pair.indexOf("=") + 1);
|
|
243
|
+
if (/max-age=0/i.test(cookie) || value === "") this.cookies.delete(name);
|
|
244
|
+
else this.cookies.set(name, value);
|
|
245
|
+
}
|
|
246
|
+
return response;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function signUp(auth: AuthHandler, email: string): Promise<Browser> {
|
|
251
|
+
const browser = new Browser(auth);
|
|
252
|
+
expect((await browser.post("/sign-up/email", { email, password: PASSWORD, name: "Audit" })).status).toBe(200);
|
|
253
|
+
return browser;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function userId(browser: Browser): Promise<string> {
|
|
257
|
+
return ((await (await browser.get("/get-session")).json()) as { user: { id: string } }).user.id;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// The current code for an otpauth:// URI, as an authenticator app computes it.
|
|
261
|
+
function totp(uri: string): string {
|
|
262
|
+
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
263
|
+
const secret = (new URL(uri).searchParams.get("secret") ?? "").replace(/=+$/, "").toUpperCase();
|
|
264
|
+
const bits = [...secret].map((char) => alphabet.indexOf(char).toString(2).padStart(5, "0")).join("");
|
|
265
|
+
const key = Buffer.from((bits.match(/.{8}/g) ?? []).map((byte) => Number.parseInt(byte, 2)));
|
|
266
|
+
const counter = Buffer.alloc(8);
|
|
267
|
+
counter.writeBigUInt64BE(BigInt(Math.floor(Date.now() / 30_000)));
|
|
268
|
+
const digest = createHmac("sha1", key).update(counter).digest();
|
|
269
|
+
const offset = (digest[digest.length - 1] ?? 0) & 0x0f;
|
|
270
|
+
return String((digest.readUInt32BE(offset) & 0x7fffffff) % 1_000_000).padStart(6, "0");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const otherCode = (code: string): string => String((Number(code) + 1) % 1_000_000).padStart(6, "0");
|
|
274
|
+
|
|
275
|
+
describe("audit plugin", () => {
|
|
276
|
+
test("records a password sign-in once and a wrong password not at all", async () => {
|
|
277
|
+
const { auth } = startAuth();
|
|
278
|
+
await signUp(auth, "password@example.com");
|
|
279
|
+
// Signing up starts a session too, recorded as the sign-up only.
|
|
280
|
+
expect(entries.map((entry) => entry.action)).toEqual(["auth.signup"]);
|
|
281
|
+
|
|
282
|
+
const wrong = await new Browser(auth).post("/sign-in/email", { email: "password@example.com", password: "Wrong-password-1" });
|
|
283
|
+
expect(wrong.status).toBe(401);
|
|
284
|
+
expect(logins()).toEqual([]);
|
|
285
|
+
|
|
286
|
+
const right = await new Browser(auth).post("/sign-in/email", { email: "password@example.com", password: PASSWORD });
|
|
287
|
+
expect(right.status).toBe(200);
|
|
288
|
+
expect(logins()).toEqual([
|
|
289
|
+
expect.objectContaining({
|
|
290
|
+
actorEmail: "password@example.com",
|
|
291
|
+
targetLabel: "password@example.com",
|
|
292
|
+
metadata: { method: "password" },
|
|
293
|
+
}),
|
|
294
|
+
]);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("records a Google sign-in with its provider and not a callback with a forged state", async () => {
|
|
298
|
+
const { auth } = startAuth();
|
|
299
|
+
const realFetch = globalThis.fetch;
|
|
300
|
+
const tokenEndpoint = spyOn(globalThis, "fetch").mockImplementation(((input: RequestInfo | URL, init?: RequestInit) => {
|
|
301
|
+
const url = input instanceof Request ? input.url : String(input);
|
|
302
|
+
if (url.startsWith("https://oauth2.googleapis.com/token")) {
|
|
303
|
+
return Promise.resolve(Response.json({ access_token: "access", expires_in: 3600, token_type: "Bearer" }));
|
|
304
|
+
}
|
|
305
|
+
return realFetch(input, init);
|
|
306
|
+
}) as typeof fetch);
|
|
307
|
+
try {
|
|
308
|
+
const browser = new Browser(auth);
|
|
309
|
+
const started = await browser.post("/sign-in/social", { provider: "google", callbackURL: `${BASE_URL}/` });
|
|
310
|
+
const state = new URL(((await started.json()) as { url: string }).url).searchParams.get("state") ?? "";
|
|
311
|
+
expect(logins()).toEqual([]);
|
|
312
|
+
|
|
313
|
+
const forged = await new Browser(auth).get("/callback/google?code=code&state=forged");
|
|
314
|
+
expect(forged.status).toBe(302);
|
|
315
|
+
expect(logins()).toEqual([]);
|
|
316
|
+
|
|
317
|
+
const callback = await browser.get(`/callback/google?code=code&state=${encodeURIComponent(state)}`);
|
|
318
|
+
expect(callback.status).toBe(302);
|
|
319
|
+
expect(logins()).toEqual([
|
|
320
|
+
expect.objectContaining({ actorEmail: "social@example.com", metadata: { method: "social", provider: "google" } }),
|
|
321
|
+
]);
|
|
322
|
+
} finally {
|
|
323
|
+
tokenEndpoint.mockRestore();
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("records magic link and email code sign-ins, and not a used link or a wrong code", async () => {
|
|
328
|
+
const { auth, mail } = startAuth();
|
|
329
|
+
const browser = new Browser(auth);
|
|
330
|
+
await browser.post("/sign-in/magic-link", { email: "magic@example.com", callbackURL: `${BASE_URL}/` });
|
|
331
|
+
expect(logins()).toEqual([]);
|
|
332
|
+
expect((await browser.get(mail.magicLink)).status).toBe(302);
|
|
333
|
+
expect((await new Browser(auth).get(mail.magicLink)).status).toBe(302);
|
|
334
|
+
expect(logins()).toEqual([expect.objectContaining({ actorEmail: "magic@example.com", metadata: { method: "magic-link" } })]);
|
|
335
|
+
|
|
336
|
+
entries = [];
|
|
337
|
+
const coded = new Browser(auth);
|
|
338
|
+
await coded.post("/email-otp/send-verification-otp", { email: "code@example.com", type: "sign-in" });
|
|
339
|
+
const wrong = await coded.post("/sign-in/email-otp", { email: "code@example.com", otp: otherCode(mail.otp) });
|
|
340
|
+
expect(wrong.status).toBeGreaterThanOrEqual(400);
|
|
341
|
+
expect(logins()).toEqual([]);
|
|
342
|
+
expect((await coded.post("/sign-in/email-otp", { email: "code@example.com", otp: mail.otp })).status).toBe(200);
|
|
343
|
+
expect(logins()).toEqual([expect.objectContaining({ actorEmail: "code@example.com", metadata: { method: "email-otp" } })]);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("records a two-factor sign-in when the second factor completes it", async () => {
|
|
347
|
+
const { auth } = startAuth();
|
|
348
|
+
const email = "two-factor@example.com";
|
|
349
|
+
const owner = await signUp(auth, email);
|
|
350
|
+
const enabled = (await (await owner.post("/two-factor/enable", { password: PASSWORD })).json()) as {
|
|
351
|
+
totpURI: string;
|
|
352
|
+
backupCodes: string[];
|
|
353
|
+
};
|
|
354
|
+
// Confirming the authenticator replaces the owner's session; nobody signs in.
|
|
355
|
+
expect((await owner.post("/two-factor/verify-totp", { code: totp(enabled.totpURI) })).status).toBe(200);
|
|
356
|
+
expect(actions("2fa.enable")).toHaveLength(1);
|
|
357
|
+
expect(logins()).toEqual([]);
|
|
358
|
+
|
|
359
|
+
const challenged = new Browser(auth);
|
|
360
|
+
const pending = await challenged.post("/sign-in/email", { email, password: PASSWORD });
|
|
361
|
+
expect(await pending.json()).toMatchObject({ twoFactorRedirect: true });
|
|
362
|
+
expect(logins()).toEqual([]);
|
|
363
|
+
const code = totp(enabled.totpURI);
|
|
364
|
+
expect((await challenged.post("/two-factor/verify-totp", { code: otherCode(code) })).status).toBe(401);
|
|
365
|
+
expect(logins()).toEqual([]);
|
|
366
|
+
expect((await challenged.post("/two-factor/verify-totp", { code })).status).toBe(200);
|
|
367
|
+
expect(logins()).toEqual([expect.objectContaining({ actorEmail: email, metadata: { method: "two-factor-totp" } })]);
|
|
368
|
+
|
|
369
|
+
const backup = new Browser(auth);
|
|
370
|
+
await backup.post("/sign-in/email", { email, password: PASSWORD });
|
|
371
|
+
expect((await backup.post("/two-factor/verify-backup-code", { code: enabled.backupCodes[0] })).status).toBe(200);
|
|
372
|
+
expect(logins().map((entry) => entry.metadata)).toEqual([
|
|
373
|
+
{ method: "two-factor-totp" },
|
|
374
|
+
{ method: "two-factor-backup-code" },
|
|
375
|
+
]);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("records an email verification link as a sign-in only when it starts a session", async () => {
|
|
379
|
+
const { auth, mail } = startAuth();
|
|
380
|
+
const owner = await signUp(auth, "verified@example.com");
|
|
381
|
+
await owner.post("/send-verification-email", { email: "verified@example.com", callbackURL: `${BASE_URL}/` });
|
|
382
|
+
expect((await owner.get(mail.verification)).status).toBe(302);
|
|
383
|
+
expect(actions("auth.email_verified")).toHaveLength(1);
|
|
384
|
+
expect(logins()).toEqual([]);
|
|
385
|
+
|
|
386
|
+
const sender = await signUp(auth, "elsewhere@example.com");
|
|
387
|
+
await sender.post("/send-verification-email", { email: "elsewhere@example.com", callbackURL: `${BASE_URL}/` });
|
|
388
|
+
expect((await new Browser(auth).get(mail.verification)).status).toBe(302);
|
|
389
|
+
expect(actions("auth.email_verified")).toHaveLength(2);
|
|
390
|
+
expect(logins()).toEqual([
|
|
391
|
+
expect.objectContaining({ actorEmail: "elsewhere@example.com", metadata: { method: "email-verification" } }),
|
|
392
|
+
]);
|
|
393
|
+
|
|
394
|
+
const forged = await new Browser(auth).get(`/verify-email?token=forged&callbackURL=${encodeURIComponent(`${BASE_URL}/`)}`);
|
|
395
|
+
expect(forged.status).toBe(302);
|
|
396
|
+
expect(new URL(forged.headers.get("location") ?? "", BASE_URL).searchParams.get("error")).toBeTruthy();
|
|
397
|
+
expect(actions("auth.email_verified")).toHaveLength(2);
|
|
398
|
+
expect(logins()).toHaveLength(1);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("records a sign-in that resumes an OAuth authorization request", async () => {
|
|
402
|
+
const { auth } = startAuth();
|
|
403
|
+
const owner = await signUp(auth, "oauth@example.com");
|
|
404
|
+
const client = await auth.api.adminCreateOAuthClient({
|
|
405
|
+
headers: owner.headers(),
|
|
406
|
+
body: { redirect_uris: ["https://client.example.com/callback"], skip_consent: true },
|
|
407
|
+
});
|
|
408
|
+
const verifier = "audit-hook-code-verifier-0123456789abcdefghijklmnop";
|
|
409
|
+
const query = new URLSearchParams({
|
|
410
|
+
response_type: "code",
|
|
411
|
+
client_id: client.client_id,
|
|
412
|
+
redirect_uri: "https://client.example.com/callback",
|
|
413
|
+
scope: "openid",
|
|
414
|
+
state: "state",
|
|
415
|
+
code_challenge: createHash("sha256").update(verifier).digest("base64url"),
|
|
416
|
+
code_challenge_method: "S256",
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
const browser = new Browser(auth);
|
|
420
|
+
const authorize = await browser.get(`/oauth2/authorize?${query}`);
|
|
421
|
+
expect(authorize.status).toBe(302);
|
|
422
|
+
const login = new URL(authorize.headers.get("location") ?? "", BASE_URL);
|
|
423
|
+
const signedIn = await browser.post("/sign-in/email", {
|
|
424
|
+
email: "oauth@example.com",
|
|
425
|
+
password: PASSWORD,
|
|
426
|
+
oauth_query: login.search.slice(1),
|
|
427
|
+
});
|
|
428
|
+
expect(signedIn.status).toBe(200);
|
|
429
|
+
expect(await signedIn.json()).toMatchObject({ redirect: true, url: expect.stringContaining("code=") });
|
|
430
|
+
expect(logins()).toEqual([expect.objectContaining({ actorEmail: "oauth@example.com", metadata: { method: "password" } })]);
|
|
431
|
+
|
|
432
|
+
// Authorizing again with that session and exchanging the code sign nobody in.
|
|
433
|
+
const again = await browser.get(`/oauth2/authorize?${query}`);
|
|
434
|
+
expect(again.status).toBe(302);
|
|
435
|
+
const code = new URL(again.headers.get("location") ?? "").searchParams.get("code") ?? "";
|
|
436
|
+
expect(code).not.toBe("");
|
|
437
|
+
const exchange = new URLSearchParams({
|
|
438
|
+
grant_type: "authorization_code",
|
|
439
|
+
code,
|
|
440
|
+
redirect_uri: "https://client.example.com/callback",
|
|
441
|
+
code_verifier: verifier,
|
|
442
|
+
});
|
|
443
|
+
const credentials = Buffer.from(`${client.client_id}:${client.client_secret ?? ""}`).toString("base64");
|
|
444
|
+
const token = await auth.handler(
|
|
445
|
+
new Request(`${BASE_URL}/api/auth/oauth2/token`, {
|
|
446
|
+
method: "POST",
|
|
447
|
+
headers: { "content-type": "application/x-www-form-urlencoded", authorization: `Basic ${credentials}` },
|
|
448
|
+
body: exchange,
|
|
449
|
+
}),
|
|
450
|
+
);
|
|
451
|
+
expect(token.status).toBe(200);
|
|
452
|
+
expect(logins()).toHaveLength(1);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test("does not record failed account and admin requests", async () => {
|
|
456
|
+
const { auth } = startAuth();
|
|
457
|
+
const target = await signUp(auth, "target@example.com");
|
|
458
|
+
const targetId = ((await (await target.get("/get-session")).json()) as { user: { id: string } }).user.id;
|
|
459
|
+
const member = await signUp(auth, "member@example.com");
|
|
460
|
+
|
|
461
|
+
expect((await member.post("/admin/ban-user", { userId: targetId })).status).toBe(403);
|
|
462
|
+
expect(actions("user.ban")).toEqual([]);
|
|
463
|
+
|
|
464
|
+
const wrong = await member.post("/change-password", { currentPassword: "Wrong-password-1", newPassword: "N3w-Str0ng!password" });
|
|
465
|
+
expect(wrong.status).toBe(400);
|
|
466
|
+
expect(actions("auth.password_change")).toEqual([]);
|
|
467
|
+
|
|
468
|
+
const changed = await member.post("/change-password", {
|
|
469
|
+
currentPassword: PASSWORD,
|
|
470
|
+
newPassword: "N3w-Str0ng!password",
|
|
471
|
+
revokeOtherSessions: true,
|
|
472
|
+
});
|
|
473
|
+
expect(changed.status).toBe(200);
|
|
474
|
+
expect(actions("auth.password_change")).toEqual([expect.objectContaining({ actorEmail: "member@example.com" })]);
|
|
475
|
+
expect(logins()).toEqual([]);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test("records a sign-in through an endpoint of an application's plugin with its path", async () => {
|
|
479
|
+
const { auth } = startAuth();
|
|
480
|
+
await signUp(auth, "desktop@example.com");
|
|
481
|
+
entries = [];
|
|
482
|
+
|
|
483
|
+
const desktop = new Browser(auth);
|
|
484
|
+
expect((await desktop.post("/desktop/token", { code: "nobody@example.com" })).status).toBe(404);
|
|
485
|
+
expect(logins()).toEqual([]);
|
|
486
|
+
expect((await desktop.post("/desktop/token", { code: "desktop@example.com" })).status).toBe(200);
|
|
487
|
+
expect(logins()).toEqual([
|
|
488
|
+
expect.objectContaining({ actorEmail: "desktop@example.com", metadata: { method: "/desktop/token" } }),
|
|
489
|
+
]);
|
|
490
|
+
// The session it started is then the desktop app's own.
|
|
491
|
+
expect((await desktop.get("/get-session")).status).toBe(200);
|
|
492
|
+
expect((await desktop.post("/update-user", { name: "Desktop" })).status).toBe(200);
|
|
493
|
+
expect(logins()).toHaveLength(1);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("records no sign-in for reads and updates that refresh the session", async () => {
|
|
497
|
+
// Every session read extends the session and the cookie cache expires at once.
|
|
498
|
+
const { auth } = startAuth({ session: { updateAge: 0, cookieCache: { enabled: true, maxAge: 1 } } });
|
|
499
|
+
const owner = await signUp(auth, "refresh@example.com");
|
|
500
|
+
entries = [];
|
|
501
|
+
expect((await owner.get("/get-session")).status).toBe(200);
|
|
502
|
+
expect((await owner.get("/get-session?disableCookieCache=true")).status).toBe(200);
|
|
503
|
+
expect((await owner.post("/update-user", { name: "Refreshed" })).status).toBe(200);
|
|
504
|
+
expect((await owner.post("/change-email", { newEmail: "refreshed@example.com" })).status).toBe(200);
|
|
505
|
+
expect((await owner.post("/update-session", {})).status).toBeLessThan(500);
|
|
506
|
+
expect((await owner.get("/token")).status).toBe(200);
|
|
507
|
+
expect((await owner.get("/list-sessions")).status).toBe(200);
|
|
508
|
+
expect(actions("account.update")).toHaveLength(1);
|
|
509
|
+
expect(actions("auth.email_change")).toHaveLength(1);
|
|
510
|
+
expect(logins()).toEqual([]);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test("records no sign-in for bearer and API key requests", async () => {
|
|
514
|
+
const { auth } = startAuth({ session: { updateAge: 0 } });
|
|
515
|
+
const owner = await signUp(auth, "client@example.com");
|
|
516
|
+
const signIn = await new Browser(auth).post("/sign-in/email", { email: "client@example.com", password: PASSWORD });
|
|
517
|
+
const token = signIn.headers.get("set-auth-token") ?? "";
|
|
518
|
+
expect(token).not.toBe("");
|
|
519
|
+
const created = await owner.post("/api-key/create", {});
|
|
520
|
+
expect(created.status).toBe(200);
|
|
521
|
+
const { key } = (await created.json()) as { key: string };
|
|
522
|
+
entries = [];
|
|
523
|
+
|
|
524
|
+
for (const client of [new Browser(auth, { authorization: `Bearer ${token}` }), new Browser(auth, { "x-api-key": key })]) {
|
|
525
|
+
for (let i = 0; i < 3; i++) expect((await client.get("/get-session")).status).toBe(200);
|
|
526
|
+
expect((await client.post("/update-user", { name: "Client" })).status).toBe(200);
|
|
527
|
+
expect((await client.get("/list-sessions")).status).toBe(200);
|
|
528
|
+
}
|
|
529
|
+
expect(actions("account.update")).toHaveLength(2);
|
|
530
|
+
expect(logins()).toEqual([]);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test("records no sign-in when an administrator impersonates a user or a browser switches accounts", async () => {
|
|
534
|
+
const { auth, db } = startAuth();
|
|
535
|
+
const operator = await signUp(auth, "operator@example.com");
|
|
536
|
+
const operatorId = await userId(operator);
|
|
537
|
+
const row = db.user?.find((user) => user.id === operatorId);
|
|
538
|
+
if (row) row.role = "admin";
|
|
539
|
+
const target = await signUp(auth, "impersonated@example.com");
|
|
540
|
+
const targetId = await userId(target);
|
|
541
|
+
entries = [];
|
|
542
|
+
|
|
543
|
+
expect((await operator.post("/admin/impersonate-user", { userId: targetId })).status).toBe(200);
|
|
544
|
+
expect(await userId(operator)).toBe(targetId);
|
|
545
|
+
expect((await operator.post("/admin/stop-impersonating", {})).status).toBe(200);
|
|
546
|
+
expect(await userId(operator)).toBe(operatorId);
|
|
547
|
+
expect(actions("user.impersonate")).toHaveLength(1);
|
|
548
|
+
expect(actions("user.stop_impersonate")).toHaveLength(1);
|
|
549
|
+
expect(logins()).toEqual([]);
|
|
550
|
+
|
|
551
|
+
// Signing in to a second account in the same browser is a sign-in; switching
|
|
552
|
+
// between the two and signing one out are not.
|
|
553
|
+
const second = await operator.post("/sign-in/email", { email: "impersonated@example.com", password: PASSWORD });
|
|
554
|
+
expect(second.status).toBe(200);
|
|
555
|
+
expect(logins()).toEqual([expect.objectContaining({ actorEmail: "impersonated@example.com" })]);
|
|
556
|
+
const sessions = (await (await operator.get("/multi-session/list-device-sessions")).json()) as Array<{
|
|
557
|
+
session: { token: string };
|
|
558
|
+
user: { id: string };
|
|
559
|
+
}>;
|
|
560
|
+
const tokenOf = (id: string): string => sessions.find((entry) => entry.user.id === id)?.session.token ?? "";
|
|
561
|
+
expect((await operator.post("/multi-session/set-active", { sessionToken: tokenOf(operatorId) })).status).toBe(200);
|
|
562
|
+
expect(await userId(operator)).toBe(operatorId);
|
|
563
|
+
expect((await operator.post("/multi-session/revoke", { sessionToken: tokenOf(operatorId) })).status).toBe(200);
|
|
564
|
+
expect(await userId(operator)).toBe(targetId);
|
|
565
|
+
expect((await operator.post("/revoke-other-sessions", {})).status).toBe(200);
|
|
566
|
+
expect((await operator.post("/revoke-sessions", {})).status).toBe(200);
|
|
567
|
+
expect(logins()).toHaveLength(1);
|
|
568
|
+
|
|
569
|
+
const again = await new Browser(auth).post("/sign-in/email", { email: "operator@example.com", password: PASSWORD });
|
|
570
|
+
expect(again.status).toBe(200);
|
|
571
|
+
const signedOut = new Browser(auth);
|
|
572
|
+
await signedOut.post("/sign-in/email", { email: "operator@example.com", password: PASSWORD });
|
|
573
|
+
expect((await signedOut.post("/sign-out", {})).status).toBe(200);
|
|
574
|
+
expect(actions("auth.logout")).toHaveLength(1);
|
|
575
|
+
expect(logins()).toHaveLength(3);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
test("records nothing while auditing is off", async () => {
|
|
579
|
+
const { auth } = startAuth({ enabled: async () => false });
|
|
580
|
+
await signUp(auth, "off@example.com");
|
|
581
|
+
expect((await new Browser(auth).post("/sign-in/email", { email: "off@example.com", password: PASSWORD })).status).toBe(200);
|
|
582
|
+
expect(entries).toEqual([]);
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
describe("the app's Better Auth", () => {
|
|
587
|
+
test("runs the audit hook after every plugin's after hooks", () => {
|
|
588
|
+
const instance = buildAuth(envAuthConfig());
|
|
589
|
+
const plugins = instance.options.plugins as Array<{ id: string }>;
|
|
590
|
+
expect(plugins.at(-1)?.id).toBe("podokit-audit");
|
|
591
|
+
expect(instance.options.hooks?.after).toBeUndefined();
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
test("classifies every endpoint of Better Auth and PodoKit's plugins", () => {
|
|
595
|
+
const instance = buildAuth(envAuthConfig());
|
|
596
|
+
type Endpoints = Record<string, { path?: string }> | undefined;
|
|
597
|
+
const pathsOf = (endpoints: Endpoints): string[] =>
|
|
598
|
+
Object.values(endpoints ?? {}).flatMap((endpoint) => (endpoint.path ? [endpoint.path] : []));
|
|
599
|
+
const plugins = instance.options.plugins as Array<{ id: string; endpoints?: Endpoints }>;
|
|
600
|
+
const applications = new Set(
|
|
601
|
+
plugins.filter((plugin) => !PODOKIT_PLUGINS.has(plugin.id)).flatMap((plugin) => pathsOf(plugin.endpoints)),
|
|
602
|
+
);
|
|
603
|
+
const paths = new Set(pathsOf(instance.api as Endpoints).filter((path) => !applications.has(path)));
|
|
604
|
+
expect(Object.keys(SIGN_IN_METHODS).filter((path) => !paths.has(path))).toEqual([]);
|
|
605
|
+
const classified = (path: string): boolean =>
|
|
606
|
+
SIGN_IN_METHODS[path] !== undefined || NOT_SIGN_INS.has(path) || STARTS_NO_SESSION.has(path);
|
|
607
|
+
expect([...paths].filter((path) => !classified(path))).toEqual([]);
|
|
608
|
+
});
|
|
609
|
+
});
|