@headless-lms/adapter-auth 0.3.0 → 0.4.0

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.
@@ -1,8 +1,8 @@
1
- import { accessTokenAuthorization, consentOrgId, grantFromClaims, isCompactJws, needsOrgChoice, orgIdFromMetadata, permissionsToScopes, scopesToPermissions } from "./access-grant.js";
2
- import { ac, roles } from "./access.js";
1
+ import { accessTokenAuthorization, consentOrgId, grantFromClaims, isCompactJws, needsOrgChoice, orgIdFromMetadata, roleFromMetadata } from "./access-grant.js";
2
+ import { OWNER_MEMBERSHIP, PARTICIPANT_MEMBERSHIP, ac, roles } from "./access.js";
3
3
  import { withLoopbackNativeDefault } from "./client-registration.js";
4
4
  import { ACCESS_TOKEN_TTL_SECONDS, ORG_CLAIM } from "@headless-lms/core/identity";
5
- import { ACCESS_SCOPES, OrganizationRuleError, parseRole } from "@headless-lms/core/organizations";
5
+ import { ALL_PERMISSIONS, OrganizationRuleError } from "@headless-lms/core/organizations";
6
6
  import { createDpopReplayStore, enforceDpopBinding, isDpopBindingError, verifyJwsAccessToken } from "better-auth/oauth2";
7
7
  import { fromNodeHeaders } from "better-auth/node";
8
8
  import { APIError, betterAuth } from "better-auth";
@@ -10,20 +10,30 @@ import { createAuthMiddleware } from "better-auth/api";
10
10
  import { drizzleAdapter } from "better-auth/adapters/drizzle";
11
11
  import * as authSchema from "@headless-lms/adapter-db/schema/better-auth";
12
12
  import { prefixId } from "@headless-lms/core/shared/id";
13
- import { NotFoundError } from "@headless-lms/core/shared/errors";
14
- import { customSession, jwt, magicLink, organization } from "better-auth/plugins";
13
+ import { ConflictError, NotFoundError } from "@headless-lms/core/shared/errors";
14
+ import { jwt, magicLink, organization } from "better-auth/plugins";
15
+ import { getOrgAdapter } from "better-auth/plugins/organization";
15
16
  import { oauthProviderAuthServerMetadata } from "@better-auth/oauth-provider";
16
17
  import { mcp } from "@better-auth/mcp";
17
18
  import { cimd } from "@better-auth/cimd";
18
19
  import { fetchClientMetadataResource } from "@better-auth/cimd/node";
19
20
  import { apiKey } from "@better-auth/api-key";
20
- import { eq } from "drizzle-orm";
21
+ import { and, count, eq, sql } from "drizzle-orm";
21
22
  //#region src/better-auth.ts
22
23
  const JWT_ALG = "EdDSA";
