@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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +106 -0
  3. package/dist/backend/index.cjs +1166 -0
  4. package/dist/backend/index.cjs.map +1 -0
  5. package/dist/backend/index.d.ts +42 -0
  6. package/dist/backend/index.d.ts.map +1 -0
  7. package/dist/backend/index.mjs +1164 -0
  8. package/dist/backend/index.mjs.map +1 -0
  9. package/dist/backend/plugin.d.ts +62 -0
  10. package/dist/backend/plugin.d.ts.map +1 -0
  11. package/dist/backend/types.d.ts +299 -0
  12. package/dist/backend/types.d.ts.map +1 -0
  13. package/dist/frontend/components/AuthGate.d.ts +17 -0
  14. package/dist/frontend/components/AuthGate.d.ts.map +1 -0
  15. package/dist/frontend/components/AuthenticatedInvect.d.ts +129 -0
  16. package/dist/frontend/components/AuthenticatedInvect.d.ts.map +1 -0
  17. package/dist/frontend/components/ProfilePage.d.ts +10 -0
  18. package/dist/frontend/components/ProfilePage.d.ts.map +1 -0
  19. package/dist/frontend/components/SidebarUserMenu.d.ts +12 -0
  20. package/dist/frontend/components/SidebarUserMenu.d.ts.map +1 -0
  21. package/dist/frontend/components/SignInForm.d.ts +14 -0
  22. package/dist/frontend/components/SignInForm.d.ts.map +1 -0
  23. package/dist/frontend/components/SignInPage.d.ts +19 -0
  24. package/dist/frontend/components/SignInPage.d.ts.map +1 -0
  25. package/dist/frontend/components/UserButton.d.ts +15 -0
  26. package/dist/frontend/components/UserButton.d.ts.map +1 -0
  27. package/dist/frontend/components/UserManagement.d.ts +19 -0
  28. package/dist/frontend/components/UserManagement.d.ts.map +1 -0
  29. package/dist/frontend/components/UserManagementPage.d.ts +9 -0
  30. package/dist/frontend/components/UserManagementPage.d.ts.map +1 -0
  31. package/dist/frontend/index.cjs +1262 -0
  32. package/dist/frontend/index.cjs.map +1 -0
  33. package/dist/frontend/index.d.ts +29 -0
  34. package/dist/frontend/index.d.ts.map +1 -0
  35. package/dist/frontend/index.mjs +1250 -0
  36. package/dist/frontend/index.mjs.map +1 -0
  37. package/dist/frontend/plugins/authFrontendPlugin.d.ts +14 -0
  38. package/dist/frontend/plugins/authFrontendPlugin.d.ts.map +1 -0
  39. package/dist/frontend/providers/AuthProvider.d.ts +46 -0
  40. package/dist/frontend/providers/AuthProvider.d.ts.map +1 -0
  41. package/dist/roles-BOY5N82v.cjs +74 -0
  42. package/dist/roles-BOY5N82v.cjs.map +1 -0
  43. package/dist/roles-CZuKFEpJ.mjs +33 -0
  44. package/dist/roles-CZuKFEpJ.mjs.map +1 -0
  45. package/dist/shared/roles.d.ts +11 -0
  46. package/dist/shared/roles.d.ts.map +1 -0
  47. package/dist/shared/types.cjs +0 -0
  48. package/dist/shared/types.d.ts +46 -0
  49. package/dist/shared/types.d.ts.map +1 -0
  50. package/dist/shared/types.mjs +1 -0
  51. 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