@invect/user-auth 0.0.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 +106 -0
- package/dist/backend/index.cjs +1166 -0
- package/dist/backend/index.cjs.map +1 -0
- package/dist/backend/index.d.ts +42 -0
- package/dist/backend/index.d.ts.map +1 -0
- package/dist/backend/index.mjs +1164 -0
- package/dist/backend/index.mjs.map +1 -0
- package/dist/backend/plugin.d.ts +62 -0
- package/dist/backend/plugin.d.ts.map +1 -0
- package/dist/backend/types.d.ts +299 -0
- package/dist/backend/types.d.ts.map +1 -0
- package/dist/frontend/components/AuthGate.d.ts +17 -0
- package/dist/frontend/components/AuthGate.d.ts.map +1 -0
- package/dist/frontend/components/AuthenticatedInvect.d.ts +129 -0
- package/dist/frontend/components/AuthenticatedInvect.d.ts.map +1 -0
- package/dist/frontend/components/ProfilePage.d.ts +10 -0
- package/dist/frontend/components/ProfilePage.d.ts.map +1 -0
- package/dist/frontend/components/SidebarUserMenu.d.ts +12 -0
- package/dist/frontend/components/SidebarUserMenu.d.ts.map +1 -0
- package/dist/frontend/components/SignInForm.d.ts +14 -0
- package/dist/frontend/components/SignInForm.d.ts.map +1 -0
- package/dist/frontend/components/SignInPage.d.ts +19 -0
- package/dist/frontend/components/SignInPage.d.ts.map +1 -0
- package/dist/frontend/components/UserButton.d.ts +15 -0
- package/dist/frontend/components/UserButton.d.ts.map +1 -0
- package/dist/frontend/components/UserManagement.d.ts +19 -0
- package/dist/frontend/components/UserManagement.d.ts.map +1 -0
- package/dist/frontend/components/UserManagementPage.d.ts +9 -0
- package/dist/frontend/components/UserManagementPage.d.ts.map +1 -0
- package/dist/frontend/index.cjs +1262 -0
- package/dist/frontend/index.cjs.map +1 -0
- package/dist/frontend/index.d.ts +29 -0
- package/dist/frontend/index.d.ts.map +1 -0
- package/dist/frontend/index.mjs +1250 -0
- package/dist/frontend/index.mjs.map +1 -0
- package/dist/frontend/plugins/authFrontendPlugin.d.ts +14 -0
- package/dist/frontend/plugins/authFrontendPlugin.d.ts.map +1 -0
- package/dist/frontend/providers/AuthProvider.d.ts +46 -0
- package/dist/frontend/providers/AuthProvider.d.ts.map +1 -0
- package/dist/roles-BOY5N82v.cjs +74 -0
- package/dist/roles-BOY5N82v.cjs.map +1 -0
- package/dist/roles-CZuKFEpJ.mjs +33 -0
- package/dist/roles-CZuKFEpJ.mjs.map +1 -0
- package/dist/shared/roles.d.ts +11 -0
- package/dist/shared/roles.d.ts.map +1 -0
- package/dist/shared/types.cjs +0 -0
- package/dist/shared/types.d.ts +46 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/shared/types.mjs +1 -0
- package/package.json +116 -0
|
@@ -0,0 +1,1164 @@
|
|
|
1
|
+
import { i as AUTH_VISIBLE_ROLES, n as AUTH_ASSIGNABLE_ROLES, o as isAuthAssignableRole, r as AUTH_DEFAULT_ROLE, s as isAuthVisibleRole, t as AUTH_ADMIN_ROLE } from "../roles-CZuKFEpJ.mjs";
|
|
2
|
+
//#region src/backend/plugin.ts
|
|
3
|
+
const DEFAULT_PREFIX = "auth";
|
|
4
|
+
/**
|
|
5
|
+
* Default role mapping: keep admin/RBAC roles aligned and fall back to default.
|
|
6
|
+
*/
|
|
7
|
+
function defaultMapRole(role) {
|
|
8
|
+
if (!role) return AUTH_DEFAULT_ROLE;
|
|
9
|
+
if (role === "viewer" || role === "readonly") return "viewer";
|
|
10
|
+
if (isAuthVisibleRole(role)) return role;
|
|
11
|
+
return AUTH_DEFAULT_ROLE;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Default user → identity mapping.
|
|
15
|
+
*/
|
|
16
|
+
function defaultMapUser(user, _session, mapRole) {
|
|
17
|
+
const resolvedRole = mapRole(user.role);
|
|
18
|
+
return {
|
|
19
|
+
id: user.id,
|
|
20
|
+
name: user.name ?? user.email ?? void 0,
|
|
21
|
+
role: resolvedRole,
|
|
22
|
+
permissions: resolvedRole === "admin" ? ["admin:*"] : void 0,
|
|
23
|
+
resourceAccess: resolvedRole === "admin" ? {
|
|
24
|
+
flows: "*",
|
|
25
|
+
credentials: "*"
|
|
26
|
+
} : void 0
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Simple in-memory sliding-window rate limiter.
|
|
31
|
+
*
|
|
32
|
+
* Keyed by IP address (or a fallback identifier). Tracks request timestamps
|
|
33
|
+
* per window and rejects requests that exceed the limit with HTTP 429.
|
|
34
|
+
*
|
|
35
|
+
* Only applied to authentication-sensitive endpoints (sign-in, sign-up,
|
|
36
|
+
* password reset) to prevent brute-force attacks. Session reads (GET) are
|
|
37
|
+
* not rate-limited.
|
|
38
|
+
*/
|
|
39
|
+
var RateLimiter = class {
|
|
40
|
+
windows = /* @__PURE__ */ new Map();
|
|
41
|
+
maxRequests;
|
|
42
|
+
windowMs;
|
|
43
|
+
constructor(maxRequests = 10, windowMs = 6e4) {
|
|
44
|
+
this.maxRequests = maxRequests;
|
|
45
|
+
this.windowMs = windowMs;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Returns `true` if the request should be rejected (over limit).
|
|
49
|
+
*/
|
|
50
|
+
isRateLimited(key) {
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
const windowStart = now - this.windowMs;
|
|
53
|
+
let timestamps = this.windows.get(key);
|
|
54
|
+
if (!timestamps) {
|
|
55
|
+
timestamps = [];
|
|
56
|
+
this.windows.set(key, timestamps);
|
|
57
|
+
}
|
|
58
|
+
const valid = timestamps.filter((t) => t > windowStart);
|
|
59
|
+
this.windows.set(key, valid);
|
|
60
|
+
if (valid.length >= this.maxRequests) {
|
|
61
|
+
const retryAfterMs = (valid[0] ?? now) + this.windowMs - now;
|
|
62
|
+
return {
|
|
63
|
+
limited: true,
|
|
64
|
+
retryAfterMs: Math.max(retryAfterMs, 1e3)
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
valid.push(now);
|
|
68
|
+
return { limited: false };
|
|
69
|
+
}
|
|
70
|
+
/** Periodic cleanup of stale keys to prevent memory leaks. */
|
|
71
|
+
cleanup() {
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
for (const [key, timestamps] of this.windows) {
|
|
74
|
+
const valid = timestamps.filter((t) => t > now - this.windowMs);
|
|
75
|
+
if (valid.length === 0) this.windows.delete(key);
|
|
76
|
+
else this.windows.set(key, valid);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
/** Auth-sensitive path segments that should be rate-limited. */
|
|
81
|
+
const RATE_LIMITED_AUTH_PATHS = [
|
|
82
|
+
"/sign-in/",
|
|
83
|
+
"/sign-up/",
|
|
84
|
+
"/forgot-password",
|
|
85
|
+
"/reset-password"
|
|
86
|
+
];
|
|
87
|
+
/**
|
|
88
|
+
* Convert a Node.js-style `IncomingHttpHeaders` record or a `Headers` instance
|
|
89
|
+
* to a standard `Headers` object for passing to better-auth.
|
|
90
|
+
*/
|
|
91
|
+
function toHeaders(raw) {
|
|
92
|
+
if (raw instanceof Headers) return raw;
|
|
93
|
+
const headers = new Headers();
|
|
94
|
+
for (const [key, value] of Object.entries(raw)) if (value !== void 0) headers.set(key, value);
|
|
95
|
+
return headers;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Resolve the session from a better-auth instance using request headers.
|
|
99
|
+
*/
|
|
100
|
+
async function resolveSession(auth, headers) {
|
|
101
|
+
if (!auth) return null;
|
|
102
|
+
const h = toHeaders(headers);
|
|
103
|
+
try {
|
|
104
|
+
console.log("[auth-debug] resolveSession: cookie header =", h.get("cookie")?.slice(0, 80));
|
|
105
|
+
const result = await auth.api.getSession({ headers: h });
|
|
106
|
+
console.log("[auth-debug] resolveSession: result keys =", result ? Object.keys(result) : "null");
|
|
107
|
+
if (result?.session && result?.user) return {
|
|
108
|
+
session: result.session,
|
|
109
|
+
user: result.user
|
|
110
|
+
};
|
|
111
|
+
return null;
|
|
112
|
+
} catch (err) {
|
|
113
|
+
console.error("[auth-debug] resolveSession threw:", err?.message ?? err);
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async function callBetterAuthHandler(auth, request, path, init) {
|
|
118
|
+
if (!auth) return null;
|
|
119
|
+
const basePath = auth.options?.basePath ?? "/api/auth";
|
|
120
|
+
const targetUrl = new URL(`${basePath}${path}`, request.url);
|
|
121
|
+
for (const [key, value] of Object.entries(init?.query ?? {})) if (value !== void 0) targetUrl.searchParams.set(key, value);
|
|
122
|
+
const headers = new Headers(request.headers);
|
|
123
|
+
const hasBody = init?.body !== void 0;
|
|
124
|
+
if (hasBody && !headers.has("content-type")) headers.set("content-type", "application/json");
|
|
125
|
+
const authRequest = new Request(targetUrl.toString(), {
|
|
126
|
+
method: init?.method ?? "GET",
|
|
127
|
+
headers,
|
|
128
|
+
body: hasBody ? JSON.stringify(init?.body) : void 0
|
|
129
|
+
});
|
|
130
|
+
const response = await auth.handler(authRequest);
|
|
131
|
+
const text = await response.text();
|
|
132
|
+
if (!text) return {
|
|
133
|
+
status: response.status,
|
|
134
|
+
body: null
|
|
135
|
+
};
|
|
136
|
+
try {
|
|
137
|
+
return {
|
|
138
|
+
status: response.status,
|
|
139
|
+
body: JSON.parse(text)
|
|
140
|
+
};
|
|
141
|
+
} catch {
|
|
142
|
+
return {
|
|
143
|
+
status: response.status,
|
|
144
|
+
body: text
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
async function getAuthContext(auth) {
|
|
149
|
+
if (!auth) return null;
|
|
150
|
+
try {
|
|
151
|
+
return await auth.$context ?? null;
|
|
152
|
+
} catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function isBetterAuthUser(value) {
|
|
157
|
+
return !!value && typeof value === "object" && "id" in value && typeof value.id === "string";
|
|
158
|
+
}
|
|
159
|
+
function unwrapFoundUser(result) {
|
|
160
|
+
if (!result) return null;
|
|
161
|
+
if (typeof result === "object" && "user" in result) {
|
|
162
|
+
const nestedUser = result.user;
|
|
163
|
+
if (isBetterAuthUser(nestedUser)) return nestedUser;
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
if (isBetterAuthUser(result)) return result;
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
function toAuthApiErrorResponse(fallbackError, error) {
|
|
170
|
+
if (error instanceof Response) return {
|
|
171
|
+
status: error.status || 500,
|
|
172
|
+
body: {
|
|
173
|
+
error: fallbackError,
|
|
174
|
+
message: error.statusText || fallbackError
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
const status = error && typeof error === "object" && "status" in error && typeof error.status === "number" ? error.status ?? 500 : error && typeof error === "object" && "statusCode" in error && typeof error.statusCode === "number" ? error.statusCode ?? 500 : 500;
|
|
178
|
+
const message = error && typeof error === "object" && "message" in error && typeof error.message === "string" ? error.message || fallbackError : fallbackError;
|
|
179
|
+
const code = error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : void 0;
|
|
180
|
+
return {
|
|
181
|
+
status,
|
|
182
|
+
body: {
|
|
183
|
+
error: fallbackError,
|
|
184
|
+
message,
|
|
185
|
+
...code ? { code } : {}
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
function sanitizeForLogging(value) {
|
|
190
|
+
if (Array.isArray(value)) return value.map((item) => sanitizeForLogging(item));
|
|
191
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, nestedValue]) => {
|
|
192
|
+
if (/password|token|secret/i.test(key)) return [key, "[REDACTED]"];
|
|
193
|
+
return [key, sanitizeForLogging(nestedValue)];
|
|
194
|
+
}));
|
|
195
|
+
return value;
|
|
196
|
+
}
|
|
197
|
+
function getErrorLogDetails(error) {
|
|
198
|
+
if (error instanceof Response) return {
|
|
199
|
+
type: "Response",
|
|
200
|
+
status: error.status,
|
|
201
|
+
statusText: error.statusText
|
|
202
|
+
};
|
|
203
|
+
if (error instanceof Error) return {
|
|
204
|
+
name: error.name,
|
|
205
|
+
message: error.message,
|
|
206
|
+
stack: error.stack,
|
|
207
|
+
...error && typeof error === "object" && "cause" in error ? { cause: sanitizeForLogging(error.cause) } : {},
|
|
208
|
+
...error && typeof error === "object" && "code" in error ? { code: error.code } : {},
|
|
209
|
+
...error && typeof error === "object" && "status" in error ? { status: error.status } : {},
|
|
210
|
+
...error && typeof error === "object" && "statusCode" in error ? { statusCode: error.statusCode } : {}
|
|
211
|
+
};
|
|
212
|
+
if (error && typeof error === "object") return sanitizeForLogging(error);
|
|
213
|
+
return { value: error };
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Abstract schema for better-auth's database tables.
|
|
217
|
+
*
|
|
218
|
+
* These definitions allow the Invect CLI (`npx invect generate`) to include
|
|
219
|
+
* the better-auth tables when generating Drizzle/Prisma schema files.
|
|
220
|
+
*
|
|
221
|
+
* The shapes match better-auth's default table structure. If your better-auth
|
|
222
|
+
* config adds extra fields (e.g., via plugins like `twoFactor`, `organization`),
|
|
223
|
+
* you can extend these in your own config.
|
|
224
|
+
*/
|
|
225
|
+
const BETTER_AUTH_SCHEMA = {
|
|
226
|
+
user: {
|
|
227
|
+
tableName: "user",
|
|
228
|
+
order: 1,
|
|
229
|
+
fields: {
|
|
230
|
+
id: {
|
|
231
|
+
type: "string",
|
|
232
|
+
primaryKey: true
|
|
233
|
+
},
|
|
234
|
+
name: {
|
|
235
|
+
type: "string",
|
|
236
|
+
required: true
|
|
237
|
+
},
|
|
238
|
+
email: {
|
|
239
|
+
type: "string",
|
|
240
|
+
required: true,
|
|
241
|
+
unique: true
|
|
242
|
+
},
|
|
243
|
+
emailVerified: {
|
|
244
|
+
type: "boolean",
|
|
245
|
+
required: true,
|
|
246
|
+
defaultValue: false
|
|
247
|
+
},
|
|
248
|
+
image: {
|
|
249
|
+
type: "string",
|
|
250
|
+
required: false
|
|
251
|
+
},
|
|
252
|
+
role: {
|
|
253
|
+
type: "string",
|
|
254
|
+
required: false,
|
|
255
|
+
defaultValue: AUTH_DEFAULT_ROLE
|
|
256
|
+
},
|
|
257
|
+
banned: {
|
|
258
|
+
type: "boolean",
|
|
259
|
+
required: false,
|
|
260
|
+
defaultValue: false
|
|
261
|
+
},
|
|
262
|
+
banReason: {
|
|
263
|
+
type: "string",
|
|
264
|
+
required: false
|
|
265
|
+
},
|
|
266
|
+
banExpires: {
|
|
267
|
+
type: "date",
|
|
268
|
+
required: false
|
|
269
|
+
},
|
|
270
|
+
createdAt: {
|
|
271
|
+
type: "date",
|
|
272
|
+
required: true,
|
|
273
|
+
defaultValue: "now()"
|
|
274
|
+
},
|
|
275
|
+
updatedAt: {
|
|
276
|
+
type: "date",
|
|
277
|
+
required: true,
|
|
278
|
+
defaultValue: "now()"
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
session: {
|
|
283
|
+
tableName: "session",
|
|
284
|
+
order: 2,
|
|
285
|
+
fields: {
|
|
286
|
+
id: {
|
|
287
|
+
type: "string",
|
|
288
|
+
primaryKey: true
|
|
289
|
+
},
|
|
290
|
+
expiresAt: {
|
|
291
|
+
type: "date",
|
|
292
|
+
required: true
|
|
293
|
+
},
|
|
294
|
+
token: {
|
|
295
|
+
type: "string",
|
|
296
|
+
required: true,
|
|
297
|
+
unique: true
|
|
298
|
+
},
|
|
299
|
+
createdAt: {
|
|
300
|
+
type: "date",
|
|
301
|
+
required: true,
|
|
302
|
+
defaultValue: "now()"
|
|
303
|
+
},
|
|
304
|
+
updatedAt: {
|
|
305
|
+
type: "date",
|
|
306
|
+
required: true,
|
|
307
|
+
defaultValue: "now()"
|
|
308
|
+
},
|
|
309
|
+
ipAddress: {
|
|
310
|
+
type: "string",
|
|
311
|
+
required: false
|
|
312
|
+
},
|
|
313
|
+
userAgent: {
|
|
314
|
+
type: "string",
|
|
315
|
+
required: false
|
|
316
|
+
},
|
|
317
|
+
impersonatedBy: {
|
|
318
|
+
type: "string",
|
|
319
|
+
required: false
|
|
320
|
+
},
|
|
321
|
+
userId: {
|
|
322
|
+
type: "string",
|
|
323
|
+
required: true,
|
|
324
|
+
references: {
|
|
325
|
+
table: "user",
|
|
326
|
+
field: "id",
|
|
327
|
+
onDelete: "cascade"
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
account: {
|
|
333
|
+
tableName: "account",
|
|
334
|
+
order: 2,
|
|
335
|
+
fields: {
|
|
336
|
+
id: {
|
|
337
|
+
type: "string",
|
|
338
|
+
primaryKey: true
|
|
339
|
+
},
|
|
340
|
+
accountId: {
|
|
341
|
+
type: "string",
|
|
342
|
+
required: true
|
|
343
|
+
},
|
|
344
|
+
providerId: {
|
|
345
|
+
type: "string",
|
|
346
|
+
required: true
|
|
347
|
+
},
|
|
348
|
+
userId: {
|
|
349
|
+
type: "string",
|
|
350
|
+
required: true,
|
|
351
|
+
references: {
|
|
352
|
+
table: "user",
|
|
353
|
+
field: "id",
|
|
354
|
+
onDelete: "cascade"
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
accessToken: {
|
|
358
|
+
type: "string",
|
|
359
|
+
required: false
|
|
360
|
+
},
|
|
361
|
+
refreshToken: {
|
|
362
|
+
type: "string",
|
|
363
|
+
required: false
|
|
364
|
+
},
|
|
365
|
+
idToken: {
|
|
366
|
+
type: "string",
|
|
367
|
+
required: false
|
|
368
|
+
},
|
|
369
|
+
accessTokenExpiresAt: {
|
|
370
|
+
type: "date",
|
|
371
|
+
required: false
|
|
372
|
+
},
|
|
373
|
+
refreshTokenExpiresAt: {
|
|
374
|
+
type: "date",
|
|
375
|
+
required: false
|
|
376
|
+
},
|
|
377
|
+
scope: {
|
|
378
|
+
type: "string",
|
|
379
|
+
required: false
|
|
380
|
+
},
|
|
381
|
+
password: {
|
|
382
|
+
type: "string",
|
|
383
|
+
required: false
|
|
384
|
+
},
|
|
385
|
+
createdAt: {
|
|
386
|
+
type: "date",
|
|
387
|
+
required: true,
|
|
388
|
+
defaultValue: "now()"
|
|
389
|
+
},
|
|
390
|
+
updatedAt: {
|
|
391
|
+
type: "date",
|
|
392
|
+
required: true,
|
|
393
|
+
defaultValue: "now()"
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
},
|
|
397
|
+
verification: {
|
|
398
|
+
tableName: "verification",
|
|
399
|
+
order: 2,
|
|
400
|
+
fields: {
|
|
401
|
+
id: {
|
|
402
|
+
type: "string",
|
|
403
|
+
primaryKey: true
|
|
404
|
+
},
|
|
405
|
+
identifier: {
|
|
406
|
+
type: "string",
|
|
407
|
+
required: true
|
|
408
|
+
},
|
|
409
|
+
value: {
|
|
410
|
+
type: "string",
|
|
411
|
+
required: true
|
|
412
|
+
},
|
|
413
|
+
expiresAt: {
|
|
414
|
+
type: "date",
|
|
415
|
+
required: true
|
|
416
|
+
},
|
|
417
|
+
createdAt: {
|
|
418
|
+
type: "date",
|
|
419
|
+
required: false
|
|
420
|
+
},
|
|
421
|
+
updatedAt: {
|
|
422
|
+
type: "date",
|
|
423
|
+
required: false
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
/**
|
|
429
|
+
* Create a better-auth instance internally using Invect's database config.
|
|
430
|
+
*
|
|
431
|
+
* Dynamically imports `better-auth` (a required peer dependency) and creates
|
|
432
|
+
* a fully-configured instance with email/password auth, the admin plugin,
|
|
433
|
+
* and session caching.
|
|
434
|
+
*
|
|
435
|
+
* Database resolution order:
|
|
436
|
+
* 1. Explicit `options.database` (any value `betterAuth({ database })` accepts)
|
|
437
|
+
* 2. Auto-created client from Invect's `baseDatabaseConfig.connectionString`
|
|
438
|
+
*/
|
|
439
|
+
async function createInternalBetterAuth(invectConfig, options, logger) {
|
|
440
|
+
let betterAuthFn;
|
|
441
|
+
let adminPlugin;
|
|
442
|
+
try {
|
|
443
|
+
betterAuthFn = (await import("better-auth")).betterAuth;
|
|
444
|
+
} catch {
|
|
445
|
+
throw new Error("Could not import \"better-auth\". It is a required peer dependency of @invect/user-auth. Install it with: npm install better-auth");
|
|
446
|
+
}
|
|
447
|
+
try {
|
|
448
|
+
adminPlugin = (await import("better-auth/plugins")).admin;
|
|
449
|
+
} catch {
|
|
450
|
+
throw new Error("Could not import \"better-auth/plugins\". Ensure better-auth is properly installed.");
|
|
451
|
+
}
|
|
452
|
+
let database = options.database;
|
|
453
|
+
if (!database) {
|
|
454
|
+
const dbConfig = invectConfig.baseDatabaseConfig;
|
|
455
|
+
if (!dbConfig?.connectionString) throw new Error("Cannot create internal better-auth instance: no database configuration found. Either provide `auth` (a better-auth instance), `database`, or ensure Invect baseDatabaseConfig has a connectionString.");
|
|
456
|
+
const connStr = dbConfig.connectionString;
|
|
457
|
+
const dbType = (dbConfig.type ?? "sqlite").toLowerCase();
|
|
458
|
+
if (dbType === "sqlite") database = await createSQLiteClient(connStr, logger);
|
|
459
|
+
else if (dbType === "pg" || dbType === "postgresql") database = await createPostgresPool(connStr);
|
|
460
|
+
else if (dbType === "mysql") database = await createMySQLPool(connStr);
|
|
461
|
+
else throw new Error(`Unsupported database type for internal better-auth: "${dbType}". Supported: sqlite, pg, mysql. Alternatively, provide your own better-auth instance via \`auth\`.`);
|
|
462
|
+
}
|
|
463
|
+
const baseURL = options.baseURL ?? process.env.BETTER_AUTH_URL ?? `http://localhost:${process.env.PORT ?? "3000"}`;
|
|
464
|
+
const trustedOrigins = options.trustedOrigins ?? ((request) => {
|
|
465
|
+
const trusted = new Set([
|
|
466
|
+
baseURL,
|
|
467
|
+
"http://localhost:5173",
|
|
468
|
+
"http://localhost:5174"
|
|
469
|
+
]);
|
|
470
|
+
try {
|
|
471
|
+
if (request) trusted.add(new URL(request.url).origin);
|
|
472
|
+
} catch {}
|
|
473
|
+
return Array.from(trusted);
|
|
474
|
+
});
|
|
475
|
+
const passthrough = options.betterAuthOptions ?? {};
|
|
476
|
+
const emailAndPassword = {
|
|
477
|
+
enabled: true,
|
|
478
|
+
...passthrough.emailAndPassword
|
|
479
|
+
};
|
|
480
|
+
const session = {
|
|
481
|
+
cookieCache: {
|
|
482
|
+
enabled: true,
|
|
483
|
+
maxAge: 300
|
|
484
|
+
},
|
|
485
|
+
...passthrough.session,
|
|
486
|
+
...passthrough.session?.cookieCache ? { cookieCache: {
|
|
487
|
+
enabled: true,
|
|
488
|
+
maxAge: 300,
|
|
489
|
+
...passthrough.session.cookieCache
|
|
490
|
+
} } : {}
|
|
491
|
+
};
|
|
492
|
+
logger.info?.("Creating internal better-auth instance");
|
|
493
|
+
return betterAuthFn({
|
|
494
|
+
baseURL,
|
|
495
|
+
database,
|
|
496
|
+
emailAndPassword,
|
|
497
|
+
plugins: [adminPlugin({
|
|
498
|
+
defaultRole: AUTH_DEFAULT_ROLE,
|
|
499
|
+
adminRoles: [AUTH_ADMIN_ROLE]
|
|
500
|
+
})],
|
|
501
|
+
session,
|
|
502
|
+
trustedOrigins,
|
|
503
|
+
...passthrough.socialProviders ? { socialProviders: passthrough.socialProviders } : {},
|
|
504
|
+
...passthrough.account ? { account: passthrough.account } : {},
|
|
505
|
+
...passthrough.rateLimit ? { rateLimit: passthrough.rateLimit } : {},
|
|
506
|
+
...passthrough.advanced ? { advanced: passthrough.advanced } : {},
|
|
507
|
+
...passthrough.databaseHooks ? { databaseHooks: passthrough.databaseHooks } : {},
|
|
508
|
+
...passthrough.hooks ? { hooks: passthrough.hooks } : {},
|
|
509
|
+
...passthrough.disabledPaths ? { disabledPaths: passthrough.disabledPaths } : {},
|
|
510
|
+
...passthrough.secret ? { secret: passthrough.secret } : {},
|
|
511
|
+
...passthrough.secrets ? { secrets: passthrough.secrets } : {}
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
/** Create a SQLite client using better-sqlite3. */
|
|
515
|
+
async function createSQLiteClient(connectionString, logger) {
|
|
516
|
+
try {
|
|
517
|
+
const { default: Database } = await import("better-sqlite3");
|
|
518
|
+
const { Kysely, SqliteDialect, CamelCasePlugin } = await import("kysely");
|
|
519
|
+
logger.debug?.(`Using better-sqlite3 for internal better-auth database`);
|
|
520
|
+
let dbPath = connectionString.replace(/^file:/, "");
|
|
521
|
+
if (dbPath === "") dbPath = ":memory:";
|
|
522
|
+
return {
|
|
523
|
+
db: new Kysely({
|
|
524
|
+
dialect: new SqliteDialect({ database: new Database(dbPath) }),
|
|
525
|
+
plugins: [new CamelCasePlugin()]
|
|
526
|
+
}),
|
|
527
|
+
type: "sqlite"
|
|
528
|
+
};
|
|
529
|
+
} catch (err) {
|
|
530
|
+
if (err instanceof Error && err.message.includes("better-sqlite3")) throw new Error("Cannot create SQLite database for internal better-auth: install better-sqlite3 (npm install better-sqlite3). Alternatively, provide your own better-auth instance via the `auth` option.");
|
|
531
|
+
throw err;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
/** Create a PostgreSQL pool from a connection string. */
|
|
535
|
+
async function createPostgresPool(connectionString) {
|
|
536
|
+
try {
|
|
537
|
+
const { Pool } = await import("pg");
|
|
538
|
+
return new Pool({ connectionString });
|
|
539
|
+
} catch {
|
|
540
|
+
throw new Error("Cannot create PostgreSQL pool for internal better-auth: install the \"pg\" package. Alternatively, provide your own better-auth instance via the `auth` option.");
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
/** Create a MySQL pool from a connection string. */
|
|
544
|
+
async function createMySQLPool(connectionString) {
|
|
545
|
+
try {
|
|
546
|
+
return (await import("mysql2/promise")).createPool(connectionString);
|
|
547
|
+
} catch {
|
|
548
|
+
throw new Error("Cannot create MySQL pool for internal better-auth: install the \"mysql2\" package. Alternatively, provide your own better-auth instance via the `auth` option.");
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Create an Invect plugin that wraps a better-auth instance.
|
|
553
|
+
*
|
|
554
|
+
* This plugin:
|
|
555
|
+
*
|
|
556
|
+
* 1. **Proxies better-auth routes** — All of better-auth's HTTP endpoints
|
|
557
|
+
* (sign-in, sign-up, sign-out, OAuth callbacks, session, etc.) are mounted
|
|
558
|
+
* under the plugin endpoint space at `/plugins/auth/api/auth/*` (configurable).
|
|
559
|
+
*
|
|
560
|
+
* 2. **Resolves sessions → identities** — On every Invect API request, the
|
|
561
|
+
* `onRequest` hook reads the session cookie / bearer token via
|
|
562
|
+
* `auth.api.getSession()` and populates `InvectIdentity`.
|
|
563
|
+
*
|
|
564
|
+
* 3. **Handles authorization** — The `onAuthorize` hook lets better-auth's
|
|
565
|
+
* session decide whether a request is allowed.
|
|
566
|
+
*
|
|
567
|
+
* @example
|
|
568
|
+
* ```ts
|
|
569
|
+
* // Simple: let the plugin manage better-auth internally
|
|
570
|
+
* import { betterAuthPlugin } from '@invect/user-auth';
|
|
571
|
+
*
|
|
572
|
+
* app.use('/invect', createInvectRouter({
|
|
573
|
+
* databaseUrl: 'file:./dev.db',
|
|
574
|
+
* plugins: [betterAuthPlugin({
|
|
575
|
+
* globalAdmins: [{ email: 'admin@co.com', pw: 'secret' }],
|
|
576
|
+
* })],
|
|
577
|
+
* }));
|
|
578
|
+
* ```
|
|
579
|
+
*
|
|
580
|
+
* @example
|
|
581
|
+
* ```ts
|
|
582
|
+
* // Advanced: provide your own better-auth instance
|
|
583
|
+
* import { betterAuth } from 'better-auth';
|
|
584
|
+
* import { betterAuthPlugin } from '@invect/user-auth';
|
|
585
|
+
*
|
|
586
|
+
* const auth = betterAuth({
|
|
587
|
+
* database: { ... },
|
|
588
|
+
* emailAndPassword: { enabled: true },
|
|
589
|
+
* // ... your better-auth config
|
|
590
|
+
* });
|
|
591
|
+
*
|
|
592
|
+
* app.use('/invect', createInvectRouter({
|
|
593
|
+
* databaseUrl: 'file:./dev.db',
|
|
594
|
+
* plugins: [betterAuthPlugin({ auth })],
|
|
595
|
+
* }));
|
|
596
|
+
* ```
|
|
597
|
+
*/
|
|
598
|
+
function betterAuthPlugin(options) {
|
|
599
|
+
const { prefix = DEFAULT_PREFIX, mapUser: customMapUser, mapRole = defaultMapRole, publicPaths = [], onSessionError = "throw", globalAdmins = [] } = options;
|
|
600
|
+
let auth = options.auth ?? null;
|
|
601
|
+
let endpointLogger = console;
|
|
602
|
+
let betterAuthBasePath = "/api/auth";
|
|
603
|
+
/**
|
|
604
|
+
* Resolve an identity from a request's headers.
|
|
605
|
+
*/
|
|
606
|
+
async function getIdentityFromHeaders(headers) {
|
|
607
|
+
if (!auth) return null;
|
|
608
|
+
const result = await resolveSession(auth, headers);
|
|
609
|
+
if (!result) return null;
|
|
610
|
+
if (customMapUser) return customMapUser(result.user, result.session);
|
|
611
|
+
return defaultMapUser(result.user, result.session, mapRole);
|
|
612
|
+
}
|
|
613
|
+
async function getIdentityFromRequest(request) {
|
|
614
|
+
if (!auth) return null;
|
|
615
|
+
const body = (await callBetterAuthHandler(auth, request, "/get-session"))?.body;
|
|
616
|
+
if (!body?.session || !body?.user) return null;
|
|
617
|
+
if (customMapUser) return customMapUser(body.user, body.session);
|
|
618
|
+
return defaultMapUser(body.user, body.session, mapRole);
|
|
619
|
+
}
|
|
620
|
+
async function resolveEndpointIdentity(ctx) {
|
|
621
|
+
if (ctx.identity) return ctx.identity;
|
|
622
|
+
return getIdentityFromRequest(ctx.request);
|
|
623
|
+
}
|
|
624
|
+
const authRateLimiter = new RateLimiter(10, 6e4);
|
|
625
|
+
setInterval(() => authRateLimiter.cleanup(), 5 * 6e4).unref?.();
|
|
626
|
+
const endpoints = [
|
|
627
|
+
"GET",
|
|
628
|
+
"POST",
|
|
629
|
+
"PUT",
|
|
630
|
+
"PATCH",
|
|
631
|
+
"DELETE"
|
|
632
|
+
].map((method) => ({
|
|
633
|
+
method,
|
|
634
|
+
path: `/${prefix}/*`,
|
|
635
|
+
isPublic: true,
|
|
636
|
+
handler: async (ctx) => {
|
|
637
|
+
const incomingUrl = new URL(ctx.request.url);
|
|
638
|
+
endpointLogger.debug?.(`[auth-proxy] ${method} ${incomingUrl.pathname}`);
|
|
639
|
+
const pluginPrefixPattern = `/plugins/${prefix}`;
|
|
640
|
+
let authPath = incomingUrl.pathname;
|
|
641
|
+
const prefixIdx = authPath.indexOf(pluginPrefixPattern);
|
|
642
|
+
if (prefixIdx !== -1) authPath = authPath.slice(prefixIdx + pluginPrefixPattern.length);
|
|
643
|
+
if (!authPath.startsWith("/")) authPath = "/" + authPath;
|
|
644
|
+
if (method === "POST" && RATE_LIMITED_AUTH_PATHS.some((p) => authPath.includes(p))) {
|
|
645
|
+
const clientIp = ctx.headers["x-forwarded-for"]?.split(",")[0]?.trim() || ctx.headers["x-real-ip"] || "unknown";
|
|
646
|
+
const { limited, retryAfterMs } = authRateLimiter.isRateLimited(clientIp);
|
|
647
|
+
if (limited) return new Response(JSON.stringify({
|
|
648
|
+
error: "Too Many Requests",
|
|
649
|
+
message: "Too many authentication attempts. Please try again later."
|
|
650
|
+
}), {
|
|
651
|
+
status: 429,
|
|
652
|
+
headers: {
|
|
653
|
+
"content-type": "application/json",
|
|
654
|
+
"retry-after": String(Math.ceil((retryAfterMs ?? 6e4) / 1e3))
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
const authUrl = new URL(`${incomingUrl.origin}${authPath}${incomingUrl.search}`);
|
|
659
|
+
endpointLogger.debug?.(`[auth-proxy] Forwarding to better-auth: ${method} ${authUrl.pathname}`);
|
|
660
|
+
const authRequest = new Request(authUrl.toString(), {
|
|
661
|
+
method: ctx.request.method,
|
|
662
|
+
headers: ctx.request.headers,
|
|
663
|
+
body: method !== "GET" && method !== "DELETE" ? ctx.request.body : void 0,
|
|
664
|
+
duplex: method !== "GET" && method !== "DELETE" ? "half" : void 0
|
|
665
|
+
});
|
|
666
|
+
const response = await auth.handler(authRequest);
|
|
667
|
+
endpointLogger.debug?.(`[auth-proxy] Response: ${response.status} ${response.statusText}`, {
|
|
668
|
+
setCookie: response.headers.get("set-cookie") ? "present" : "absent",
|
|
669
|
+
contentType: response.headers.get("content-type")
|
|
670
|
+
});
|
|
671
|
+
return response;
|
|
672
|
+
}
|
|
673
|
+
}));
|
|
674
|
+
return {
|
|
675
|
+
id: "better-auth",
|
|
676
|
+
name: "Better Auth",
|
|
677
|
+
schema: BETTER_AUTH_SCHEMA,
|
|
678
|
+
requiredTables: [
|
|
679
|
+
"user",
|
|
680
|
+
"session",
|
|
681
|
+
"account",
|
|
682
|
+
"verification"
|
|
683
|
+
],
|
|
684
|
+
setupInstructions: "Run `npx invect generate` to add the better-auth tables to your schema, then `npx drizzle-kit push` (or `npx invect migrate`) to apply.",
|
|
685
|
+
endpoints: [
|
|
686
|
+
{
|
|
687
|
+
method: "GET",
|
|
688
|
+
path: `/${prefix}/me`,
|
|
689
|
+
isPublic: false,
|
|
690
|
+
handler: async (ctx) => {
|
|
691
|
+
const identity = await resolveEndpointIdentity(ctx);
|
|
692
|
+
const permissions = ctx.core.getPermissions(identity);
|
|
693
|
+
const resolvedRole = identity ? ctx.core.getResolvedRole(identity) : null;
|
|
694
|
+
return {
|
|
695
|
+
status: 200,
|
|
696
|
+
body: {
|
|
697
|
+
identity: identity ? {
|
|
698
|
+
id: identity.id,
|
|
699
|
+
name: identity.name,
|
|
700
|
+
role: identity.role,
|
|
701
|
+
resolvedRole
|
|
702
|
+
} : null,
|
|
703
|
+
permissions,
|
|
704
|
+
isAuthenticated: !!identity
|
|
705
|
+
}
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
},
|
|
709
|
+
{
|
|
710
|
+
method: "GET",
|
|
711
|
+
path: `/${prefix}/roles`,
|
|
712
|
+
isPublic: false,
|
|
713
|
+
handler: async (ctx) => {
|
|
714
|
+
const roles = ctx.core.getAvailableRoles();
|
|
715
|
+
const missingRoles = AUTH_VISIBLE_ROLES.filter((role) => !roles.some((entry) => entry.role === role)).map((role) => ({
|
|
716
|
+
role,
|
|
717
|
+
permissions: []
|
|
718
|
+
}));
|
|
719
|
+
return {
|
|
720
|
+
status: 200,
|
|
721
|
+
body: { roles: [...roles, ...missingRoles] }
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
},
|
|
725
|
+
{
|
|
726
|
+
method: "GET",
|
|
727
|
+
path: `/${prefix}/users`,
|
|
728
|
+
isPublic: false,
|
|
729
|
+
handler: async (ctx) => {
|
|
730
|
+
const identity = await resolveEndpointIdentity(ctx);
|
|
731
|
+
if (!identity || identity.role !== "admin") return {
|
|
732
|
+
status: 403,
|
|
733
|
+
body: {
|
|
734
|
+
error: "Forbidden",
|
|
735
|
+
message: "Admin access required"
|
|
736
|
+
}
|
|
737
|
+
};
|
|
738
|
+
try {
|
|
739
|
+
const api = auth.api;
|
|
740
|
+
const headers = toHeaders(ctx.headers);
|
|
741
|
+
if (typeof api.listUsers === "function") {
|
|
742
|
+
const listUsers = api.listUsers;
|
|
743
|
+
const result = await listUsers({
|
|
744
|
+
headers,
|
|
745
|
+
query: {
|
|
746
|
+
limit: ctx.query.limit ?? "100",
|
|
747
|
+
offset: ctx.query.offset ?? "0"
|
|
748
|
+
}
|
|
749
|
+
});
|
|
750
|
+
return {
|
|
751
|
+
status: 200,
|
|
752
|
+
body: { users: Array.isArray(result) ? result : result?.users ?? [] }
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
const fallbackResult = await callBetterAuthHandler(auth, ctx.request, "/admin/list-users", {
|
|
756
|
+
method: "GET",
|
|
757
|
+
query: {
|
|
758
|
+
limit: ctx.query.limit ?? "100",
|
|
759
|
+
offset: ctx.query.offset ?? "0"
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
if (fallbackResult && fallbackResult.status >= 200 && fallbackResult.status < 300) return {
|
|
763
|
+
status: 200,
|
|
764
|
+
body: fallbackResult.body
|
|
765
|
+
};
|
|
766
|
+
return {
|
|
767
|
+
status: 200,
|
|
768
|
+
body: {
|
|
769
|
+
users: [],
|
|
770
|
+
message: "User listing requires the better-auth admin plugin. Add `admin()` to your better-auth plugins config."
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
} catch (err) {
|
|
774
|
+
endpointLogger.error("Failed to list users", {
|
|
775
|
+
identity: sanitizeForLogging(identity),
|
|
776
|
+
query: sanitizeForLogging(ctx.query),
|
|
777
|
+
error: getErrorLogDetails(err)
|
|
778
|
+
});
|
|
779
|
+
return toAuthApiErrorResponse("Failed to list users", err);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
},
|
|
783
|
+
{
|
|
784
|
+
method: "POST",
|
|
785
|
+
path: `/${prefix}/users`,
|
|
786
|
+
isPublic: false,
|
|
787
|
+
handler: async (ctx) => {
|
|
788
|
+
const identity = await resolveEndpointIdentity(ctx);
|
|
789
|
+
if (!identity || identity.role !== "admin") return {
|
|
790
|
+
status: 403,
|
|
791
|
+
body: {
|
|
792
|
+
error: "Forbidden",
|
|
793
|
+
message: "Admin access required"
|
|
794
|
+
}
|
|
795
|
+
};
|
|
796
|
+
const { email, password, name, role } = ctx.body;
|
|
797
|
+
if (!email || !password) return {
|
|
798
|
+
status: 400,
|
|
799
|
+
body: { error: "email and password are required" }
|
|
800
|
+
};
|
|
801
|
+
if (role !== void 0 && !isAuthAssignableRole(role)) return {
|
|
802
|
+
status: 400,
|
|
803
|
+
body: { error: "role must be one of: " + AUTH_ASSIGNABLE_ROLES.join(", ") }
|
|
804
|
+
};
|
|
805
|
+
try {
|
|
806
|
+
const api = auth.api;
|
|
807
|
+
const headers = toHeaders(ctx.headers);
|
|
808
|
+
let result = null;
|
|
809
|
+
if (typeof api.createUser === "function") {
|
|
810
|
+
const createUser = api.createUser;
|
|
811
|
+
result = await createUser({
|
|
812
|
+
headers,
|
|
813
|
+
body: {
|
|
814
|
+
email,
|
|
815
|
+
password,
|
|
816
|
+
name: name ?? email.split("@")[0],
|
|
817
|
+
role: role ?? "default"
|
|
818
|
+
}
|
|
819
|
+
});
|
|
820
|
+
} else if (typeof api.signUpEmail === "function") {
|
|
821
|
+
const signUpEmail = api.signUpEmail;
|
|
822
|
+
result = await signUpEmail({
|
|
823
|
+
headers,
|
|
824
|
+
body: {
|
|
825
|
+
email,
|
|
826
|
+
password,
|
|
827
|
+
name: name ?? email.split("@")[0],
|
|
828
|
+
role: role ?? "default"
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
} else {
|
|
832
|
+
const fallbackResult = await callBetterAuthHandler(auth, ctx.request, "/admin/create-user", {
|
|
833
|
+
method: "POST",
|
|
834
|
+
body: {
|
|
835
|
+
email,
|
|
836
|
+
password,
|
|
837
|
+
name: name ?? email.split("@")[0],
|
|
838
|
+
role: role ?? "default"
|
|
839
|
+
}
|
|
840
|
+
});
|
|
841
|
+
if (fallbackResult && fallbackResult.status >= 200 && fallbackResult.status < 300) result = fallbackResult.body;
|
|
842
|
+
}
|
|
843
|
+
if (!result && typeof api.createUser !== "function" && typeof api.signUpEmail !== "function") return {
|
|
844
|
+
status: 500,
|
|
845
|
+
body: { error: "Auth API does not support user creation" }
|
|
846
|
+
};
|
|
847
|
+
if (!result?.user) return {
|
|
848
|
+
status: 400,
|
|
849
|
+
body: {
|
|
850
|
+
error: "Failed to create user",
|
|
851
|
+
message: "User may already exist"
|
|
852
|
+
}
|
|
853
|
+
};
|
|
854
|
+
return {
|
|
855
|
+
status: 201,
|
|
856
|
+
body: { user: {
|
|
857
|
+
id: result.user.id,
|
|
858
|
+
email: result.user.email,
|
|
859
|
+
name: result.user.name,
|
|
860
|
+
role: result.user.role
|
|
861
|
+
} }
|
|
862
|
+
};
|
|
863
|
+
} catch (err) {
|
|
864
|
+
endpointLogger.error("Failed to create user", {
|
|
865
|
+
identity: sanitizeForLogging(identity),
|
|
866
|
+
body: sanitizeForLogging({
|
|
867
|
+
email,
|
|
868
|
+
name,
|
|
869
|
+
role
|
|
870
|
+
}),
|
|
871
|
+
error: getErrorLogDetails(err)
|
|
872
|
+
});
|
|
873
|
+
return toAuthApiErrorResponse("Failed to create user", err);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
},
|
|
877
|
+
{
|
|
878
|
+
method: "PATCH",
|
|
879
|
+
path: `/${prefix}/users/:userId/role`,
|
|
880
|
+
isPublic: false,
|
|
881
|
+
handler: async (ctx) => {
|
|
882
|
+
const identity = await resolveEndpointIdentity(ctx);
|
|
883
|
+
if (!identity || identity.role !== "admin") return {
|
|
884
|
+
status: 403,
|
|
885
|
+
body: {
|
|
886
|
+
error: "Forbidden",
|
|
887
|
+
message: "Admin access required"
|
|
888
|
+
}
|
|
889
|
+
};
|
|
890
|
+
const { userId } = ctx.params;
|
|
891
|
+
const { role } = ctx.body;
|
|
892
|
+
if (!isAuthAssignableRole(role)) return {
|
|
893
|
+
status: 400,
|
|
894
|
+
body: { error: "role must be one of: " + AUTH_ASSIGNABLE_ROLES.join(", ") }
|
|
895
|
+
};
|
|
896
|
+
try {
|
|
897
|
+
const api = auth.api;
|
|
898
|
+
const headers = toHeaders(ctx.headers);
|
|
899
|
+
if (typeof api.setRole === "function") {
|
|
900
|
+
const setRole = api.setRole;
|
|
901
|
+
await setRole({
|
|
902
|
+
headers,
|
|
903
|
+
body: {
|
|
904
|
+
userId,
|
|
905
|
+
role
|
|
906
|
+
}
|
|
907
|
+
});
|
|
908
|
+
return {
|
|
909
|
+
status: 200,
|
|
910
|
+
body: {
|
|
911
|
+
success: true,
|
|
912
|
+
userId,
|
|
913
|
+
role
|
|
914
|
+
}
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
const fallbackResult = await callBetterAuthHandler(auth, ctx.request, "/admin/set-role", {
|
|
918
|
+
method: "POST",
|
|
919
|
+
body: {
|
|
920
|
+
userId,
|
|
921
|
+
role
|
|
922
|
+
}
|
|
923
|
+
});
|
|
924
|
+
if (fallbackResult && fallbackResult.status >= 200 && fallbackResult.status < 300) return {
|
|
925
|
+
status: 200,
|
|
926
|
+
body: {
|
|
927
|
+
success: true,
|
|
928
|
+
userId,
|
|
929
|
+
role
|
|
930
|
+
}
|
|
931
|
+
};
|
|
932
|
+
if (typeof api.updateUser === "function") {
|
|
933
|
+
const updateUser = api.updateUser;
|
|
934
|
+
await updateUser({
|
|
935
|
+
headers,
|
|
936
|
+
body: { role },
|
|
937
|
+
params: { id: userId }
|
|
938
|
+
});
|
|
939
|
+
return {
|
|
940
|
+
status: 200,
|
|
941
|
+
body: {
|
|
942
|
+
success: true,
|
|
943
|
+
userId,
|
|
944
|
+
role
|
|
945
|
+
}
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
return {
|
|
949
|
+
status: 501,
|
|
950
|
+
body: { error: "Role update requires the better-auth admin plugin. Add `admin()` to your better-auth plugins config." }
|
|
951
|
+
};
|
|
952
|
+
} catch (err) {
|
|
953
|
+
endpointLogger.error("Failed to update role", {
|
|
954
|
+
identity: sanitizeForLogging(identity),
|
|
955
|
+
params: sanitizeForLogging(ctx.params),
|
|
956
|
+
body: sanitizeForLogging({ role }),
|
|
957
|
+
error: getErrorLogDetails(err)
|
|
958
|
+
});
|
|
959
|
+
return toAuthApiErrorResponse("Failed to update role", err);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
},
|
|
963
|
+
{
|
|
964
|
+
method: "DELETE",
|
|
965
|
+
path: `/${prefix}/users/:userId`,
|
|
966
|
+
isPublic: false,
|
|
967
|
+
handler: async (ctx) => {
|
|
968
|
+
const identity = await resolveEndpointIdentity(ctx);
|
|
969
|
+
if (!identity || identity.role !== "admin") return {
|
|
970
|
+
status: 403,
|
|
971
|
+
body: {
|
|
972
|
+
error: "Forbidden",
|
|
973
|
+
message: "Admin access required"
|
|
974
|
+
}
|
|
975
|
+
};
|
|
976
|
+
const { userId } = ctx.params;
|
|
977
|
+
if (identity.id === userId) return {
|
|
978
|
+
status: 400,
|
|
979
|
+
body: { error: "Cannot delete your own account" }
|
|
980
|
+
};
|
|
981
|
+
try {
|
|
982
|
+
const api = auth.api;
|
|
983
|
+
const headers = toHeaders(ctx.headers);
|
|
984
|
+
if (typeof api.removeUser === "function") {
|
|
985
|
+
const removeUser = api.removeUser;
|
|
986
|
+
await removeUser({
|
|
987
|
+
headers,
|
|
988
|
+
body: { userId }
|
|
989
|
+
});
|
|
990
|
+
return {
|
|
991
|
+
status: 200,
|
|
992
|
+
body: {
|
|
993
|
+
success: true,
|
|
994
|
+
userId
|
|
995
|
+
}
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
const fallbackResult = await callBetterAuthHandler(auth, ctx.request, "/admin/remove-user", {
|
|
999
|
+
method: "POST",
|
|
1000
|
+
body: { userId }
|
|
1001
|
+
});
|
|
1002
|
+
if (fallbackResult && fallbackResult.status >= 200 && fallbackResult.status < 300) return {
|
|
1003
|
+
status: 200,
|
|
1004
|
+
body: {
|
|
1005
|
+
success: true,
|
|
1006
|
+
userId
|
|
1007
|
+
}
|
|
1008
|
+
};
|
|
1009
|
+
if (typeof api.deleteUser === "function") {
|
|
1010
|
+
const deleteUser = api.deleteUser;
|
|
1011
|
+
await deleteUser({
|
|
1012
|
+
headers,
|
|
1013
|
+
body: { userId }
|
|
1014
|
+
});
|
|
1015
|
+
return {
|
|
1016
|
+
status: 200,
|
|
1017
|
+
body: {
|
|
1018
|
+
success: true,
|
|
1019
|
+
userId
|
|
1020
|
+
}
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
return {
|
|
1024
|
+
status: 501,
|
|
1025
|
+
body: { error: "User deletion requires the better-auth admin plugin. Add `admin()` to your better-auth plugins config." }
|
|
1026
|
+
};
|
|
1027
|
+
} catch (err) {
|
|
1028
|
+
endpointLogger.error("Failed to delete user", {
|
|
1029
|
+
identity: sanitizeForLogging(identity),
|
|
1030
|
+
params: sanitizeForLogging(ctx.params),
|
|
1031
|
+
error: getErrorLogDetails(err)
|
|
1032
|
+
});
|
|
1033
|
+
return toAuthApiErrorResponse("Failed to delete user", err);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
},
|
|
1037
|
+
...endpoints
|
|
1038
|
+
],
|
|
1039
|
+
hooks: {
|
|
1040
|
+
onRequest: async (request, context) => {
|
|
1041
|
+
if (isBetterAuthRoute(context.path, prefix, betterAuthBasePath)) return;
|
|
1042
|
+
if (publicPaths.some((p) => context.path.startsWith(p))) return;
|
|
1043
|
+
const headersRecord = {};
|
|
1044
|
+
request.headers.forEach((value, key) => {
|
|
1045
|
+
headersRecord[key] = value;
|
|
1046
|
+
});
|
|
1047
|
+
endpointLogger.debug?.(`[auth-onRequest] ${context.method} ${context.path}`, {
|
|
1048
|
+
hasCookie: !!headersRecord["cookie"],
|
|
1049
|
+
hasAuth: !!headersRecord["authorization"]
|
|
1050
|
+
});
|
|
1051
|
+
const identity = await getIdentityFromHeaders(headersRecord);
|
|
1052
|
+
endpointLogger.debug?.(`[auth-onRequest] Identity resolved:`, {
|
|
1053
|
+
authenticated: !!identity,
|
|
1054
|
+
userId: identity?.id,
|
|
1055
|
+
role: identity?.role
|
|
1056
|
+
});
|
|
1057
|
+
context.identity = identity;
|
|
1058
|
+
if (!identity && onSessionError === "throw") return { response: new Response(JSON.stringify({
|
|
1059
|
+
error: "Unauthorized",
|
|
1060
|
+
message: "Valid session required. Sign in via better-auth."
|
|
1061
|
+
}), {
|
|
1062
|
+
status: 401,
|
|
1063
|
+
headers: { "content-type": "application/json" }
|
|
1064
|
+
}) };
|
|
1065
|
+
},
|
|
1066
|
+
onAuthorize: async (context) => {
|
|
1067
|
+
if (context.identity) return;
|
|
1068
|
+
return {
|
|
1069
|
+
allowed: false,
|
|
1070
|
+
reason: "No valid better-auth session"
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
},
|
|
1074
|
+
init: async (pluginContext) => {
|
|
1075
|
+
endpointLogger = pluginContext.logger;
|
|
1076
|
+
if (!auth) auth = await createInternalBetterAuth(pluginContext.config, options, pluginContext.logger);
|
|
1077
|
+
betterAuthBasePath = auth.options?.basePath ?? "/api/auth";
|
|
1078
|
+
pluginContext.logger.info(`Better Auth plugin initialized (prefix: ${prefix}, basePath: ${betterAuthBasePath})`);
|
|
1079
|
+
if (globalAdmins.length === 0) {
|
|
1080
|
+
pluginContext.logger.debug("No global admins configured. Pass `globalAdmins` to betterAuthPlugin(...) to seed admin access.");
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
for (const configuredAdmin of globalAdmins) {
|
|
1084
|
+
const adminEmail = configuredAdmin.email?.trim();
|
|
1085
|
+
const adminPassword = configuredAdmin.pw;
|
|
1086
|
+
const adminName = configuredAdmin.name?.trim() || "Admin";
|
|
1087
|
+
if (!adminEmail || !adminPassword) {
|
|
1088
|
+
pluginContext.logger.debug("Skipping invalid global admin config: both email and pw are required.");
|
|
1089
|
+
continue;
|
|
1090
|
+
}
|
|
1091
|
+
try {
|
|
1092
|
+
const authContext = await getAuthContext(auth);
|
|
1093
|
+
const existingAdminUser = unwrapFoundUser(await authContext?.internalAdapter?.findUserByEmail(adminEmail));
|
|
1094
|
+
if (existingAdminUser) {
|
|
1095
|
+
if (existingAdminUser.role !== "admin") {
|
|
1096
|
+
await authContext?.internalAdapter?.updateUser(existingAdminUser.id, { role: "admin" });
|
|
1097
|
+
pluginContext.logger.info(`Admin user promoted: ${adminEmail}`);
|
|
1098
|
+
} else pluginContext.logger.debug(`Admin user already configured: ${adminEmail}`);
|
|
1099
|
+
continue;
|
|
1100
|
+
}
|
|
1101
|
+
const api = auth.api;
|
|
1102
|
+
let result = null;
|
|
1103
|
+
if (typeof api.createUser === "function") {
|
|
1104
|
+
const createUser = api.createUser;
|
|
1105
|
+
result = await createUser({
|
|
1106
|
+
headers: new Headers(),
|
|
1107
|
+
body: {
|
|
1108
|
+
email: adminEmail,
|
|
1109
|
+
password: adminPassword,
|
|
1110
|
+
name: adminName,
|
|
1111
|
+
role: "admin"
|
|
1112
|
+
}
|
|
1113
|
+
}).catch((err) => {
|
|
1114
|
+
pluginContext.logger.error?.(`createUser failed for ${adminEmail}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1115
|
+
return null;
|
|
1116
|
+
});
|
|
1117
|
+
} else if (typeof api.signUpEmail === "function") {
|
|
1118
|
+
const signUpEmail = api.signUpEmail;
|
|
1119
|
+
result = await signUpEmail({ body: {
|
|
1120
|
+
email: adminEmail,
|
|
1121
|
+
password: adminPassword,
|
|
1122
|
+
name: adminName
|
|
1123
|
+
} }).catch((err) => {
|
|
1124
|
+
pluginContext.logger.error?.(`signUpEmail failed for ${adminEmail}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1125
|
+
return null;
|
|
1126
|
+
});
|
|
1127
|
+
} else {
|
|
1128
|
+
pluginContext.logger.debug(`Could not create global admin ${adminEmail}: auth.api.createUser/signUpEmail are unavailable.`);
|
|
1129
|
+
continue;
|
|
1130
|
+
}
|
|
1131
|
+
if (result?.user) {
|
|
1132
|
+
const createdAuthContext = authContext ?? await getAuthContext(auth);
|
|
1133
|
+
const createdAdminUser = unwrapFoundUser(await createdAuthContext?.internalAdapter?.findUserByEmail(adminEmail)) ?? result.user;
|
|
1134
|
+
if (createdAdminUser?.id && createdAdminUser.role !== "admin") await createdAuthContext?.internalAdapter?.updateUser(createdAdminUser.id, { role: "admin" });
|
|
1135
|
+
pluginContext.logger.info(`Admin user created: ${adminEmail}`);
|
|
1136
|
+
} else pluginContext.logger.debug(`Admin user already exists or could not be created: ${adminEmail}`);
|
|
1137
|
+
} catch (seedErr) {
|
|
1138
|
+
pluginContext.logger.debug(`Could not seed admin user (tables may not exist yet): ${adminEmail} — ${seedErr instanceof Error ? seedErr.message : String(seedErr)}`);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
},
|
|
1142
|
+
$ERROR_CODES: {
|
|
1143
|
+
"auth:session_expired": {
|
|
1144
|
+
message: "Session has expired. Please sign in again.",
|
|
1145
|
+
status: 401
|
|
1146
|
+
},
|
|
1147
|
+
"auth:session_not_found": {
|
|
1148
|
+
message: "No valid session found.",
|
|
1149
|
+
status: 401
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
1155
|
+
* Check if a path is a better-auth proxy route (should skip session checks).
|
|
1156
|
+
* Only matches the actual better-auth API proxy routes, not custom plugin endpoints.
|
|
1157
|
+
*/
|
|
1158
|
+
function isBetterAuthRoute(path, prefix, basePath) {
|
|
1159
|
+
return path.startsWith(`/plugins/${prefix}${basePath}`);
|
|
1160
|
+
}
|
|
1161
|
+
//#endregion
|
|
1162
|
+
export { BETTER_AUTH_SCHEMA, betterAuthPlugin };
|
|
1163
|
+
|
|
1164
|
+
//# sourceMappingURL=index.mjs.map
|