23
- const UNMIRRORED_ORG_ENDPOINTS = /* @__PURE__ */ new Set([
24
- "/organization/leave",
25
- "/organization/invite-member",
26
- "/organization/accept-invitation"
24
+ const API_KEY_PREFIX = "hlms_";
25
+ const API_KEY_NAME_MAX = 80;
26
+ const DAY_SECONDS = 1440 * 60;
27
+ const OPEN_ORG_ENDPOINTS = /* @__PURE__ */ new Set([
28
+ "/organization/set-active",
29
+ "/organization/list",
30
+ "/organization/get-active-member"
31
+ ]);
32
+ const CORE_API_KEY_ENDPOINTS = /* @__PURE__ */ new Set([
33
+ "/api-key/get",
34
+ "/api-key/list",
35
+ "/api-key/update",
36
+ "/api-key/delete"
27
37
  ]);
28
38
  const OAUTH_CLIENT_WRITES = /* @__PURE__ */ new Set([
29
39
  "/oauth2/create-client",
@@ -42,8 +52,8 @@ function mcpOAuthPlugins(opts) {
42
52
  resource: opts.mcp.resource,
43
53
  loginPage: opts.mcp.loginPage,
44
54
  consentPage: opts.mcp.consentPage,
45
- scopes: [...ACCESS_SCOPES, "offline_access"],
46
- clientRegistrationDefaultScopes: [...ACCESS_SCOPES, "offline_access"],
55
+ scopes: [...ALL_PERMISSIONS, "offline_access"],
56
+ clientRegistrationDefaultScopes: [...ALL_PERMISSIONS, "offline_access"],
47
57
  accessTokenExpiresIn: ACCESS_TOKEN_TTL_SECONDS,
48
58
  allowDynamicClientRegistration: true,
49
59
  allowUnauthenticatedClientRegistration: true,
@@ -71,6 +81,10 @@ function buildAuthOptions(opts) {
71
81
  baseURL: opts.baseUrl,
72
82
  secret: opts.secret,
73
83
  trustedOrigins: opts.trustedOrigins,
84
+ logger: { log: (level, message, ...args) => {
85
+ const [first] = args;
86
+ opts.logger[level](message, first instanceof Error ? { err: first } : args.length > 0 ? { details: args } : void 0);
87
+ } },
74
88
  session: { cookieCache: {
75
89
  enabled: true,
76
90
  maxAge: 300
@@ -105,7 +119,8 @@ function buildAuthOptions(opts) {
105
119
  organization({
106
120
  ac,
107
121
  roles,
108
- creatorRole: "owner",
122
+ creatorRole: OWNER_MEMBERSHIP,
123
+ membershipLimit: Number.MAX_SAFE_INTEGER,
109
124
  organizationHooks: opts.hooks
110
125
  }),
111
126
  jwt({
@@ -115,37 +130,15 @@ function buildAuthOptions(opts) {
115
130
  ...opts.mcp.oauthEnabled ? mcpOAuthPlugins(opts) : [],
116
131
  apiKey({
117
132
  enableMetadata: true,
133
+ defaultPrefix: API_KEY_PREFIX,
134
+ maximumNameLength: API_KEY_NAME_MAX,
118
135
  rateLimit: { enabled: false }
119
- }),
120
- customSession(async ({ user, session }) => {
121
- const memberships = await opts.db.select({
122
- role: authSchema.member.role,
123
- id: authSchema.organization.id,
124
- name: authSchema.organization.name,
125
- slug: authSchema.organization.slug
126
- }).from(authSchema.member).innerJoin(authSchema.organization, eq(authSchema.member.organizationId, authSchema.organization.id)).where(eq(authSchema.member.userId, user.id));
127
- const activeOrgId = session.activeOrganizationId ?? null;
128
- const active = memberships.find((m) => m.id === activeOrgId) ?? null;
129
- return {
130
- user,
131
- session,
132
- activeMemberRole: active?.role ?? null,
133
- activeOrganization: active ? {
134
- id: active.id,
135
- name: active.name,
136
- slug: active.slug
137
- } : null,
138
- organizations: memberships.map(({ id, name, slug }) => ({
139
- id,
140
- name,
141
- slug
142
- }))
143
- };
144
136
  })
145
137
  ],
146
138
  hooks: { before: createAuthMiddleware(async (ctx) => {
147
139
  if (ctx.path === "/api-key/create" && (ctx.request || ctx.headers)) throw new APIError("FORBIDDEN", { message: "API keys are created through the LMS" });
148
- if (UNMIRRORED_ORG_ENDPOINTS.has(ctx.path)) throw new APIError("FORBIDDEN", { message: "Membership changes go through the LMS" });
140
+ if (ctx.request && ctx.path.startsWith("/organization/") && !OPEN_ORG_ENDPOINTS.has(ctx.path)) throw new APIError("FORBIDDEN", { message: "Organizations are managed through the LMS" });
141
+ if (ctx.request && CORE_API_KEY_ENDPOINTS.has(ctx.path)) throw new APIError("FORBIDDEN", { message: "API keys are managed through the LMS" });
149
142
  if (OAUTH_CLIENT_WRITES.has(ctx.path) && (ctx.request || ctx.headers)) throw new APIError("FORBIDDEN", { message: "Apps register themselves when they connect" });
150
143
  if (ctx.path === "/api-key/update" && ctx.body?.metadata !== void 0) throw new APIError("FORBIDDEN", { message: "An API key stays in the organization it was made for" });
151
144
  if (ctx.path !== "/oauth2/register") return;
@@ -161,14 +154,34 @@ function buildAuthOptions(opts) {
161
154
  function createAuth(opts) {
162
155
  return betterAuth(buildAuthOptions(opts));
163
156
  }
157
+ function toStored(key) {
158
+ return {
159
+ id: key.id,
160
+ name: key.name ?? null,
161
+ start: key.start ?? null,
162
+ roleKey: roleFromMetadata(key.metadata),
163
+ createdAt: new Date(key.createdAt),
164
+ lastUsedAt: key.lastRequest ? new Date(key.lastRequest) : null,
165
+ expiresAt: key.expiresAt ? new Date(key.expiresAt) : null
166
+ };
167
+ }
168
+ function apiKeyHeader(headers) {
169
+ const header = headers["x-api-key"];
170
+ if (typeof header === "string" && header.length > 0) return header;
171
+ const authorization = accessTokenAuthorization(headers.authorization);
172
+ if (authorization?.scheme === "Bearer" && !isCompactJws(authorization.token)) return authorization.token;
173
+ return null;
174
+ }
164
175
  var BetterAuth = class {
165
176
  auth;
177
+ db;
166
178
  logger;
167
179
  mcp;
168
180
  issuer;
169
181
  jwksCacheKey = {};
170
182
  constructor(opts, auth) {
171
183
  this.auth = auth ?? createAuth(opts);
184
+ this.db = opts.db;
172
185
  this.logger = opts.logger;
173
186
  this.mcp = opts.mcp;
174
187
  this.issuer = `${opts.baseUrl.replace(/\/+$/, "")}/api/auth`;
@@ -183,13 +196,16 @@ var BetterAuth = class {
183
196
  return oauthProviderAuthServerMetadata(this.auth)(request);
184
197
  }
185
198
  async verifyAccess(request) {
186
- const apiKeyHeader = request.headers["x-api-key"];
187
- if (typeof apiKeyHeader === "string" && apiKeyHeader.length > 0) return this.verifyApiKey(apiKeyHeader);
199
+ const key = apiKeyHeader(request.headers);
200
+ if (key) return this.grantForKey(key);
188
201
  const authorization = accessTokenAuthorization(request.headers.authorization);
189
202
  if (!authorization) return null;
190
- if (authorization.scheme === "Bearer" && !isCompactJws(authorization.token)) return this.verifyApiKey(authorization.token);
191
203
  return this.mcp.oauthEnabled ? this.verifyAccessToken(authorization, request) : null;
192
204
  }
205
+ async verifyApiKey(headers) {
206
+ const key = apiKeyHeader(headers);
207
+ return key ? this.grantForKey(key) : null;
208
+ }
193
209
  async verifyAccessToken(authorization, request) {
194
210
  let claims;
195
211
  try {
@@ -239,37 +255,82 @@ var BetterAuth = class {
239
255
  }
240
256
  ]
241
257
  });
242
- return consent ? {
258
+ if (!consent || !("scopes" in grant.limit)) return null;
259
+ return {
243
260
  ...grant,
244
- scopes: grant.scopes.filter((scope) => consent.scopes.includes(scope))
245
- } : null;
261
+ limit: { scopes: grant.limit.scopes.filter((scope) => consent.scopes.includes(scope)) }
262
+ };
246
263
  }
247
- async verifyApiKey(key) {
264
+ async grantForKey(key) {
248
265
  const result = await this.pluginApi.verifyApiKey({ body: { key } });
249
266
  if (!result.valid || !result.key) return null;
250
267
  const orgId = orgIdFromMetadata(result.key.metadata);
251
- if (!orgId) return null;
268
+ const roleKey = roleFromMetadata(result.key.metadata);
269
+ if (!orgId || !roleKey) return null;
252
270
  return {
253
271
  userId: result.key.referenceId,
254
272
  orgId,
255
- scopes: permissionsToScopes(result.key.permissions),
256
- clientId: `api-key:${result.key.id}`
273
+ clientId: `api-key:${result.key.id}`,
274
+ limit: { roleKey }
275
+ };
276
+ }
277
+ async ownKeys(orgId, userExternalId) {
278
+ return (await (await this.auth.$context).adapter.findMany({
279
+ model: "apikey",
280
+ where: [{
281
+ field: "referenceId",
282
+ value: userExternalId
283
+ }]
284
+ })).filter((key) => orgIdFromMetadata(key.metadata) === orgId);
285
+ }
286
+ async list(orgId, userExternalId) {
287
+ return (await this.ownKeys(orgId, userExternalId)).map(toStored);
288
+ }
289
+ async create(orgId, userExternalId, input) {
290
+ const created = await this.pluginApi.createApiKey({ body: {
291
+ name: input.name,
292
+ userId: userExternalId,
293
+ metadata: {
294
+ organizationId: orgId,
295
+ role: input.roleKey
296
+ },
297
+ ...input.expiresInDays !== void 0 && { expiresIn: input.expiresInDays * DAY_SECONDS }
298
+ } });
299
+ return {
300
+ ...toStored(created),
301
+ key: created.key
257
302
  };
258
303
  }
259
- async sessionUserId(headers) {
260
- const session = await this.auth.api.getSession({ headers: fromNodeHeaders(headers) });
261
- if (!session) throw new OrganizationRuleError("No session");
262
- return session.user.id;
304
+ async countWithRole(orgId, roleKey) {
305
+ const metadata = sql`${authSchema.apikey.metadata}::jsonb`;
306
+ const [row] = await this.db.select({ value: count() }).from(authSchema.apikey).where(and(sql`${metadata} ->> 'organizationId' = ${orgId}`, sql`${metadata} ->> 'role' = ${roleKey}`));
307
+ return row?.value ?? 0;
308
+ }
309
+ async createAccount(input) {
310
+ return { externalId: (await (await this.auth.$context).internalAdapter.createUser({
311
+ email: input.email,
312
+ name: input.name,
313
+ emailVerified: true
314
+ }, { method: "admin" })).id };
315
+ }
316
+ async revoke(orgId, userExternalId, keyId) {
317
+ if (!(await this.ownKeys(orgId, userExternalId)).some((key) => key.id === keyId)) throw new NotFoundError("API key", keyId);
318
+ await (await this.auth.$context).adapter.delete({
319
+ model: "apikey",
320
+ where: [{
321
+ field: "id",
322
+ value: keyId
323
+ }]
324
+ });
263
325
  }
264
- async listConnectedApps(headers, orgId) {
265
- const userId = await this.sessionUserId(headers);
326
+ async listApps(orgId, userExternalId) {
266
327
  if (!this.mcp.oauthEnabled) return [];
267
328
  const ctx = await this.auth.$context;
268
329
  const consents = await ctx.adapter.findMany({
269
330
  model: "oauthConsent",
270
331
  where: [{
271
332
  field: "userId",
272
- value: userId
333
+ value: userExternalId
273
334
  }, {
274
335
  field: "referenceId",
275
336
  value: orgId
@@ -292,8 +353,7 @@ var BetterAuth = class {
292
353
  };
293
354
  }));
294
355
  }
295
- async revokeConnectedApp(headers, orgId, clientId) {
296
- const userId = await this.sessionUserId(headers);
356
+ async revokeApp(orgId, userExternalId, clientId) {
297
357
  if (!this.mcp.oauthEnabled) return;
298
358
  const ctx = await this.auth.$context;
299
359
  const where = [
@@ -303,7 +363,7 @@ var BetterAuth = class {
303
363
  },
304
364
  {
305
365
  field: "userId",
306
- value: userId
366
+ value: userExternalId
307
367
  },
308
368
  {
309
369
  field: "referenceId",
@@ -323,38 +383,41 @@ var BetterAuth = class {
323
383
  where
324
384
  });
325
385
  }
326
- async listApiKeys(headers, orgId) {
327
- return (await this.pluginApi.listApiKeys({ headers: fromNodeHeaders(headers) })).apiKeys.filter((key) => orgIdFromMetadata(key.metadata) === orgId).map((key) => ({
328
- id: key.id,
329
- name: key.name ?? null,
330
- start: key.start ?? null,
331
- scopes: permissionsToScopes(key.permissions),
332
- createdAt: new Date(key.createdAt),
333
- lastUsedAt: key.lastRequest ? new Date(key.lastRequest) : null
334
- }));
335
- }
336
- async createApiKey(input) {
337
- const created = await this.pluginApi.createApiKey({ body: {
338
- name: input.name,
339
- userId: input.authUserId,
340
- metadata: { organizationId: input.orgId },
341
- permissions: scopesToPermissions(input.scopes)
342
- } });
343
- return {
344
- id: created.id,
345
- name: created.name ?? null,
346
- start: created.start ?? null,
347
- scopes: permissionsToScopes(created.permissions),
348
- createdAt: new Date(created.createdAt),
349
- lastUsedAt: null,
350
- key: created.key
351
- };
352
- }
353
- async deleteApiKey(headers, orgId, keyId) {
354
- if (!(await this.listApiKeys(headers, orgId)).some((key) => key.id === keyId)) throw new NotFoundError("API key", keyId);
355
- await this.pluginApi.deleteApiKey({
356
- body: { keyId },
357
- headers: fromNodeHeaders(headers)
386
+ async revokeAllFor(orgId, userExternalId) {
387
+ const ctx = await this.auth.$context;
388
+ const keys = await this.ownKeys(orgId, userExternalId);
389
+ for (const key of keys) await ctx.adapter.delete({
390
+ model: "apikey",
391
+ where: [{
392
+ field: "id",
393
+ value: key.id
394
+ }]
395
+ });
396
+ if (this.mcp.oauthEnabled) {
397
+ const where = [{
398
+ field: "userId",
399
+ value: userExternalId
400
+ }, {
401
+ field: "referenceId",
402
+ value: orgId
403
+ }];
404
+ await ctx.adapter.deleteMany({
405
+ model: "oauthAccessToken",
406
+ where
407
+ });
408
+ await ctx.adapter.deleteMany({
409
+ model: "oauthRefreshToken",
410
+ where
411
+ });
412
+ await ctx.adapter.deleteMany({
413
+ model: "oauthConsent",
414
+ where
415
+ });
416
+ }
417
+ this.logger.info("access revoked for person", {
418
+ orgId,
419
+ userExternalId,
420
+ apiKeys: keys.length
358
421
  });
359
422
  }
360
423
  async verify(headers) {
@@ -398,35 +461,42 @@ var BetterAuth = class {
398
461
  createdAt: org.createdAt
399
462
  };
400
463
  }
464
+ async createOrganizationFor(ownerExternalId, input) {
465
+ let org;
466
+ try {
467
+ org = await this.auth.api.createOrganization({ body: {
468
+ name: input.name,
469
+ slug: input.slug,
470
+ userId: ownerExternalId
471
+ } });
472
+ } catch (err) {
473
+ if (err instanceof APIError && err.body?.code === "ORGANIZATION_ALREADY_EXISTS") throw new ConflictError("That slug is already in use");
474
+ throw err;
475
+ }
476
+ if (!org) throw new Error(`Couldn't create organization`);
477
+ return {
478
+ id: org.id,
479
+ name: org.name,
480
+ slug: org.slug,
481
+ createdAt: org.createdAt
482
+ };
483
+ }
401
484
  async setActiveOrganization(headers, externalId) {
402
485
  await this.auth.api.setActiveOrganization({
403
486
  body: { organizationId: externalId },
404
487
  headers: fromNodeHeaders(headers)
405
488
  });
406
489
  }
407
- async updateOrganization(headers, externalId, input) {
408
- try {
409
- const org = await this.auth.api.updateOrganization({
410
- body: {
411
- organizationId: externalId,
412
- data: {
413
- name: input.name,
414
- slug: input.slug
415
- }
416
- },
417
- headers: fromNodeHeaders(headers)
418
- });
419
- if (!org) throw new Error(`Couldn't update organization`);
420
- return {
421
- id: org.id,
422
- name: org.name,
423
- slug: org.slug,
424
- createdAt: org.createdAt
425
- };
426
- } catch (err) {
427
- if (err instanceof APIError) throw new OrganizationRuleError(err.message || "Could not update organization");
428
- throw err;
429
- }
490
+ async orgAdapter() {
491
+ return getOrgAdapter(await this.auth.$context, {});
492
+ }
493
+ async updateOrganization(externalId, input) {
494
+ const data = {
495
+ ...input.name !== void 0 && { name: input.name },
496
+ ...input.slug !== void 0 && { slug: input.slug }
497
+ };
498
+ if (Object.keys(data).length === 0) return;
499
+ if (!await (await this.orgAdapter()).updateOrganization(externalId, data)) throw new NotFoundError("Organization", externalId);
430
500
  }
431
501
  async deleteOrganization(headers, externalId) {
432
502
  try {
@@ -439,55 +509,50 @@ var BetterAuth = class {
439
509
  throw err;
440
510
  }
441
511
  }
442
- async grantMembership(orgExternalId, userExternalId, role) {
443
- try {
444
- await this.auth.api.addMember({ body: {
445
- userId: userExternalId,
512
+ async grantMembership(orgExternalId, userExternalId) {
513
+ const adapter = await this.orgAdapter();
514
+ if (await adapter.findMemberByOrgId({
515
+ userId: userExternalId,
516
+ organizationId: orgExternalId
517
+ })) return;
518
+ await adapter.createMember({
519
+ organizationId: orgExternalId,
520
+ userId: userExternalId,
521
+ role: PARTICIPANT_MEMBERSHIP
522
+ });
523
+ }
524
+ async setMembershipOwner(orgExternalId, userExternalId, owner) {
525
+ const adapter = await this.orgAdapter();
526
+ const role = owner ? OWNER_MEMBERSHIP : PARTICIPANT_MEMBERSHIP;
527
+ const existing = await adapter.findMemberByOrgId({
528
+ userId: userExternalId,
529
+ organizationId: orgExternalId
530
+ });
531
+ if (!existing) {
532
+ await adapter.createMember({
446
533
  organizationId: orgExternalId,
447
- role: parseRole(role)
448
- } });
449
- } catch (err) {
450
- if (err instanceof APIError && err.body?.code === "USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION") return;
451
- throw err;
534
+ userId: userExternalId,
535
+ role
536
+ });
537
+ return;
452
538
  }
539
+ if (existing.role !== role) await adapter.updateMember(existing.id, role);
453
540
  }
454
- async updateRole(ctx, userExternalId, role) {
455
- await this.auth.api.updateMemberRole({
456
- body: {
457
- memberId: await this.resolveMemberId(ctx, userExternalId),
458
- role,
459
- organizationId: ctx.authOrgId
460
- },
461
- headers: this.headersOf(ctx)
541
+ async removeMembership(orgExternalId, userExternalId) {
542
+ const adapter = await this.orgAdapter();
543
+ const existing = await adapter.findMemberByOrgId({
544
+ userId: userExternalId,
545
+ organizationId: orgExternalId
462
546
  });
463
- }
464
- async removeMember(ctx, userExternalId) {
465
- await this.auth.api.removeMember({
466
- body: {
467
- memberIdOrEmail: await this.resolveMemberId(ctx, userExternalId),
468
- organizationId: ctx.authOrgId
469
- },
470
- headers: this.headersOf(ctx)
547
+ if (!existing) return;
548
+ await adapter.deleteMember({
549
+ memberId: existing.id,
550
+ organizationId: orgExternalId,
551
+ userId: userExternalId
471
552
  });
472
553
  }
473
- headersOf(ctx) {
474
- return fromNodeHeaders(ctx.headers);
475
- }
476
- async resolveMemberId(ctx, userExternalId) {
477
- const memberId = (await this.auth.api.listMembers({
478
- query: {
479
- organizationId: ctx.authOrgId,
480
- filterField: "userId",
481
- filterValue: userExternalId,
482
- limit: 1
483
- },
484
- headers: this.headersOf(ctx)
485
- }))?.members?.[0]?.id;
486
- if (!memberId) throw new OrganizationRuleError("That person is no longer a member of this organization");
487
- return memberId;
488
- }
489
554
  };
490
555
  //#endregion
491
- export { BetterAuth, buildAuthOptions, createAuth };
556
+ export { API_KEY_PREFIX, BetterAuth, buildAuthOptions, createAuth };
492
557
 
493
558
  //# sourceMappingURL=better-auth.js.map