@stotles/better-auth-audit-logs 0.4.0-rc.1
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/LICENSE +21 -0
- package/README.md +319 -0
- package/dist/adapters/index.d.ts +1 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/memory.d.ts +13 -0
- package/dist/adapters/memory.d.ts.map +1 -0
- package/dist/client.cjs +77 -0
- package/dist/client.d.ts +10 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +13 -0
- package/dist/endpoints/get-log.d.ts +27 -0
- package/dist/endpoints/get-log.d.ts.map +1 -0
- package/dist/endpoints/index.d.ts +3 -0
- package/dist/endpoints/index.d.ts.map +1 -0
- package/dist/endpoints/insert-log.d.ts +44 -0
- package/dist/endpoints/insert-log.d.ts.map +1 -0
- package/dist/endpoints/list-logs.d.ts +40 -0
- package/dist/endpoints/list-logs.d.ts.map +1 -0
- package/dist/hooks/after.d.ts +6 -0
- package/dist/hooks/after.d.ts.map +1 -0
- package/dist/hooks/before.d.ts +6 -0
- package/dist/hooks/before.d.ts.map +1 -0
- package/dist/hooks/index.d.ts +2 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/index.cjs +828 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +762 -0
- package/dist/internal.d.ts +16 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/plugin.d.ts +180 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/schema.d.ts +109 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/types.d.ts +102 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/utils/index.d.ts +7 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/normalize-path.d.ts +1 -0
- package/dist/utils/normalize-path.d.ts.map +1 -0
- package/dist/utils/parse-metadata.d.ts +8 -0
- package/dist/utils/parse-metadata.d.ts.map +1 -0
- package/dist/utils/request-meta.d.ts +9 -0
- package/dist/utils/request-meta.d.ts.map +1 -0
- package/dist/utils/retry.d.ts +8 -0
- package/dist/utils/retry.d.ts.map +1 -0
- package/dist/utils/sanitize.d.ts +3 -0
- package/dist/utils/sanitize.d.ts.map +1 -0
- package/dist/utils/severity.d.ts +2 -0
- package/dist/utils/severity.d.ts.map +1 -0
- package/dist/utils/validate-entry.d.ts +8 -0
- package/dist/utils/validate-entry.d.ts.map +1 -0
- package/dist/utils/validate-metadata.d.ts +10 -0
- package/dist/utils/validate-metadata.d.ts.map +1 -0
- package/package.json +72 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,828 @@
|
|
|
1
|
+
var __create = Object.create;
|
|
2
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
function __accessProp(key) {
|
|
8
|
+
return this[key];
|
|
9
|
+
}
|
|
10
|
+
var __toESMCache_node;
|
|
11
|
+
var __toESMCache_esm;
|
|
12
|
+
var __toESM = (mod, isNodeMode, target) => {
|
|
13
|
+
var canCache = mod != null && typeof mod === "object";
|
|
14
|
+
if (canCache) {
|
|
15
|
+
var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap;
|
|
16
|
+
var cached = cache.get(mod);
|
|
17
|
+
if (cached)
|
|
18
|
+
return cached;
|
|
19
|
+
}
|
|
20
|
+
target = mod != null ? __create(__getProtoOf(mod)) : {};
|
|
21
|
+
const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
|
|
22
|
+
for (let key of __getOwnPropNames(mod))
|
|
23
|
+
if (!__hasOwnProp.call(to, key))
|
|
24
|
+
__defProp(to, key, {
|
|
25
|
+
get: __accessProp.bind(mod, key),
|
|
26
|
+
enumerable: true
|
|
27
|
+
});
|
|
28
|
+
if (canCache)
|
|
29
|
+
cache.set(mod, to);
|
|
30
|
+
return to;
|
|
31
|
+
};
|
|
32
|
+
var __toCommonJS = (from) => {
|
|
33
|
+
var entry = (__moduleCache ??= new WeakMap).get(from), desc;
|
|
34
|
+
if (entry)
|
|
35
|
+
return entry;
|
|
36
|
+
entry = __defProp({}, "__esModule", { value: true });
|
|
37
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
38
|
+
for (var key of __getOwnPropNames(from))
|
|
39
|
+
if (!__hasOwnProp.call(entry, key))
|
|
40
|
+
__defProp(entry, key, {
|
|
41
|
+
get: __accessProp.bind(from, key),
|
|
42
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
__moduleCache.set(from, entry);
|
|
46
|
+
return entry;
|
|
47
|
+
};
|
|
48
|
+
var __moduleCache;
|
|
49
|
+
var __returnValue = (v) => v;
|
|
50
|
+
function __exportSetter(name, newValue) {
|
|
51
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
52
|
+
}
|
|
53
|
+
var __export = (target, all) => {
|
|
54
|
+
for (var name in all)
|
|
55
|
+
__defProp(target, name, {
|
|
56
|
+
get: all[name],
|
|
57
|
+
enumerable: true,
|
|
58
|
+
configurable: true,
|
|
59
|
+
set: __exportSetter.bind(all, name)
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// src/index.ts
|
|
64
|
+
var exports_src = {};
|
|
65
|
+
__export(exports_src, {
|
|
66
|
+
auditLog: () => auditLog,
|
|
67
|
+
MemoryStorage: () => MemoryStorage
|
|
68
|
+
});
|
|
69
|
+
module.exports = __toCommonJS(exports_src);
|
|
70
|
+
|
|
71
|
+
// src/schema.ts
|
|
72
|
+
var import_db = require("better-auth/db");
|
|
73
|
+
var baseSchema = {
|
|
74
|
+
auditLog: {
|
|
75
|
+
modelName: "auditLog",
|
|
76
|
+
fields: {
|
|
77
|
+
userId: {
|
|
78
|
+
type: "string",
|
|
79
|
+
required: false,
|
|
80
|
+
references: {
|
|
81
|
+
model: "user",
|
|
82
|
+
field: "id",
|
|
83
|
+
onDelete: "set null"
|
|
84
|
+
},
|
|
85
|
+
index: true
|
|
86
|
+
},
|
|
87
|
+
action: {
|
|
88
|
+
type: "string",
|
|
89
|
+
required: true,
|
|
90
|
+
sortable: true,
|
|
91
|
+
index: true
|
|
92
|
+
},
|
|
93
|
+
status: {
|
|
94
|
+
type: "string",
|
|
95
|
+
required: true,
|
|
96
|
+
sortable: true
|
|
97
|
+
},
|
|
98
|
+
severity: {
|
|
99
|
+
type: "string",
|
|
100
|
+
required: true,
|
|
101
|
+
sortable: true
|
|
102
|
+
},
|
|
103
|
+
ipAddress: {
|
|
104
|
+
type: "string",
|
|
105
|
+
required: false
|
|
106
|
+
},
|
|
107
|
+
userAgent: {
|
|
108
|
+
type: "string",
|
|
109
|
+
required: false,
|
|
110
|
+
returned: false
|
|
111
|
+
},
|
|
112
|
+
metadata: {
|
|
113
|
+
type: "string",
|
|
114
|
+
required: false
|
|
115
|
+
},
|
|
116
|
+
createdAt: {
|
|
117
|
+
type: "date",
|
|
118
|
+
required: true,
|
|
119
|
+
sortable: true,
|
|
120
|
+
index: true,
|
|
121
|
+
defaultValue: () => new Date
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
function buildSchema(options) {
|
|
127
|
+
return import_db.mergeSchema(baseSchema, options?.schema);
|
|
128
|
+
}
|
|
129
|
+
function getModelName(options) {
|
|
130
|
+
return options?.schema?.auditLog?.modelName ?? "auditLog";
|
|
131
|
+
}
|
|
132
|
+
var CRITICAL_FIELDS = ["userId", "action", "status", "severity", "metadata", "createdAt"];
|
|
133
|
+
function validateSchema(schema) {
|
|
134
|
+
const model = schema.auditLog;
|
|
135
|
+
if (!model) {
|
|
136
|
+
throw new Error("[audit-log] Schema must define an auditLog model");
|
|
137
|
+
}
|
|
138
|
+
const fields = model.fields;
|
|
139
|
+
if (!fields || typeof fields !== "object") {
|
|
140
|
+
throw new Error("[audit-log] Schema auditLog model must have fields");
|
|
141
|
+
}
|
|
142
|
+
for (const field of CRITICAL_FIELDS) {
|
|
143
|
+
if (!(field in fields)) {
|
|
144
|
+
throw new Error(`[audit-log] Schema missing critical field: ${field}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// src/hooks/before.ts
|
|
150
|
+
var import_api = require("better-auth/api");
|
|
151
|
+
|
|
152
|
+
// src/utils/normalize-path.ts
|
|
153
|
+
function normalizePath(path) {
|
|
154
|
+
return path.replace(/^\//, "").replace(/\//g, ":");
|
|
155
|
+
}
|
|
156
|
+
// src/utils/severity.ts
|
|
157
|
+
var SEVERITY_MAP = new Map([
|
|
158
|
+
["ban-user", "critical"],
|
|
159
|
+
["impersonate-user", "critical"],
|
|
160
|
+
["delete-user", "high"],
|
|
161
|
+
["delete-account", "high"],
|
|
162
|
+
["revoke-sessions", "high"],
|
|
163
|
+
["revoke-other-sessions", "high"],
|
|
164
|
+
["sign-in", "medium"],
|
|
165
|
+
["sign-out", "medium"],
|
|
166
|
+
["revoke-session", "medium"],
|
|
167
|
+
["two-factor", "medium"],
|
|
168
|
+
["change-password", "medium"],
|
|
169
|
+
["reset-password", "medium"]
|
|
170
|
+
]);
|
|
171
|
+
function inferSeverity(action, status) {
|
|
172
|
+
for (const [pattern, severity] of SEVERITY_MAP) {
|
|
173
|
+
if (action.includes(pattern)) {
|
|
174
|
+
if (severity === "medium" && status === "failed")
|
|
175
|
+
return "high";
|
|
176
|
+
return severity;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return "low";
|
|
180
|
+
}
|
|
181
|
+
// src/utils/request-meta.ts
|
|
182
|
+
var betterAuthApi = __toESM(require("better-auth/api"));
|
|
183
|
+
var hasWarnedMissingGetIp = false;
|
|
184
|
+
function getIpWrapper(req, options, logger) {
|
|
185
|
+
if ("getIP" in betterAuthApi) {
|
|
186
|
+
return betterAuthApi.getIP(req, options);
|
|
187
|
+
}
|
|
188
|
+
if ("getIp" in betterAuthApi) {
|
|
189
|
+
return betterAuthApi.getIp(req, options);
|
|
190
|
+
}
|
|
191
|
+
if (!hasWarnedMissingGetIp) {
|
|
192
|
+
hasWarnedMissingGetIp = true;
|
|
193
|
+
logger?.error("[audit-log] better-auth/api exposes neither getIP nor getIp; " + "IP addresses will not be captured. This likely means an incompatible better-auth version.");
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
function extractRequestMeta(request, headers, options, logger) {
|
|
198
|
+
return {
|
|
199
|
+
ipAddress: request ? getIpWrapper(request, options, logger) ?? null : null,
|
|
200
|
+
userAgent: headers?.get("user-agent") ?? null
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
// src/utils/sanitize.ts
|
|
204
|
+
var DEFAULT_PII_FIELDS = [
|
|
205
|
+
"password",
|
|
206
|
+
"newPassword",
|
|
207
|
+
"currentPassword",
|
|
208
|
+
"token",
|
|
209
|
+
"secret",
|
|
210
|
+
"apiKey",
|
|
211
|
+
"refreshToken",
|
|
212
|
+
"accessToken",
|
|
213
|
+
"code",
|
|
214
|
+
"backupCode",
|
|
215
|
+
"otp"
|
|
216
|
+
];
|
|
217
|
+
async function sha256(value) {
|
|
218
|
+
const encoded = new TextEncoder().encode(value);
|
|
219
|
+
const buffer = await crypto.subtle.digest("SHA-256", encoded);
|
|
220
|
+
return Array.from(new Uint8Array(buffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
221
|
+
}
|
|
222
|
+
async function redactPII(data, config) {
|
|
223
|
+
if (!config.enabled)
|
|
224
|
+
return data;
|
|
225
|
+
const fields = config.fields ?? DEFAULT_PII_FIELDS;
|
|
226
|
+
const strategy = config.strategy ?? "mask";
|
|
227
|
+
const result = { ...data };
|
|
228
|
+
for (const field of fields) {
|
|
229
|
+
if (!(field in result) || result[field] == null)
|
|
230
|
+
continue;
|
|
231
|
+
if (strategy === "remove") {
|
|
232
|
+
delete result[field];
|
|
233
|
+
} else if (strategy === "hash") {
|
|
234
|
+
result[field] = await sha256(String(result[field]));
|
|
235
|
+
} else {
|
|
236
|
+
result[field] = "[REDACTED]";
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return result;
|
|
240
|
+
}
|
|
241
|
+
// src/utils/parse-metadata.ts
|
|
242
|
+
function parseMetadata(raw) {
|
|
243
|
+
if (raw == null)
|
|
244
|
+
return {};
|
|
245
|
+
if (typeof raw === "object" && !Array.isArray(raw)) {
|
|
246
|
+
return raw;
|
|
247
|
+
}
|
|
248
|
+
if (typeof raw === "string") {
|
|
249
|
+
try {
|
|
250
|
+
const parsed = JSON.parse(raw);
|
|
251
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
252
|
+
return parsed;
|
|
253
|
+
}
|
|
254
|
+
return {};
|
|
255
|
+
} catch {
|
|
256
|
+
return {};
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return {};
|
|
260
|
+
}
|
|
261
|
+
// src/utils/validate-entry.ts
|
|
262
|
+
var import_zod = require("zod");
|
|
263
|
+
var auditLogEntrySchema = import_zod.z.object({
|
|
264
|
+
userId: import_zod.z.string().nullable(),
|
|
265
|
+
action: import_zod.z.string().min(1),
|
|
266
|
+
status: import_zod.z.enum(["success", "failed"]),
|
|
267
|
+
severity: import_zod.z.enum(["low", "medium", "high", "critical"]),
|
|
268
|
+
ipAddress: import_zod.z.string().nullable(),
|
|
269
|
+
userAgent: import_zod.z.string().nullable(),
|
|
270
|
+
metadata: import_zod.z.record(import_zod.z.string(), import_zod.z.unknown()),
|
|
271
|
+
createdAt: import_zod.z.date()
|
|
272
|
+
});
|
|
273
|
+
function validateEntry(entry, logger) {
|
|
274
|
+
const result = auditLogEntrySchema.safeParse(entry);
|
|
275
|
+
if (!result.success) {
|
|
276
|
+
logger?.warn("[audit-log] beforeLog returned invalid entry, skipping write:", result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`));
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
return result.data;
|
|
280
|
+
}
|
|
281
|
+
// src/utils/retry.ts
|
|
282
|
+
async function withRetry(fn, opts) {
|
|
283
|
+
let lastError;
|
|
284
|
+
for (let attempt = 0;attempt <= opts.maxRetries; attempt++) {
|
|
285
|
+
try {
|
|
286
|
+
return await fn();
|
|
287
|
+
} catch (err) {
|
|
288
|
+
lastError = err;
|
|
289
|
+
if (attempt < opts.maxRetries) {
|
|
290
|
+
const delay = opts.baseDelayMs * Math.pow(2, attempt);
|
|
291
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
throw lastError;
|
|
296
|
+
}
|
|
297
|
+
// src/internal.ts
|
|
298
|
+
async function buildLogEntry(path, status, params) {
|
|
299
|
+
const action = normalizePath(path);
|
|
300
|
+
const severity = params.pathConfig?.severity ?? inferSeverity(action, status);
|
|
301
|
+
const captureOpts = {
|
|
302
|
+
...params.options.capture,
|
|
303
|
+
...params.pathConfig?.capture
|
|
304
|
+
};
|
|
305
|
+
const { ipAddress, userAgent } = extractRequestMeta(captureOpts.ipAddress !== false ? params.request : undefined, captureOpts.userAgent !== false ? params.headers : undefined, params.authOptions, params.logger);
|
|
306
|
+
let metadata = params.metadata ?? {};
|
|
307
|
+
if (params.options.piiRedaction.enabled) {
|
|
308
|
+
metadata = await redactPII(metadata, params.options.piiRedaction);
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
userId: params.userId,
|
|
312
|
+
action,
|
|
313
|
+
status,
|
|
314
|
+
severity,
|
|
315
|
+
ipAddress,
|
|
316
|
+
userAgent,
|
|
317
|
+
metadata,
|
|
318
|
+
createdAt: new Date
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
async function buildLogEntryFromAction(action, status, params) {
|
|
322
|
+
const severity = inferSeverity(action, status);
|
|
323
|
+
const { ipAddress, userAgent } = extractRequestMeta(params.options.capture.ipAddress !== false ? params.request : undefined, params.options.capture.userAgent !== false ? params.headers : undefined, params.authOptions, params.logger);
|
|
324
|
+
let metadata = params.metadata ?? {};
|
|
325
|
+
if (params.options.piiRedaction.enabled) {
|
|
326
|
+
metadata = await redactPII(metadata, params.options.piiRedaction);
|
|
327
|
+
}
|
|
328
|
+
return {
|
|
329
|
+
userId: params.userId,
|
|
330
|
+
action,
|
|
331
|
+
status,
|
|
332
|
+
severity,
|
|
333
|
+
ipAddress,
|
|
334
|
+
userAgent,
|
|
335
|
+
metadata,
|
|
336
|
+
createdAt: new Date
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
async function writeEntry(ctx, entry, opts, modelName) {
|
|
340
|
+
const doFullWrite = async () => {
|
|
341
|
+
let finalEntry = entry;
|
|
342
|
+
if (opts.beforeLog) {
|
|
343
|
+
const modified = await opts.beforeLog(finalEntry, ctx);
|
|
344
|
+
if (modified === null)
|
|
345
|
+
return;
|
|
346
|
+
const validated = validateEntry(modified, ctx.context.logger);
|
|
347
|
+
if (validated === null)
|
|
348
|
+
return;
|
|
349
|
+
finalEntry = validated;
|
|
350
|
+
}
|
|
351
|
+
let written;
|
|
352
|
+
try {
|
|
353
|
+
written = await withRetry(async () => {
|
|
354
|
+
if (opts.storage) {
|
|
355
|
+
const result = { id: crypto.randomUUID(), ...finalEntry };
|
|
356
|
+
await opts.storage.write(result);
|
|
357
|
+
return result;
|
|
358
|
+
}
|
|
359
|
+
const record = await ctx.context.adapter.create({
|
|
360
|
+
model: modelName,
|
|
361
|
+
data: {
|
|
362
|
+
...finalEntry,
|
|
363
|
+
metadata: JSON.stringify(finalEntry.metadata)
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
return {
|
|
367
|
+
...record,
|
|
368
|
+
metadata: finalEntry.metadata
|
|
369
|
+
};
|
|
370
|
+
}, { maxRetries: 2, baseDelayMs: 100 });
|
|
371
|
+
} catch (err) {
|
|
372
|
+
ctx.context.logger?.error("[audit-log] storage write failed after retries", err);
|
|
373
|
+
opts.onWriteError?.(err, finalEntry);
|
|
374
|
+
throw err;
|
|
375
|
+
}
|
|
376
|
+
if (opts.afterLog)
|
|
377
|
+
await opts.afterLog(written);
|
|
378
|
+
};
|
|
379
|
+
if (opts.nonBlocking) {
|
|
380
|
+
ctx.context.runInBackground(doFullWrite().catch((err) => {
|
|
381
|
+
ctx.context.logger?.error("[audit-log] background write failed", err);
|
|
382
|
+
opts.onWriteError?.(err, entry);
|
|
383
|
+
}));
|
|
384
|
+
} else {
|
|
385
|
+
await doFullWrite();
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// src/hooks/before.ts
|
|
390
|
+
function createBeforeHooks(opts, modelName) {
|
|
391
|
+
return [
|
|
392
|
+
{
|
|
393
|
+
matcher: (context) => !!context.path && opts.beforePaths.some((p) => context.path.startsWith(p)) && opts.shouldCapture(context.path),
|
|
394
|
+
handler: import_api.createAuthMiddleware(async (ctx) => {
|
|
395
|
+
try {
|
|
396
|
+
const session = await import_api.getSessionFromCtx(ctx);
|
|
397
|
+
const path = ctx.path;
|
|
398
|
+
const pathConfig = opts.getPathConfig(path);
|
|
399
|
+
const entry = await buildLogEntry(path, "success", {
|
|
400
|
+
userId: session?.user?.id ?? null,
|
|
401
|
+
request: ctx.request,
|
|
402
|
+
headers: ctx.headers,
|
|
403
|
+
pathConfig,
|
|
404
|
+
options: opts,
|
|
405
|
+
authOptions: ctx.context.options,
|
|
406
|
+
logger: ctx.context.logger
|
|
407
|
+
});
|
|
408
|
+
await writeEntry(ctx, entry, opts, modelName);
|
|
409
|
+
} catch (err) {
|
|
410
|
+
ctx.context.logger?.error("[audit-log] before hook failed", err);
|
|
411
|
+
}
|
|
412
|
+
})
|
|
413
|
+
}
|
|
414
|
+
];
|
|
415
|
+
}
|
|
416
|
+
// src/hooks/after.ts
|
|
417
|
+
var import_api2 = require("better-auth/api");
|
|
418
|
+
function createAfterHooks(opts, modelName) {
|
|
419
|
+
return [
|
|
420
|
+
{
|
|
421
|
+
matcher: (context) => !!context.path && !opts.beforePaths.some((p) => context.path.startsWith(p)) && opts.shouldCapture(context.path),
|
|
422
|
+
handler: import_api2.createAuthMiddleware(async (ctx) => {
|
|
423
|
+
try {
|
|
424
|
+
const path = ctx.path;
|
|
425
|
+
const isError = ctx.context.returned instanceof Error;
|
|
426
|
+
const status = isError ? "failed" : "success";
|
|
427
|
+
const user = ctx.context.newSession?.user ?? ctx.context.session?.user;
|
|
428
|
+
const pathConfig = opts.getPathConfig(path);
|
|
429
|
+
const metadata = {};
|
|
430
|
+
if (opts.capture.requestBody && ctx.body) {
|
|
431
|
+
metadata.requestBody = ctx.body;
|
|
432
|
+
}
|
|
433
|
+
if (isError) {
|
|
434
|
+
const err = ctx.context.returned;
|
|
435
|
+
metadata.error = {
|
|
436
|
+
message: err.message,
|
|
437
|
+
...err.status !== undefined && { status: err.status },
|
|
438
|
+
...err.code !== undefined && { code: err.code }
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
const entry = await buildLogEntry(path, status, {
|
|
442
|
+
userId: user?.id ?? null,
|
|
443
|
+
request: ctx.request,
|
|
444
|
+
headers: ctx.headers,
|
|
445
|
+
metadata,
|
|
446
|
+
pathConfig,
|
|
447
|
+
options: opts,
|
|
448
|
+
authOptions: ctx.context.options,
|
|
449
|
+
logger: ctx.context.logger
|
|
450
|
+
});
|
|
451
|
+
await writeEntry(ctx, entry, opts, modelName);
|
|
452
|
+
} catch (err) {
|
|
453
|
+
ctx.context.logger?.error("[audit-log] after hook failed", err);
|
|
454
|
+
}
|
|
455
|
+
})
|
|
456
|
+
}
|
|
457
|
+
];
|
|
458
|
+
}
|
|
459
|
+
// src/endpoints/list-logs.ts
|
|
460
|
+
var import_api3 = require("better-auth/api");
|
|
461
|
+
var import_zod2 = require("zod");
|
|
462
|
+
function createListLogsEndpoint(opts, modelName) {
|
|
463
|
+
return import_api3.createAuthEndpoint("/audit-log/list", {
|
|
464
|
+
method: "GET",
|
|
465
|
+
use: [import_api3.sessionMiddleware],
|
|
466
|
+
query: import_zod2.z.object({
|
|
467
|
+
userId: import_zod2.z.string().optional(),
|
|
468
|
+
action: import_zod2.z.string().optional(),
|
|
469
|
+
status: import_zod2.z.enum(["success", "failed"]).optional(),
|
|
470
|
+
from: import_zod2.z.string().optional(),
|
|
471
|
+
to: import_zod2.z.string().optional(),
|
|
472
|
+
limit: import_zod2.z.coerce.number().min(1).max(500).optional().default(50),
|
|
473
|
+
offset: import_zod2.z.coerce.number().min(0).optional().default(0)
|
|
474
|
+
})
|
|
475
|
+
}, async (ctx) => {
|
|
476
|
+
const session = ctx.context.session;
|
|
477
|
+
const targetUserId = ctx.query.userId ?? session.user.id;
|
|
478
|
+
if (targetUserId !== session.user.id) {
|
|
479
|
+
throw new import_api3.APIError("FORBIDDEN", {
|
|
480
|
+
message: "Cannot query other users' audit logs"
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
const fromDate = ctx.query.from ? new Date(ctx.query.from) : undefined;
|
|
484
|
+
const toDate = ctx.query.to ? new Date(ctx.query.to) : undefined;
|
|
485
|
+
try {
|
|
486
|
+
if (opts.storage?.read) {
|
|
487
|
+
const readOpts = {
|
|
488
|
+
userId: targetUserId,
|
|
489
|
+
action: ctx.query.action,
|
|
490
|
+
status: ctx.query.status,
|
|
491
|
+
from: fromDate,
|
|
492
|
+
to: toDate,
|
|
493
|
+
limit: ctx.query.limit,
|
|
494
|
+
offset: ctx.query.offset
|
|
495
|
+
};
|
|
496
|
+
const result = await opts.storage.read(readOpts);
|
|
497
|
+
if (result.entries.length > readOpts.limit) {
|
|
498
|
+
result.entries = result.entries.slice(0, readOpts.limit);
|
|
499
|
+
}
|
|
500
|
+
if (opts.piiRedaction.enabled) {
|
|
501
|
+
for (let i = 0;i < result.entries.length; i++) {
|
|
502
|
+
result.entries[i] = {
|
|
503
|
+
...result.entries[i],
|
|
504
|
+
metadata: await redactPII(result.entries[i].metadata, opts.piiRedaction)
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return ctx.json(result);
|
|
509
|
+
}
|
|
510
|
+
const where = [{ field: "userId", value: targetUserId }];
|
|
511
|
+
if (ctx.query.action) {
|
|
512
|
+
where.push({ field: "action", value: ctx.query.action });
|
|
513
|
+
}
|
|
514
|
+
if (ctx.query.status) {
|
|
515
|
+
where.push({ field: "status", value: ctx.query.status });
|
|
516
|
+
}
|
|
517
|
+
if (fromDate) {
|
|
518
|
+
where.push({ field: "createdAt", operator: "gte", value: fromDate });
|
|
519
|
+
}
|
|
520
|
+
if (toDate) {
|
|
521
|
+
where.push({ field: "createdAt", operator: "lte", value: toDate });
|
|
522
|
+
}
|
|
523
|
+
const [entries, total] = await Promise.all([
|
|
524
|
+
ctx.context.adapter.findMany({
|
|
525
|
+
model: modelName,
|
|
526
|
+
where,
|
|
527
|
+
sortBy: { field: "createdAt", direction: "desc" },
|
|
528
|
+
limit: ctx.query.limit,
|
|
529
|
+
offset: ctx.query.offset
|
|
530
|
+
}),
|
|
531
|
+
ctx.context.adapter.count({ model: modelName, where })
|
|
532
|
+
]);
|
|
533
|
+
let parsed = entries.map((e) => ({
|
|
534
|
+
...e,
|
|
535
|
+
metadata: parseMetadata(e["metadata"])
|
|
536
|
+
}));
|
|
537
|
+
if (opts.piiRedaction.enabled) {
|
|
538
|
+
parsed = await Promise.all(parsed.map(async (e) => ({
|
|
539
|
+
...e,
|
|
540
|
+
metadata: await redactPII(e.metadata, opts.piiRedaction)
|
|
541
|
+
})));
|
|
542
|
+
}
|
|
543
|
+
return ctx.json({ entries: parsed, total });
|
|
544
|
+
} catch (err) {
|
|
545
|
+
if (err instanceof import_api3.APIError)
|
|
546
|
+
throw err;
|
|
547
|
+
ctx.context.logger?.error("[audit-log] list failed", err);
|
|
548
|
+
throw new import_api3.APIError("INTERNAL_SERVER_ERROR", {
|
|
549
|
+
message: "Failed to retrieve audit logs"
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
// src/endpoints/get-log.ts
|
|
555
|
+
var import_api4 = require("better-auth/api");
|
|
556
|
+
function createGetLogEndpoint(opts, modelName) {
|
|
557
|
+
return import_api4.createAuthEndpoint("/audit-log/:id", { method: "GET", use: [import_api4.sessionMiddleware] }, async (ctx) => {
|
|
558
|
+
const id = ctx.params?.id;
|
|
559
|
+
if (!id) {
|
|
560
|
+
throw new import_api4.APIError("BAD_REQUEST", {
|
|
561
|
+
message: "Missing audit log entry id"
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
const session = ctx.context.session;
|
|
565
|
+
try {
|
|
566
|
+
if (opts.storage?.readById) {
|
|
567
|
+
const entry2 = await opts.storage.readById(id);
|
|
568
|
+
if (!entry2 || entry2.userId !== session.user.id) {
|
|
569
|
+
throw new import_api4.APIError("NOT_FOUND", {
|
|
570
|
+
message: "Audit log entry not found"
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
if (opts.piiRedaction.enabled) {
|
|
574
|
+
entry2.metadata = await redactPII(entry2.metadata, opts.piiRedaction);
|
|
575
|
+
}
|
|
576
|
+
return ctx.json(entry2);
|
|
577
|
+
}
|
|
578
|
+
const record = await ctx.context.adapter.findOne({
|
|
579
|
+
model: modelName,
|
|
580
|
+
where: [
|
|
581
|
+
{ field: "id", value: id },
|
|
582
|
+
{ field: "userId", value: session.user.id }
|
|
583
|
+
]
|
|
584
|
+
});
|
|
585
|
+
if (!record) {
|
|
586
|
+
throw new import_api4.APIError("NOT_FOUND", {
|
|
587
|
+
message: "Audit log entry not found"
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
let metadata = parseMetadata(record["metadata"]);
|
|
591
|
+
if (opts.piiRedaction.enabled) {
|
|
592
|
+
metadata = await redactPII(metadata, opts.piiRedaction);
|
|
593
|
+
}
|
|
594
|
+
const entry = {
|
|
595
|
+
...record,
|
|
596
|
+
metadata
|
|
597
|
+
};
|
|
598
|
+
return ctx.json(entry);
|
|
599
|
+
} catch (err) {
|
|
600
|
+
if (err instanceof import_api4.APIError)
|
|
601
|
+
throw err;
|
|
602
|
+
ctx.context.logger?.error("[audit-log] get failed", err);
|
|
603
|
+
throw new import_api4.APIError("INTERNAL_SERVER_ERROR", {
|
|
604
|
+
message: "Failed to retrieve audit log entry"
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
// src/endpoints/insert-log.ts
|
|
610
|
+
var import_api5 = require("better-auth/api");
|
|
611
|
+
var import_zod3 = require("zod");
|
|
612
|
+
|
|
613
|
+
// src/utils/validate-metadata.ts
|
|
614
|
+
var DEFAULT_METADATA_LIMITS = {
|
|
615
|
+
maxBytes: 64 * 1024,
|
|
616
|
+
maxDepth: 5
|
|
617
|
+
};
|
|
618
|
+
function measureDepth(value, current = 0) {
|
|
619
|
+
if (current > 100)
|
|
620
|
+
return current;
|
|
621
|
+
if (typeof value !== "object" || value === null)
|
|
622
|
+
return current;
|
|
623
|
+
if (Array.isArray(value)) {
|
|
624
|
+
let max2 = current;
|
|
625
|
+
for (const item of value) {
|
|
626
|
+
max2 = Math.max(max2, measureDepth(item, current + 1));
|
|
627
|
+
}
|
|
628
|
+
return max2;
|
|
629
|
+
}
|
|
630
|
+
let max = current;
|
|
631
|
+
for (const v of Object.values(value)) {
|
|
632
|
+
max = Math.max(max, measureDepth(v, current + 1));
|
|
633
|
+
}
|
|
634
|
+
return max;
|
|
635
|
+
}
|
|
636
|
+
function validateMetadataSize(metadata, limits = DEFAULT_METADATA_LIMITS) {
|
|
637
|
+
const depth = measureDepth(metadata);
|
|
638
|
+
if (depth > limits.maxDepth) {
|
|
639
|
+
return `Metadata exceeds maximum depth of ${limits.maxDepth}`;
|
|
640
|
+
}
|
|
641
|
+
const bytes = new TextEncoder().encode(JSON.stringify(metadata)).byteLength;
|
|
642
|
+
if (bytes > limits.maxBytes) {
|
|
643
|
+
return `Metadata exceeds maximum size of ${limits.maxBytes} bytes`;
|
|
644
|
+
}
|
|
645
|
+
return null;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// src/endpoints/insert-log.ts
|
|
649
|
+
function createInsertLogEndpoint(opts, modelName) {
|
|
650
|
+
return import_api5.createAuthEndpoint("/audit-log/insert", {
|
|
651
|
+
method: "POST",
|
|
652
|
+
use: [import_api5.sessionMiddleware],
|
|
653
|
+
body: import_zod3.z.object({
|
|
654
|
+
action: import_zod3.z.string().min(1),
|
|
655
|
+
status: import_zod3.z.enum(["success", "failed"]).optional().default("success"),
|
|
656
|
+
severity: import_zod3.z.enum(["low", "medium", "high", "critical"]).optional(),
|
|
657
|
+
metadata: import_zod3.z.record(import_zod3.z.string(), import_zod3.z.unknown()).optional().default({})
|
|
658
|
+
})
|
|
659
|
+
}, async (ctx) => {
|
|
660
|
+
const session = ctx.context.session;
|
|
661
|
+
const { action, status, severity, metadata } = ctx.body;
|
|
662
|
+
if (opts.metadataLimits !== false) {
|
|
663
|
+
const error = validateMetadataSize(metadata, opts.metadataLimits);
|
|
664
|
+
if (error) {
|
|
665
|
+
throw new import_api5.APIError("BAD_REQUEST", { message: error });
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
try {
|
|
669
|
+
const entry = await buildLogEntryFromAction(action, status, {
|
|
670
|
+
userId: session.user.id,
|
|
671
|
+
request: ctx.request,
|
|
672
|
+
headers: ctx.headers,
|
|
673
|
+
metadata,
|
|
674
|
+
options: opts,
|
|
675
|
+
authOptions: ctx.context.options,
|
|
676
|
+
logger: ctx.context.logger
|
|
677
|
+
});
|
|
678
|
+
if (severity) {
|
|
679
|
+
entry.severity = severity;
|
|
680
|
+
}
|
|
681
|
+
await writeEntry(ctx, entry, opts, modelName);
|
|
682
|
+
return ctx.json({ success: true });
|
|
683
|
+
} catch (err) {
|
|
684
|
+
if (err instanceof import_api5.APIError)
|
|
685
|
+
throw err;
|
|
686
|
+
ctx.context.logger?.error("[audit-log] insert failed", err);
|
|
687
|
+
throw new import_api5.APIError("INTERNAL_SERVER_ERROR", {
|
|
688
|
+
message: "Failed to insert audit log entry"
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
// src/plugin.ts
|
|
694
|
+
var DEFAULT_BEFORE_PATHS = [
|
|
695
|
+
"/sign-out",
|
|
696
|
+
"/delete-user",
|
|
697
|
+
"/revoke-session",
|
|
698
|
+
"/revoke-sessions",
|
|
699
|
+
"/revoke-other-sessions"
|
|
700
|
+
];
|
|
701
|
+
function validateStorageAdapter(storage) {
|
|
702
|
+
if (!storage)
|
|
703
|
+
return;
|
|
704
|
+
if (typeof storage.write !== "function") {
|
|
705
|
+
throw new Error("[audit-log] Custom storage adapter must implement write(entry): Promise<void>");
|
|
706
|
+
}
|
|
707
|
+
if (storage.read !== undefined && typeof storage.read !== "function") {
|
|
708
|
+
throw new Error("[audit-log] storage.read must be a function if provided");
|
|
709
|
+
}
|
|
710
|
+
if (storage.readById !== undefined && typeof storage.readById !== "function") {
|
|
711
|
+
throw new Error("[audit-log] storage.readById must be a function if provided");
|
|
712
|
+
}
|
|
713
|
+
if (storage.deleteOlderThan !== undefined && typeof storage.deleteOlderThan !== "function") {
|
|
714
|
+
throw new Error("[audit-log] storage.deleteOlderThan must be a function if provided");
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
function resolveOptions(options) {
|
|
718
|
+
const pathsMap = new Map;
|
|
719
|
+
const hasPaths = (options?.paths?.length ?? 0) > 0;
|
|
720
|
+
for (const p of options?.paths ?? []) {
|
|
721
|
+
if (typeof p === "string") {
|
|
722
|
+
pathsMap.set(p, undefined);
|
|
723
|
+
} else {
|
|
724
|
+
pathsMap.set(p.path, p.config);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
const metadataLimits = options?.metadataLimits === false ? false : {
|
|
728
|
+
maxBytes: options?.metadataLimits?.maxBytes ?? DEFAULT_METADATA_LIMITS.maxBytes,
|
|
729
|
+
maxDepth: options?.metadataLimits?.maxDepth ?? DEFAULT_METADATA_LIMITS.maxDepth
|
|
730
|
+
};
|
|
731
|
+
return {
|
|
732
|
+
enabled: options?.enabled ?? true,
|
|
733
|
+
nonBlocking: options?.nonBlocking ?? false,
|
|
734
|
+
storage: options?.storage,
|
|
735
|
+
capture: {
|
|
736
|
+
ipAddress: options?.capture?.ipAddress ?? true,
|
|
737
|
+
userAgent: options?.capture?.userAgent ?? true,
|
|
738
|
+
requestBody: options?.capture?.requestBody ?? false
|
|
739
|
+
},
|
|
740
|
+
piiRedaction: {
|
|
741
|
+
enabled: options?.piiRedaction?.enabled ?? false,
|
|
742
|
+
fields: options?.piiRedaction?.fields,
|
|
743
|
+
strategy: options?.piiRedaction?.strategy ?? "mask"
|
|
744
|
+
},
|
|
745
|
+
retention: options?.retention,
|
|
746
|
+
metadataLimits,
|
|
747
|
+
beforePaths: options?.beforePaths ?? DEFAULT_BEFORE_PATHS,
|
|
748
|
+
beforeLog: options?.beforeLog,
|
|
749
|
+
afterLog: options?.afterLog,
|
|
750
|
+
onWriteError: options?.onWriteError,
|
|
751
|
+
shouldCapture: (path) => !hasPaths || pathsMap.has(path),
|
|
752
|
+
getPathConfig: (path) => pathsMap.get(path)
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
function auditLog(options) {
|
|
756
|
+
validateStorageAdapter(options?.storage);
|
|
757
|
+
const schema = buildSchema(options);
|
|
758
|
+
validateSchema(schema);
|
|
759
|
+
const modelName = getModelName(options);
|
|
760
|
+
const resolved = resolveOptions(options);
|
|
761
|
+
const beforeHooks = resolved.enabled ? createBeforeHooks(resolved, modelName) : [];
|
|
762
|
+
const afterHooks = resolved.enabled ? createAfterHooks(resolved, modelName) : [];
|
|
763
|
+
return {
|
|
764
|
+
id: "audit-log",
|
|
765
|
+
schema,
|
|
766
|
+
hooks: {
|
|
767
|
+
before: beforeHooks,
|
|
768
|
+
after: afterHooks
|
|
769
|
+
},
|
|
770
|
+
endpoints: {
|
|
771
|
+
listAuditLogs: createListLogsEndpoint(resolved, modelName),
|
|
772
|
+
getAuditLog: createGetLogEndpoint(resolved, modelName),
|
|
773
|
+
insertAuditLog: createInsertLogEndpoint(resolved, modelName)
|
|
774
|
+
},
|
|
775
|
+
rateLimit: [
|
|
776
|
+
{
|
|
777
|
+
pathMatcher: (path) => path.startsWith("/audit-log/"),
|
|
778
|
+
window: 60,
|
|
779
|
+
max: 60
|
|
780
|
+
}
|
|
781
|
+
]
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
// src/adapters/memory.ts
|
|
785
|
+
var DEFAULT_MAX_ENTRIES = 1e4;
|
|
786
|
+
|
|
787
|
+
class MemoryStorage {
|
|
788
|
+
entries = [];
|
|
789
|
+
maxEntries;
|
|
790
|
+
constructor(opts) {
|
|
791
|
+
this.maxEntries = opts?.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
792
|
+
}
|
|
793
|
+
async write(entry) {
|
|
794
|
+
this.entries.push(entry);
|
|
795
|
+
if (this.entries.length > this.maxEntries) {
|
|
796
|
+
const excess = this.entries.length - this.maxEntries;
|
|
797
|
+
this.entries.splice(0, excess);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
async read(opts) {
|
|
801
|
+
let filtered = this.entries.filter((e) => {
|
|
802
|
+
if (opts.userId !== undefined && e.userId !== opts.userId)
|
|
803
|
+
return false;
|
|
804
|
+
if (opts.action !== undefined && e.action !== opts.action)
|
|
805
|
+
return false;
|
|
806
|
+
if (opts.status !== undefined && e.status !== opts.status)
|
|
807
|
+
return false;
|
|
808
|
+
if (opts.from !== undefined && e.createdAt < opts.from)
|
|
809
|
+
return false;
|
|
810
|
+
if (opts.to !== undefined && e.createdAt > opts.to)
|
|
811
|
+
return false;
|
|
812
|
+
return true;
|
|
813
|
+
});
|
|
814
|
+
const total = filtered.length;
|
|
815
|
+
filtered = filtered.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()).slice(opts.offset, opts.offset + opts.limit);
|
|
816
|
+
return { entries: filtered, total };
|
|
817
|
+
}
|
|
818
|
+
async readById(id) {
|
|
819
|
+
return this.entries.find((e) => e.id === id) ?? null;
|
|
820
|
+
}
|
|
821
|
+
async deleteOlderThan(date) {
|
|
822
|
+
const before = this.entries.length;
|
|
823
|
+
const retained = this.entries.filter((e) => e.createdAt >= date);
|
|
824
|
+
this.entries.length = 0;
|
|
825
|
+
this.entries.push(...retained);
|
|
826
|
+
return before - this.entries.length;
|
|
827
|
+
}
|
|
828
|
+
}
|