@mastra/auth-better-auth 1.1.2-alpha.0 → 1.1.2-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,3 +1,9 @@
1
+ import { LibsqlDialect } from '@libsql/kysely-libsql';
2
+ import { betterAuth } from 'better-auth';
3
+ import { makeSignature } from 'better-auth/crypto';
4
+ import { getMigrations } from 'better-auth/db/migration';
5
+ import { organization } from 'better-auth/plugins';
6
+
1
7
  // ../../packages/_internal-core/dist/chunk-3M4SEWMI.js
2
8
  var RegisteredLogger = {
3
9
  LLM: "LLM"};
@@ -150,7 +156,7 @@ var MastraBase = class {
150
156
  }
151
157
  };
152
158
 
153
- // ../../packages/_internals/auth/dist/chunk-7GLKLOU3.js
159
+ // ../../packages/_internals/auth/dist/chunk-NJOKT6V5.js
154
160
  var MastraAuthProvider = class extends MastraBase {
155
161
  protected;
156
162
  public;
@@ -178,8 +184,13 @@ var MastraAuthProvider = class extends MastraBase {
178
184
  }
179
185
  }
180
186
  };
181
-
182
- // src/index.ts
187
+ function tryDecode(value) {
188
+ try {
189
+ return decodeURIComponent(value);
190
+ } catch {
191
+ return value;
192
+ }
193
+ }
183
194
  function getRequestHeader2(request, name) {
184
195
  if (request instanceof Request) {
185
196
  return request.headers.get(name);
@@ -200,23 +211,253 @@ function mapBetterAuthUserToEEUser(user) {
200
211
  };
201
212
  }
202
213
  var MastraAuthBetterAuth = class extends MastraAuthProvider {
203
- auth;
214
+ #auth;
215
+ #secret;
216
+ /** True when `init()` built the instance — then we also own its migrations. */
217
+ #ownsInstance = false;
218
+ /** Once-per-process migration latch; reset on failure so a later call retries. */
219
+ #migrated;
220
+ /** In-process `userId → orgId` cache so hosts can call `ensureOrganization` per request. */
221
+ #orgCache = /* @__PURE__ */ new Map();
222
+ /** Set from `init()`: cross-origin SPA deploys need SameSite=None; Secure cookies. */
223
+ #crossSite = false;
204
224
  signUpEnabledConfig;
205
- sessionCookieName;
206
225
  constructor(options) {
207
226
  super({ name: options?.name ?? "better-auth" });
208
- if (!options.auth) {
227
+ if (!options.auth && !options.secret) {
209
228
  throw new Error(
210
- "Better Auth instance is required. Please provide the auth option with your Better Auth instance created via betterAuth({ ... })"
229
+ "Better Auth instance is required. Please provide the auth option with your Better Auth instance created via betterAuth({ ... }), or provide `secret` so the provider can build its own instance in init() on the host database."
211
230
  );
212
231
  }
213
- this.auth = options.auth;
232
+ this.#auth = options.auth;
233
+ this.#secret = options.secret;
214
234
  this.signUpEnabledConfig = options.signUpEnabled ?? true;
215
- const authWithOptions = this.auth;
216
- const prefix = authWithOptions.options?.advanced?.cookiePrefix ?? "better-auth";
217
- this.sessionCookieName = `${prefix}.session_token`;
218
235
  this.registerOptions(options);
219
236
  }
237
+ /**
238
+ * The active Better Auth instance. Throws before `init()` in deferred
239
+ * instance mode (constructed with `secret` instead of `auth`).
240
+ */
241
+ get auth() {
242
+ if (!this.#auth) {
243
+ throw new Error(
244
+ "MastraAuthBetterAuth is not initialized \u2014 init() must run first (or pass a configured `auth` instance)."
245
+ );
246
+ }
247
+ return this.#auth;
248
+ }
249
+ /**
250
+ * Session cookie name, honoring Better Auth's `cookiePrefix`, a caller
251
+ * override via `advanced.cookies.session_token.name`, and the `__Secure-`
252
+ * prefix Better Auth applies when secure cookies are active.
253
+ */
254
+ get sessionCookieName() {
255
+ const options = this.#auth?.options;
256
+ const advanced = options?.advanced;
257
+ const prefix = advanced?.cookiePrefix ?? "better-auth";
258
+ const secure = advanced?.useSecureCookies ?? options?.baseURL?.startsWith("https://") ?? false;
259
+ const baseName = advanced?.cookies?.session_token?.name ?? `${prefix}.session_token`;
260
+ return `${secure ? "__Secure-" : ""}${baseName}`;
261
+ }
262
+ // ============================================
263
+ // IAuthInit implementation
264
+ // ============================================
265
+ /**
266
+ * One-time host initialization. In deferred instance mode (no `auth` in the
267
+ * options) this builds the provider-owned `betterAuth()` instance on the
268
+ * host's auth database; schema migrations then run lazily behind a
269
+ * once-per-process latch on first use.
270
+ *
271
+ * Bring-your-own `auth` instances skip construction entirely — the caller
272
+ * owns their database and migrations.
273
+ */
274
+ async init(ctx) {
275
+ this.#crossSite = (ctx.allowedOrigins?.length ?? 0) > 0;
276
+ if (this.#auth) return;
277
+ const authDb = ctx.database;
278
+ if (!authDb) {
279
+ throw new Error(
280
+ "MastraAuthBetterAuth needs a database to build its own better-auth instance, but the host passed none. Use a storage backend that exposes an auth database, or pass your own configured `auth` instance."
281
+ );
282
+ }
283
+ const database = authDb.dialect === "postgres" ? authDb.pool : authDb.dialect === "libsql" ? {
284
+ dialect: new LibsqlDialect({ client: authDb.client }),
285
+ type: "sqlite"
286
+ } : authDb.database;
287
+ const allowedOrigins = ctx.allowedOrigins ?? [];
288
+ const options = {
289
+ database,
290
+ secret: this.#secret,
291
+ // All provider endpoints (sign-in/up/out/session) live under /auth/api/*,
292
+ // where hosts mount handleAuthRequest.
293
+ basePath: "/auth/api",
294
+ ...ctx.publicUrl ? { baseURL: ctx.publicUrl } : {},
295
+ // Cross-origin SPA deploys: SameSite=None only lets the browser SEND the
296
+ // cookie — better-auth still rejects requests from origins outside its
297
+ // own allow-list, so the SPA origins must be trusted too.
298
+ ...allowedOrigins.length ? { trustedOrigins: allowedOrigins } : {},
299
+ emailAndPassword: { enabled: true, disableSignUp: !this.signUpEnabledConfig },
300
+ plugins: [organization()],
301
+ // Cross-origin SPA deploys need SameSite=None; Secure for the browser to
302
+ // send the session cookie.
303
+ ...this.#crossSite ? { advanced: { defaultCookieAttributes: { sameSite: "none", secure: true } } } : {}
304
+ };
305
+ this.#auth = betterAuth(options);
306
+ this.#ownsInstance = true;
307
+ }
308
+ /**
309
+ * Ensure better-auth's tables exist in the host database. Only for instances
310
+ * this provider built — bring-your-own instances manage their own migrations.
311
+ */
312
+ async #ensureDbReady() {
313
+ if (!this.#ownsInstance) return;
314
+ this.#migrated ??= (async () => {
315
+ const { runMigrations } = await getMigrations(this.auth.options);
316
+ await runMigrations();
317
+ })();
318
+ try {
319
+ await this.#migrated;
320
+ } catch (error) {
321
+ this.#migrated = void 0;
322
+ console.warn("[BetterAuth] Failed to run auth schema migrations; auth stays unavailable until this succeeds.");
323
+ throw error;
324
+ }
325
+ }
326
+ // ============================================
327
+ // IAuthHttpHandler implementation
328
+ // ============================================
329
+ /**
330
+ * Proxy a raw HTTP request to Better Auth's own API surface
331
+ * (sign-in/up/out/session). Hosts mount this under `/auth/api/*`.
332
+ */
333
+ async handleAuthRequest(request) {
334
+ try {
335
+ await this.#ensureDbReady();
336
+ } catch {
337
+ return new Response(JSON.stringify({ error: "auth_unavailable" }), {
338
+ status: 503,
339
+ headers: { "content-type": "application/json" }
340
+ });
341
+ }
342
+ return this.auth.handler(request);
343
+ }
344
+ // ============================================
345
+ // IOrganizationsProvider implementation
346
+ // ============================================
347
+ /**
348
+ * Ensure the user belongs to an organization, mirroring the WorkOS
349
+ * personal-org bootstrap on better-auth's organization tables:
350
+ * ≥1 membership → first org id; 0 → create a personal org with an
351
+ * idempotent slug derived from the user id.
352
+ *
353
+ * Concurrent/retried first logins recover via the unique slug instead of
354
+ * creating duplicates. Slug alone is NOT proof of ownership though: the
355
+ * organization API is reachable by any authenticated user, so an attacker
356
+ * could squat `personal-<victimId>`. The slug-matched org is only adopted
357
+ * when nobody else is a member; otherwise a fresh org with an unguessable
358
+ * slug is created instead.
359
+ *
360
+ * Best-effort: any failure is swallowed and leaves the user no-org.
361
+ */
362
+ async ensureOrganization(userId) {
363
+ const cached = this.#orgCache.get(userId);
364
+ if (cached) return cached;
365
+ try {
366
+ await this.#ensureDbReady();
367
+ const ctx = await this.getAuthContext();
368
+ if (!ctx) return void 0;
369
+ const memberships = await ctx.adapter.findMany({
370
+ model: "member",
371
+ where: [{ field: "userId", value: userId }]
372
+ });
373
+ const firstExisting = memberships.find((m) => m.organizationId)?.organizationId;
374
+ if (firstExisting) {
375
+ this.#orgCache.set(userId, firstExisting);
376
+ return firstExisting;
377
+ }
378
+ const userRecord = await ctx.internalAdapter.findUserById(userId).catch(() => null);
379
+ const label = userRecord?.email ?? userRecord?.name ?? userId;
380
+ const orgName = `${label}'s org`;
381
+ const orgData = () => ({
382
+ name: orgName,
383
+ createdAt: /* @__PURE__ */ new Date(),
384
+ metadata: JSON.stringify({ mastraPersonalOrg: "true" })
385
+ });
386
+ const slug = `personal-${userId}`;
387
+ let organizationId;
388
+ try {
389
+ const created = await ctx.adapter.create({
390
+ model: "organization",
391
+ data: { ...orgData(), slug }
392
+ });
393
+ organizationId = created.id;
394
+ } catch (error) {
395
+ const existing = await ctx.adapter.findOne({
396
+ model: "organization",
397
+ where: [{ field: "slug", value: slug }]
398
+ });
399
+ if (!existing) throw error;
400
+ const existingMembers = await ctx.adapter.findMany({
401
+ model: "member",
402
+ where: [{ field: "organizationId", value: existing.id }]
403
+ });
404
+ const foreignMember = existingMembers.some((m) => m.userId !== userId);
405
+ if (foreignMember) {
406
+ const fallback = await ctx.adapter.create({
407
+ model: "organization",
408
+ data: { ...orgData(), slug: `personal-${userId}-${crypto.randomUUID()}` }
409
+ });
410
+ organizationId = fallback.id;
411
+ } else {
412
+ organizationId = existing.id;
413
+ }
414
+ }
415
+ try {
416
+ await ctx.adapter.create({
417
+ model: "member",
418
+ data: { organizationId, userId, role: "owner", createdAt: /* @__PURE__ */ new Date() }
419
+ });
420
+ } catch (error) {
421
+ const member = await ctx.adapter.findOne({
422
+ model: "member",
423
+ where: [
424
+ { field: "organizationId", value: organizationId },
425
+ { field: "userId", value: userId }
426
+ ]
427
+ });
428
+ if (!member) throw error;
429
+ }
430
+ this.#orgCache.set(userId, organizationId);
431
+ return organizationId;
432
+ } catch (error) {
433
+ console.warn(
434
+ `[BetterAuth] Failed to bootstrap personal organization for user ${userId}. The user will see organization_required until this succeeds.`,
435
+ error
436
+ );
437
+ return void 0;
438
+ }
439
+ }
440
+ /**
441
+ * Whether the user holds an admin-equivalent role (`owner` or `admin`) in
442
+ * the organization. Provider errors resolve to `false`.
443
+ */
444
+ async isOrganizationAdmin(organizationId, userId) {
445
+ try {
446
+ await this.#ensureDbReady();
447
+ const ctx = await this.getAuthContext();
448
+ if (!ctx) return false;
449
+ const membership = await ctx.adapter.findOne({
450
+ model: "member",
451
+ where: [
452
+ { field: "organizationId", value: organizationId },
453
+ { field: "userId", value: userId }
454
+ ]
455
+ });
456
+ return membership?.role === "owner" || membership?.role === "admin";
457
+ } catch {
458
+ return false;
459
+ }
460
+ }
220
461
  /**
221
462
  * Check if sign-up is enabled.
222
463
  * Implements ICredentialsProvider.isSignUpEnabled.
@@ -232,13 +473,21 @@ var MastraAuthBetterAuth = class extends MastraAuthProvider {
232
473
  * Get current user from request.
233
474
  * Implements IUserProvider for EE user awareness in Studio.
234
475
  *
476
+ * Supports both cookie-based sessions and `Authorization: Bearer <token>`
477
+ * requests (e.g. API clients using the token returned by `signIn`).
478
+ *
235
479
  * @param request - Incoming HTTP request
236
480
  * @returns EE User object or null if not authenticated
237
481
  */
238
482
  async getCurrentUser(request) {
239
483
  try {
484
+ await this.#ensureDbReady();
485
+ const headers = new Headers(request.headers);
486
+ const authHeader = headers.get("Authorization");
487
+ const bearerToken = authHeader && authHeader.slice(0, 7).toLowerCase() === "bearer " ? authHeader.slice(7).trim() : void 0;
488
+ await this.ensureSessionCookie(headers, bearerToken);
240
489
  const result = await this.auth.api.getSession({
241
- headers: request.headers
490
+ headers
242
491
  });
243
492
  if (!result?.user) return null;
244
493
  return mapBetterAuthUserToEEUser(result.user);
@@ -250,18 +499,32 @@ var MastraAuthBetterAuth = class extends MastraAuthProvider {
250
499
  * Get user by ID.
251
500
  * Implements IUserProvider for EE user awareness.
252
501
  *
253
- * Note: Better Auth doesn't expose a direct getUser API.
254
- * For full functionality, you may need to implement this using
255
- * direct database access in a subclass.
502
+ * Uses Better Auth's internal adapter to look up the user record,
503
+ * so it works with whichever database Better Auth is configured with.
256
504
  *
257
505
  * @param userId - User identifier
258
506
  * @returns EE User object or null if not found
259
507
  */
260
- async getUser(_userId) {
261
- console.warn(
262
- "[MastraAuthBetterAuth] getUser() requires direct database access. Override this method in a subclass for full user lookup support."
263
- );
264
- return null;
508
+ async getUser(userId) {
509
+ try {
510
+ const ctx = await this.getAuthContext();
511
+ if (!ctx) return null;
512
+ const user = await ctx.internalAdapter.findUserById(userId);
513
+ if (!user) return null;
514
+ return mapBetterAuthUserToEEUser(user);
515
+ } catch {
516
+ return null;
517
+ }
518
+ }
519
+ /**
520
+ * Get multiple users by ID.
521
+ * Optional IUserProvider method used for batch author enrichment.
522
+ *
523
+ * @param userIds - User identifiers
524
+ * @returns EE User objects (null for missing users, order preserved)
525
+ */
526
+ async getUsers(userIds) {
527
+ return Promise.all(userIds.map((userId) => this.getUser(userId)));
265
528
  }
266
529
  /**
267
530
  * Get URL to user's profile page.
@@ -270,11 +533,51 @@ var MastraAuthBetterAuth = class extends MastraAuthProvider {
270
533
  getUserProfileUrl(user) {
271
534
  return `/profile/${user.id}`;
272
535
  }
536
+ /**
537
+ * Get the resolved Better Auth context, or null when unavailable
538
+ * (e.g. a partial mock without `$context`).
539
+ */
540
+ async getAuthContext() {
541
+ try {
542
+ return await Promise.resolve(this.auth.$context) ?? null;
543
+ } catch {
544
+ return null;
545
+ }
546
+ }
547
+ /**
548
+ * Ensure the Cookie header on `headers` carries a Better Auth session cookie
549
+ * for the given token.
550
+ *
551
+ * Better Auth's `getSession` reads the session from a *signed* cookie
552
+ * (`<token>.<HMAC-SHA256 signature>`), while `signIn`/`signUp` return the
553
+ * raw unsigned token. Mirroring Better Auth's bearer plugin, unsigned tokens
554
+ * are signed with the instance secret before being set as the session cookie.
555
+ */
556
+ async ensureSessionCookie(headers, token) {
557
+ if (!token) return;
558
+ const ctx = await this.getAuthContext();
559
+ const cookieName = ctx?.authCookies.sessionToken.name ?? this.sessionCookieName;
560
+ const secret = ctx?.secret;
561
+ const cookieHeader = headers.get("Cookie");
562
+ const hasSessionCookie = !!cookieHeader?.split(";").some((pair) => {
563
+ const [key] = pair.trim().split("=");
564
+ return key?.trim() === cookieName;
565
+ });
566
+ if (hasSessionCookie) return;
567
+ let cookieValue = token.includes(".") ? token.includes("%") ? tryDecode(token) : token : token;
568
+ if (!token.includes(".") && secret) {
569
+ cookieValue = `${token}.${await makeSignature(token, secret)}`;
570
+ }
571
+ const existingCookies = cookieHeader ? `${cookieHeader}; ` : "";
572
+ headers.set("Cookie", `${existingCookies}${cookieName}=${encodeURIComponent(cookieValue)}`);
573
+ }
273
574
  /**
274
575
  * Authenticate a bearer token by verifying the session with Better Auth.
275
576
  *
276
577
  * This method extracts the session from the request headers using
277
- * Better Auth's `api.getSession()` endpoint.
578
+ * Better Auth's `api.getSession()` endpoint. Raw (unsigned) session tokens —
579
+ * as returned by `signIn`/`signUp` — are signed before being passed to
580
+ * Better Auth as a session cookie, matching the bearer plugin's semantics.
278
581
  *
279
582
  * @param token - The bearer token (session token) to authenticate
280
583
  * @param request - The request containing headers
@@ -282,19 +585,13 @@ var MastraAuthBetterAuth = class extends MastraAuthProvider {
282
585
  */
283
586
  async authenticateToken(token, request) {
284
587
  try {
588
+ await this.#ensureDbReady();
285
589
  const headers = new Headers();
286
590
  const cookieHeader = getRequestHeader2(request, "Cookie");
287
591
  if (cookieHeader) {
288
592
  headers.set("Cookie", cookieHeader);
289
593
  }
290
- const hasSessionCookie = !!cookieHeader?.split(";").some((pair) => {
291
- const [key] = pair.trim().split("=");
292
- return key?.trim() === this.sessionCookieName;
293
- });
294
- if (token && !hasSessionCookie) {
295
- const existingCookies = cookieHeader ? `${cookieHeader}; ` : "";
296
- headers.set("Cookie", `${existingCookies}${this.sessionCookieName}=${token}`);
297
- }
594
+ await this.ensureSessionCookie(headers, token);
298
595
  const result = await this.auth.api.getSession({
299
596
  headers
300
597
  });
@@ -414,9 +711,10 @@ var MastraAuthBetterAuth = class extends MastraAuthProvider {
414
711
  * Clears Better Auth's default session cookies.
415
712
  */
416
713
  getClearSessionHeaders() {
714
+ const sameSite = this.#crossSite ? "None; Secure" : "Lax";
417
715
  const cookies = [
418
- `${this.sessionCookieName}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`,
419
- `${this.sessionCookieName}_sig=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`
716
+ `${this.sessionCookieName}=; Path=/; HttpOnly; SameSite=${sameSite}; Max-Age=0`,
717
+ `${this.sessionCookieName}_sig=; Path=/; HttpOnly; SameSite=${sameSite}; Max-Age=0`
420
718
  ];
421
719
  return {
422
720
  "Set-Cookie": cookies.join(", ")