@protobase/server 0.33.4 → 0.34.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.
Files changed (77) hide show
  1. package/dist/assistant/query-tools.js +1 -1
  2. package/dist/auth/jwt.d.ts +15 -1
  3. package/dist/auth/jwt.js +41 -5
  4. package/dist/better-auth/authenticator.d.ts +16 -1
  5. package/dist/better-auth/authenticator.js +41 -24
  6. package/dist/better-auth/create-auth.d.ts +3063 -364
  7. package/dist/better-auth/create-auth.js +36 -19
  8. package/dist/better-auth/mcp/connected-apps.d.ts +13 -0
  9. package/dist/better-auth/mcp/connected-apps.js +86 -0
  10. package/dist/better-auth/mcp/consent-check.js +38 -0
  11. package/dist/better-auth/mcp/consent-reference.js +16 -0
  12. package/dist/better-auth/mcp/discovery-routes.js +19 -0
  13. package/dist/better-auth/mcp/grant-reference.js +25 -0
  14. package/dist/better-auth/mcp/mcp-plugins.d.ts +13 -0
  15. package/dist/better-auth/mcp/mcp-plugins.js +107 -0
  16. package/dist/better-auth/mcp/mcp-token-claims.js +39 -0
  17. package/dist/better-auth/mcp/request-defaults.js +57 -0
  18. package/dist/better-auth/mcp/revoke-grants.js +75 -0
  19. package/dist/better-auth/mcp/setup-gate.js +38 -0
  20. package/dist/better-auth/mcp/signed-query-guard.js +29 -0
  21. package/dist/better-auth/origins.d.ts +7 -0
  22. package/dist/better-auth/origins.js +8 -1
  23. package/dist/better-auth/password-reset.js +4 -3
  24. package/dist/better-auth/policy-plugin.js +1 -24
  25. package/dist/better-auth/sign-in-enforcement.js +6 -1
  26. package/dist/better-auth/sign-in-setup.js +38 -0
  27. package/dist/better-auth/snake-case-names.js +102 -1
  28. package/dist/better-auth/staff-checks.js +9 -2
  29. package/dist/better-auth/staff-plugin.js +9 -2
  30. package/dist/better-auth/status-route.js +2 -1
  31. package/dist/better-auth/token-claims.js +19 -0
  32. package/dist/better-auth/users.d.ts +4 -1
  33. package/dist/better-auth/users.js +6 -1
  34. package/dist/check-roles.js +1 -1
  35. package/dist/create-admin.js +8 -0
  36. package/dist/deps.d.ts +1 -0
  37. package/dist/encryption/apply.d.ts +40 -0
  38. package/dist/encryption/apply.js +99 -0
  39. package/dist/encryption/binding.d.ts +2 -0
  40. package/dist/encryption/binding.js +53 -0
  41. package/dist/encryption/capture-off.js +12 -0
  42. package/dist/encryption/cascading-keys.d.ts +9 -0
  43. package/dist/encryption/cascading-keys.js +21 -0
  44. package/dist/encryption/envelope-open.d.ts +5 -0
  45. package/dist/encryption/envelope-open.js +57 -0
  46. package/dist/encryption/envelope-seal.js +38 -0
  47. package/dist/encryption/keyring.js +60 -0
  48. package/dist/encryption/mask-stored.js +16 -0
  49. package/dist/encryption/rebind-check.js +28 -0
  50. package/dist/encryption/remnants.js +53 -0
  51. package/dist/encryption/runtime.d.ts +1 -0
  52. package/dist/encryption/runtime.js +18 -0
  53. package/dist/encryption/scan.d.ts +4 -0
  54. package/dist/encryption/scan.js +61 -0
  55. package/dist/encryption/seal-values.js +30 -0
  56. package/dist/encryption/shared-columns.js +19 -0
  57. package/dist/encryption/status.d.ts +39 -0
  58. package/dist/encryption/status.js +92 -0
  59. package/dist/encryption/stored-value.js +16 -0
  60. package/dist/encryption/tools.d.ts +26 -0
  61. package/dist/encryption/tools.js +34 -0
  62. package/dist/forms/check-forms.js +1 -1
  63. package/dist/gotrue/user-routes.js +6 -0
  64. package/dist/index.d.ts +9 -5
  65. package/dist/index.js +3 -2
  66. package/dist/records.js +3 -3
  67. package/dist/request-access.js +9 -5
  68. package/dist/rest/encrypted-columns.js +37 -0
  69. package/dist/rest/table-routes.js +25 -13
  70. package/dist/reveal-service.js +17 -3
  71. package/dist/types.d.ts +5 -0
  72. package/dist/validate-body.js +4 -2
  73. package/dist/webhooks/subscriber-view.js +14 -1
  74. package/dist/write-pipeline.js +14 -2
  75. package/dist/write-shared.js +7 -3
  76. package/dist/write-update.js +12 -2
  77. package/package.json +11 -7
@@ -3,7 +3,6 @@ import { appNameOver, defaultAppName } from "../app-name.js";
3
3
  import { goTrueIssuer, goTrueKeyPair } from "../gotrue/keys.js";
4
4
  import { defaultSignInPolicy } from "./sign-in-policy.js";
5
5
  import { roleDefinitions } from "./organizations/role-definitions.js";
6
- import { narrowClaims } from "./roles/active-role.js";
7
6
  import { signInPolicyPlugin } from "./policy-plugin.js";
8
7
  import { goTrueSessionsPlugin } from "../gotrue/sessions-plugin.js";
9
8
  import { authBasePath } from "./auth-base-path.js";
@@ -11,12 +10,15 @@ import { platformMailer } from "../mail/platform-mailer.js";
11
10
  import { readOperatorSettings, resolveOperator } from "./operator-provider.js";
12
11
  import { defaultInvitationUrl } from "./invitations/invitation-link-hook.js";
13
12
  import { lastAdminGuardPlugin } from "./last-admin-guard.js";
14
- import { lastOrganizationField, organizationClaims, sessionOrganizationHooks } from "./organizations/session-organization.js";
13
+ import { revokeMcpGrants } from "./mcp/revoke-grants.js";
14
+ import { coreNames, pluginNames } from "./snake-case-names.js";
15
+ import { lastOrganizationField, sessionOrganizationHooks } from "./organizations/session-organization.js";
16
+ import { tokenClaims } from "./token-claims.js";
17
+ import { mcpAuthPlugins, mcpUnusedPaths, resolveMcp } from "./mcp/mcp-plugins.js";
15
18
  import { organizationPlugin } from "./organizations/organization-plugin.js";
16
19
  import { protobaseOrganizationPlugin } from "./organizations/protobase-plugin.js";
17
20
  import { resolveOrganizations } from "./organizations/options.js";
18
21
  import { passkeyHosts, readPasskeyDomain, resolvePasskeyDomain } from "./passkey-domain.js";
19
- import { coreNames, pluginNames } from "./snake-case-names.js";
20
22
  import { passkeyPlugin } from "./passkey-plugin.js";
21
23
  import { passwordResetOptions } from "./password-reset.js";
22
24
  import { rolesPlugin } from "./roles/roles-plugin.js";
@@ -57,7 +59,7 @@ const organizationPaths = ["/organization/set-active", "/organization/accept-inv
57
59
  * The options `createAuth` gives Better Auth, with the roles, default role, mailer, operator provider and platform
58
60
  * sign-in provider already resolved.
59
61
  */
60
- const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, definitions, defaultRole, appName = defaultAppName, mailer, signUpRole, operator, platformSignIn, saml = [], organizations, context = noContext, signInPolicy = defaultSignInPolicy }) => {
62
+ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, definitions, defaultRole, appName = defaultAppName, mailer, signUpRole, operator, platformSignIn, saml = [], organizations, mcp, context = noContext, signInPolicy = defaultSignInPolicy }) => {
61
63
  const secure = options.baseURL.startsWith("https://");
62
64
  const signIn = platformSignInFor(platformSignIn, Object.keys(options.socialProviders ?? {}));
63
65
  const hosts = passkeyHosts([options.baseURL, ...origins]);
@@ -88,14 +90,14 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
88
90
  audience: options.baseURL,
89
91
  definePayload: async ({ user, session }) => {
90
92
  const { activeRole, activeOrganizationId } = session;
91
- const claims = {
92
- role: user.role,
93
- ...organizations && await organizationClaims((await context()).adapter, user, activeOrganizationId)
94
- };
93
+ const wanted = [activeRole, user.lastRole];
95
94
  return {
96
- email: user.email,
97
- sid: session.id,
98
- ...narrowClaims(claims, [activeRole, user.lastRole], Boolean(organizations))
95
+ ...await tokenClaims(user, {
96
+ wanted,
97
+ organizationId: activeOrganizationId,
98
+ ...organizations && { organizations: (await context()).adapter }
99
+ }),
100
+ sid: session.id
99
101
  };
100
102
  }
101
103
  },
@@ -122,7 +124,7 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
122
124
  disableSignUp: true,
123
125
  minPasswordLength: 12,
124
126
  ...passwordHashes.length > 0 && { password: { verify: verifyAcceptedHash(passwordHashes) } },
125
- ...mail && passwordResetOptions(mail)
127
+ ...mail && passwordResetOptions(mail, mcp ? { onPasswordReset: async ({ user }) => revokeMcpGrants((await context()).adapter, user.id) } : {})
126
128
  },
127
129
  ...options.socialProviders && { socialProviders: options.socialProviders },
128
130
  user: Object.keys(userFields).length > 0 ? {
@@ -192,13 +194,22 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
192
194
  invitationSeconds: organizations?.invitationExpiresIn ?? 604800,
193
195
  ...organizations && { organizations }
194
196
  }),
195
- ...passwordHashes.length > 0 ? [rehashPasswordsPlugin(passwordHashes)] : []
197
+ ...passwordHashes.length > 0 ? [rehashPasswordsPlugin(passwordHashes)] : [],
198
+ ...mcp ? mcpAuthPlugins({
199
+ mcp,
200
+ organizations: Boolean(organizations),
201
+ mail: Boolean(mailer),
202
+ defaultPolicy: signInPolicy,
203
+ definitions,
204
+ context
205
+ }) : []
196
206
  ].map((plugin) => withUserColumns(plugin, users)),
197
207
  disabledPaths: [
198
208
  ...unusedEmailCodePaths,
199
209
  ...adminImpersonationPaths,
200
210
  ...organizations ? organizationPaths : [],
201
- ...saml.length > 0 ? samlDisabledPaths : []
211
+ ...saml.length > 0 ? samlDisabledPaths : [],
212
+ ...mcp ? mcpUnusedPaths : []
202
213
  ],
203
214
  databaseHooks: {
204
215
  user: {
@@ -294,6 +305,7 @@ const createAuth = (options) => {
294
305
  const origins = resolveOrigins(options.baseURL, options.origins ?? platformOrigins());
295
306
  const passkeyDomain = resolvePasskeyDomain(options.passkeyDomain ?? platformPasskeyDomain(), [options.baseURL, ...origins]);
296
307
  const organizations = options.organizations && resolveOrganizations(options.organizations, definitions, options.baseURL);
308
+ const mcp = options.mcp && resolveMcp(options.baseURL, options.mcp);
297
309
  let instance;
298
310
  const context = () => instance?.$context ?? Promise.reject(/* @__PURE__ */ new Error("The auth instance is not made yet"));
299
311
  const auth = betterAuth(betterAuthOptions(options, {
@@ -309,10 +321,9 @@ const createAuth = (options) => {
309
321
  platformSignIn,
310
322
  saml,
311
323
  signInPolicy,
312
- ...organizations && {
313
- organizations,
314
- context
315
- }
324
+ ...organizations && { organizations },
325
+ ...mcp && { mcp },
326
+ ...(organizations || mcp) && { context }
316
327
  }));
317
328
  instance = auth;
318
329
  return Object.assign(auth, {
@@ -350,7 +361,13 @@ const createAuth = (options) => {
350
361
  samlProviders: saml.map(({ id, name }) => ({
351
362
  id,
352
363
  name
353
- }))
364
+ })),
365
+ /** The OAuth provider for MCP clients, when on: the endpoint's resource (its URL) and path, and the scope its tokens carry. */
366
+ mcp: mcp && {
367
+ resource: mcp.resource,
368
+ path: mcp.path,
369
+ scope: "mcp"
370
+ }
354
371
  });
355
372
  };
356
373
  //#endregion
@@ -0,0 +1,13 @@
1
+ import "zod";
2
+ //#region src/better-auth/mcp/connected-apps.d.ts
3
+ /** An app the person allowed: the client, the name it gave itself, when they last allowed it, and as what. */
4
+ export type ConnectedApp = {
5
+ clientId: string;
6
+ name?: string;
7
+ allowedAt: string;
8
+ access: {
9
+ role: string;
10
+ organization?: string;
11
+ }[];
12
+ };
13
+ //#endregion
@@ -0,0 +1,86 @@
1
+ import { everyRow } from "../organizations/store.js";
2
+ import { consentModel } from "./consent-check.js";
3
+ import { revokeMcpGrants } from "./revoke-grants.js";
4
+ import { readGrantReference } from "./grant-reference.js";
5
+ import * as z$1 from "zod";
6
+ import { createAuthEndpoint, sessionMiddleware } from "better-auth/api";
7
+ //#region src/better-auth/mcp/connected-apps.ts
8
+ const allowedAt = (consent) => new Date(consent.updatedAt ?? consent.createdAt);
9
+ /**
10
+ * The person's consents as the apps they allowed, newest first: one entry per client, with the role (as its label)
11
+ * and organization (as its name) of each consent it holds. A consent whose client is gone is left out.
12
+ */
13
+ const connectedApps = (consents, clients, { roleLabel, organizationName }) => {
14
+ const byClient = /* @__PURE__ */ new Map();
15
+ for (const consent of consents) byClient.set(consent.clientId, [...byClient.get(consent.clientId) ?? [], consent]);
16
+ return clients.filter((client) => byClient.has(client.clientId)).map((client) => {
17
+ const own = byClient.get(client.clientId).toSorted((a, b) => allowedAt(b).getTime() - allowedAt(a).getTime());
18
+ const access = own.map((consent) => readGrantReference(consent.referenceId)).map(({ role, organization }) => ({
19
+ role: role ? roleLabel(role) : "",
20
+ ...organization && { organization: organizationName(organization) }
21
+ }));
22
+ return {
23
+ clientId: client.clientId,
24
+ ...client.name && { name: client.name },
25
+ allowedAt: allowedAt(own[0]).toISOString(),
26
+ access
27
+ };
28
+ }).toSorted((a, b) => b.allowedAt.localeCompare(a.allowedAt));
29
+ };
30
+ const idsIn = (field, values) => [{
31
+ field,
32
+ value: values,
33
+ operator: "in"
34
+ }];
35
+ /**
36
+ * `GET /account/connected-apps`: the apps the signed-in person allowed to act as them (`connectedApps`).
37
+ * `POST /account/connected-apps/disconnect { clientId }`: disconnects one, forgetting its consents and revoking its
38
+ * tokens (`revokeMcpGrants`); one with nothing left is no error. Both act on the session's own person only.
39
+ */
40
+ const mcpConnectedApps = ({ definitions }) => ({
41
+ id: "protobase-mcp-connected-apps",
42
+ endpoints: {
43
+ listConnectedApps: createAuthEndpoint("/account/connected-apps", {
44
+ method: "GET",
45
+ use: [sessionMiddleware]
46
+ }, async (ctx) => {
47
+ const { adapter } = ctx.context;
48
+ const consents = await adapter.findMany({
49
+ model: consentModel,
50
+ where: [{
51
+ field: "userId",
52
+ value: ctx.context.session.user.id
53
+ }],
54
+ limit: everyRow
55
+ });
56
+ const clientIds = [...new Set(consents.map((consent) => consent.clientId))];
57
+ const clients = clientIds.length === 0 ? [] : await adapter.findMany({
58
+ model: "oauthClient",
59
+ where: idsIn("clientId", clientIds),
60
+ limit: everyRow
61
+ });
62
+ const organizationIds = [...new Set(consents.map((consent) => readGrantReference(consent.referenceId).organization).filter((id) => id !== void 0))];
63
+ const organizations = organizationIds.length === 0 ? [] : await adapter.findMany({
64
+ model: "organization",
65
+ where: idsIn("id", organizationIds),
66
+ limit: everyRow
67
+ });
68
+ const names = new Map(organizations.map((organization) => [organization.id, organization.name]));
69
+ const apps = connectedApps(consents, clients, {
70
+ roleLabel: (role) => definitions.labels[role] ?? role,
71
+ organizationName: (id) => names.get(id) ?? id
72
+ });
73
+ return ctx.json({ apps });
74
+ }),
75
+ disconnectApp: createAuthEndpoint("/account/connected-apps/disconnect", {
76
+ method: "POST",
77
+ use: [sessionMiddleware],
78
+ body: z$1.object({ clientId: z$1.string().min(1) })
79
+ }, async (ctx) => {
80
+ await revokeMcpGrants(ctx.context.adapter, ctx.context.session.user.id, ctx.body.clientId);
81
+ return ctx.json({ disconnected: true });
82
+ })
83
+ }
84
+ });
85
+ //#endregion
86
+ export { connectedApps, mcpConnectedApps };
@@ -0,0 +1,38 @@
1
+ import { APIError } from "better-auth/api";
2
+ //#region src/better-auth/mcp/consent-check.ts
3
+ /** Better Auth's model of the consents the OAuth provider stores, one per client, user and grant. */
4
+ const consentModel = "oauthConsent";
5
+ const disconnected = () => new APIError("BAD_REQUEST", {
6
+ error: "invalid_grant",
7
+ error_description: "You disconnected this client: connect it again."
8
+ });
9
+ /**
10
+ * A provider extension that makes the consent the one source of truth for issuing tokens: every MCP access token, at
11
+ * the code exchange and at each refresh, needs the consent of its client, user and grant (`referenceId`) to still
12
+ * exist. Disconnecting a client deletes its consents, so a code issued just before, or a refresh token left over,
13
+ * gets `invalid_grant`. It runs before the provider writes a new refresh token.
14
+ */
15
+ const mcpConsentCheck = () => ({ claims: { accessToken: async ({ ctx, client, user, referenceId }) => {
16
+ if (!user || !referenceId) throw disconnected();
17
+ const where = [
18
+ {
19
+ field: "clientId",
20
+ value: client.clientId
21
+ },
22
+ {
23
+ field: "userId",
24
+ value: user.id
25
+ },
26
+ {
27
+ field: "referenceId",
28
+ value: referenceId
29
+ }
30
+ ];
31
+ if (!await ctx.context.adapter.findOne({
32
+ model: "oauthConsent",
33
+ where
34
+ })) throw disconnected();
35
+ return {};
36
+ } } });
37
+ //#endregion
38
+ export { consentModel, mcpConsentCheck };
@@ -0,0 +1,16 @@
1
+ import { sessionRoles } from "../roles/session-role.js";
2
+ import { grantReference } from "./grant-reference.js";
3
+ //#region src/better-auth/mcp/consent-reference.ts
4
+ /**
5
+ * The `referenceId` of a consent given from `session`: the role the session acts with, as its next token would carry
6
+ * it, and its active organization. The session's own `activeRole` is not enough: one that never switched role has none.
7
+ */
8
+ const consentReference = async (context, { user, session }) => grantReference({
9
+ role: (await sessionRoles(context, {
10
+ user,
11
+ session
12
+ })).active,
13
+ organization: session.activeOrganizationId ?? void 0
14
+ });
15
+ //#endregion
16
+ export { consentReference };
@@ -0,0 +1,19 @@
1
+ import { oauthWellKnownPaths } from "../origins.js";
2
+ import { authBasePath } from "../auth-base-path.js";
3
+ import { cors } from "hono/cors";
4
+ //#region src/better-auth/mcp/discovery-routes.ts
5
+ const openPaths = [
6
+ ...oauthWellKnownPaths.flatMap((path) => [path, `${path}/*`]),
7
+ `${authBasePath}/oauth2/register`,
8
+ `${authBasePath}/oauth2/token`
9
+ ];
10
+ /**
11
+ * Routes MCP clients find the app's OAuth provider by: the two discovery documents, which Better Auth answers outside
12
+ * its base path, and open CORS on them and on registration and the token endpoint, before the auth route answers.
13
+ */
14
+ const mcpDiscovery = (app, auth) => {
15
+ for (const path of openPaths) app.use(path, cors({ origin: "*" }));
16
+ for (const path of oauthWellKnownPaths) app.get(path, (c) => auth.handler(c.req.raw)).get(`${path}/*`, (c) => auth.handler(c.req.raw));
17
+ };
18
+ //#endregion
19
+ export { mcpDiscovery };
@@ -0,0 +1,25 @@
1
+ //#region src/better-auth/mcp/grant-reference.ts
2
+ /**
3
+ * The grant written as a consent's `referenceId`: the same text for the same grant, so a consent made with one role
4
+ * is not found for another, and the person is asked again.
5
+ */
6
+ const grantReference = ({ role, organization }) => {
7
+ const entries = Object.entries({
8
+ organization,
9
+ role
10
+ }).filter((entry) => typeof entry[1] === "string" && entry[1] !== "");
11
+ return new URLSearchParams(entries.sort(([a], [b]) => a.localeCompare(b))).toString();
12
+ };
13
+ /** The grant a `referenceId` holds; none for a missing or empty one. */
14
+ const readGrantReference = (reference) => {
15
+ if (!reference) return {};
16
+ const params = new URLSearchParams(reference);
17
+ const role = params.get("role");
18
+ const organization = params.get("organization");
19
+ return {
20
+ ...role && { role },
21
+ ...organization && { organization }
22
+ };
23
+ };
24
+ //#endregion
25
+ export { grantReference, readGrantReference };
@@ -0,0 +1,13 @@
1
+ import "./connected-apps.js";
2
+ import "../organizations/store.js";
3
+ import { ClientMetadataResourceFetch } from "@better-auth/oauth-provider";
4
+ //#region src/better-auth/mcp/mcp-plugins.d.ts
5
+ export type McpAuthOptions = {
6
+ /** The path of the MCP endpoint on the app's origin, its OAuth resource; default `/api/mcp`. Changing it makes clients connect again. */
7
+ path?: string;
8
+ /** The connect page's path on the app's origin, where people sign in and allow a client; default `/-/connect`, for a UI at the root. */
9
+ connectPage?: string;
10
+ /** Fetches a client's metadata document (a URL client id); default the CIMD plugin's Node transport, which refuses non-public addresses. */
11
+ fetchClientMetadata?: ClientMetadataResourceFetch;
12
+ };
13
+ //#endregion
@@ -0,0 +1,107 @@
1
+ import { mcpConsentCheck } from "./consent-check.js";
2
+ import { mcpGrantRevocation } from "./revoke-grants.js";
3
+ import { pluginNames } from "../snake-case-names.js";
4
+ import { mcpConnectedApps } from "./connected-apps.js";
5
+ import { consentReference } from "./consent-reference.js";
6
+ import { mcpTokenClaims } from "./mcp-token-claims.js";
7
+ import { mcpRequestDefaults } from "./request-defaults.js";
8
+ import { mcpSetupGate } from "./setup-gate.js";
9
+ import { mcpSignedQueryGuard } from "./signed-query-guard.js";
10
+ import { connectRoute } from "@protobase/schema";
11
+ import { cimd } from "@better-auth/cimd";
12
+ import { fetchClientMetadataResource } from "@better-auth/cimd/node";
13
+ import { mcp } from "@better-auth/mcp";
14
+ //#region src/better-auth/mcp/mcp-plugins.ts
15
+ /** The path of the app's MCP endpoint, below its origin, unless `mcp.path` names another. */
16
+ const defaultMcpPath = "/api/mcp";
17
+ /** The OAuth resource of the MCP endpoint: the origin of `baseURL` with `path`. */
18
+ const mcpResource = (baseURL, path) => `${new URL(baseURL).origin}${path}`;
19
+ /**
20
+ * The MCP options with their defaults, for an app at `baseURL`. The path must be one below the origin, so the
21
+ * resource is never the issuer, in either of its forms, whose tokens the app's own API takes.
22
+ */
23
+ const resolveMcp = (baseURL, options) => {
24
+ const { path = defaultMcpPath, connectPage = connectRoute, fetchClientMetadata = fetchClientMetadataResource } = options === true ? {} : options;
25
+ if (!path.startsWith("/") || path.length < 2) throw new Error(`mcp.path "${path}" must be a path below the app's origin, such as ${defaultMcpPath}`);
26
+ if (!connectPage.startsWith("/")) throw new Error(`mcp.connectPage "${connectPage}" must be a path on the app's origin, such as ${connectRoute}`);
27
+ return {
28
+ path,
29
+ connectPage,
30
+ resource: mcpResource(baseURL, path),
31
+ fetchClientMetadata
32
+ };
33
+ };
34
+ /**
35
+ * The OAuth provider's endpoints MCP does not use, closed: managing clients (dynamic registration and client metadata
36
+ * documents are the only ways in), OpenID Connect's userinfo and logout, and the provider's own consent endpoints,
37
+ * whose `delete-consent` would leave the client's tokens working; the account page lists and disconnects clients.
38
+ */
39
+ const mcpUnusedPaths = [
40
+ "/oauth2/create-client",
41
+ "/oauth2/get-client",
42
+ "/oauth2/get-clients",
43
+ "/oauth2/update-client",
44
+ "/oauth2/delete-client",
45
+ "/oauth2/client/rotate-secret",
46
+ "/oauth2/client/rotate-keys",
47
+ "/oauth2/public-client-prelogin",
48
+ "/oauth2/userinfo",
49
+ "/oauth2/end-session",
50
+ "/oauth2/end-session/confirm",
51
+ "/oauth2/get-consent",
52
+ "/oauth2/get-consents",
53
+ "/oauth2/update-consent",
54
+ "/oauth2/delete-consent"
55
+ ];
56
+ /**
57
+ * The Better Auth plugins of an OAuth provider for MCP clients, after the `jwt` plugin, in this order: the guard on
58
+ * signed queries, the request defaults, the setup gate, the provider (`mcp()`), and client metadata documents
59
+ * (`cimd()`), the revocation when the account is secured (`mcpGrantRevocation`), and the person's list of connected
60
+ * apps, where they disconnect one (`mcpConnectedApps`). A client registers itself, the person signs in on the app's own pages and allows it on the connect
61
+ * page, and the client gets 15-minute access tokens for the MCP resource only, with refresh tokens, carrying the role
62
+ * and organization the person worked in when they allowed it.
63
+ */
64
+ const mcpAuthPlugins = ({ mcp: resolved, organizations, mail, defaultPolicy, definitions, context }) => [
65
+ mcpSignedQueryGuard(),
66
+ mcpRequestDefaults(resolved.resource),
67
+ mcpSetupGate({
68
+ mail,
69
+ defaultPolicy,
70
+ connectPage: resolved.connectPage
71
+ }),
72
+ mcp({
73
+ resource: resolved.resource,
74
+ loginPage: resolved.connectPage,
75
+ consentPage: resolved.connectPage,
76
+ scopes: ["mcp", "offline_access"],
77
+ clientRegistrationDefaultScopes: ["mcp", "offline_access"],
78
+ grantTypes: ["authorization_code", "refresh_token"],
79
+ allowDynamicClientRegistration: true,
80
+ allowUnauthenticatedClientRegistration: true,
81
+ clientRegistrationDefaultApplicationType: "infer",
82
+ postLogin: {
83
+ page: resolved.connectPage,
84
+ shouldRedirect: () => false,
85
+ consentReferenceId: async ({ user, session }) => consentReference(await context(), {
86
+ user,
87
+ session
88
+ })
89
+ },
90
+ customAccessTokenClaims: async ({ user, referenceId }) => mcpTokenClaims(await context(), {
91
+ organizations,
92
+ mail,
93
+ defaultPolicy
94
+ }, user, referenceId),
95
+ accessTokenExpiresIn: 900,
96
+ extensions: [mcpConsentCheck()],
97
+ schema: pluginNames.mcp
98
+ }),
99
+ cimd({
100
+ fetchClientMetadataResource: resolved.fetchClientMetadata,
101
+ metadataProfile: "mcp-2026-07-28"
102
+ }),
103
+ mcpGrantRevocation(),
104
+ mcpConnectedApps({ definitions })
105
+ ];
106
+ //#endregion
107
+ export { defaultMcpPath, mcpAuthPlugins, mcpResource, mcpUnusedPaths, resolveMcp };
@@ -0,0 +1,39 @@
1
+ import { missingSetup } from "../sign-in-setup.js";
2
+ import { heldRoles } from "../roles/session-role.js";
3
+ import { readGrantReference } from "./grant-reference.js";
4
+ import { mayEnter } from "../organizations/session-organization.js";
5
+ import { tokenClaims } from "../token-claims.js";
6
+ import { APIError } from "better-auth/api";
7
+ //#region src/better-auth/mcp/mcp-token-claims.ts
8
+ const refused = (description) => new APIError("BAD_REQUEST", {
9
+ error: "invalid_grant",
10
+ error_description: description
11
+ });
12
+ const isBanned = (user, now) => Boolean(user.banned) && !(user.banExpires && new Date(user.banExpires) < now);
13
+ /**
14
+ * The claims of an MCP access token, issued at the code exchange and at every refresh: the role and organization the
15
+ * grant was made with (its consent's `referenceId`), checked again each time. It refuses (`invalid_grant`) a grant
16
+ * without a user, for a banned user, an address that is not verified, setup the sign-in policy now requires, a grant
17
+ * made with no role, and a role or organization the user no longer holds; so a refresh stops once any of those holds.
18
+ */
19
+ const mcpTokenClaims = async (context, { organizations, mail, defaultPolicy, now = /* @__PURE__ */ new Date() }, user, referenceId) => {
20
+ if (!user) throw refused("This grant has no user.");
21
+ if (isBanned(user, now)) throw refused("This account is blocked.");
22
+ if (user.emailVerified !== true) throw refused("Confirm your address by signing in with an emailed code, then connect again.");
23
+ if ((await missingSetup(context, user, {
24
+ mail,
25
+ defaultPolicy
26
+ })).length > 0) throw refused("Set up what this app's sign-in policy requires, then try again.");
27
+ const { role, organization } = readGrantReference(referenceId);
28
+ if (!role) throw refused("You had no role in this app when you allowed this client: connect it again.");
29
+ const held = await heldRoles(context, user, { activeOrganizationId: organization ?? null });
30
+ const entered = !organizations || !organization || await mayEnter(context.adapter, user, organization);
31
+ if (!held.includes(role) || !entered) throw refused("Your role or organization changed since you allowed this client: connect it again.");
32
+ return tokenClaims(user, {
33
+ wanted: [role],
34
+ organizationId: organization ?? null,
35
+ ...organizations && { organizations: context.adapter }
36
+ });
37
+ };
38
+ //#endregion
39
+ export { mcpTokenClaims };
@@ -0,0 +1,57 @@
1
+ import { APIError, createAuthMiddleware } from "better-auth/api";
2
+ //#region src/better-auth/mcp/request-defaults.ts
3
+ const isParams = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4
+ /**
5
+ * The authorization parameters with `resource` set to the one this provider protects when none is sent. The provider
6
+ * issues a JWT access token, and so runs the grant's token-time checks (`mcpTokenClaims`, `mcpConsentCheck`), only
7
+ * for a token with an audience; without a resource it would issue an opaque token and a refresh token with no check.
8
+ */
9
+ const withResource = (params, resource) => params.resource === void 0 ? {
10
+ ...params,
11
+ resource
12
+ } : params;
13
+ /** `scope` with `offline_access` added, which refresh tokens need; the resource metadata leaves it out of its scopes. */
14
+ const withOfflineAccess = (scope) => {
15
+ const scopes = scope.split(" ").filter(Boolean);
16
+ return scopes.includes("offline_access") ? scope : [...scopes, "offline_access"].join(" ");
17
+ };
18
+ const withDefaults = (params, resource) => {
19
+ const defaulted = withResource(params, resource);
20
+ return typeof defaulted.scope === "string" ? {
21
+ ...defaulted,
22
+ scope: withOfflineAccess(defaulted.scope)
23
+ } : defaulted;
24
+ };
25
+ const refusedRegistrationFields = [
26
+ "backchannel_logout_uri",
27
+ "backchannel_logout_session_required",
28
+ "jwks_uri"
29
+ ];
30
+ /** The registration field this server refuses, when `body` has one. */
31
+ const refusedRegistrationField = (body) => isParams(body) ? refusedRegistrationFields.find((field) => body[field] !== void 0) : void 0;
32
+ /**
33
+ * Before hooks that fill in what MCP clients leave out: on `authorize`, the resource and `offline_access` (in the
34
+ * query, or the body of a form POST, wherever the parameters are); on `register`, refusing fields that would make the
35
+ * server call a URL the client chose (`400 invalid_client_metadata`).
36
+ */
37
+ const mcpRequestDefaults = (resource) => ({
38
+ id: "protobase-mcp-request-defaults",
39
+ hooks: { before: [{
40
+ matcher: (context) => context.path === "/oauth2/authorize",
41
+ handler: createAuthMiddleware(async (ctx) => {
42
+ if (isParams(ctx.body) && "client_id" in ctx.body) return { context: { body: withDefaults(ctx.body, resource) } };
43
+ return { context: { query: withDefaults(isParams(ctx.query) ? ctx.query : {}, resource) } };
44
+ })
45
+ }, {
46
+ matcher: (context) => context.path === "/oauth2/register",
47
+ handler: createAuthMiddleware(async (ctx) => {
48
+ const field = refusedRegistrationField(ctx.body);
49
+ if (field) throw new APIError("BAD_REQUEST", {
50
+ error: "invalid_client_metadata",
51
+ error_description: `This server does not take ${field}.`
52
+ });
53
+ })
54
+ }] }
55
+ });
56
+ //#endregion
57
+ export { mcpRequestDefaults, refusedRegistrationField, withOfflineAccess, withResource };
@@ -0,0 +1,75 @@
1
+ import { consentModel } from "./consent-check.js";
2
+ import { createAuthMiddleware } from "better-auth/api";
3
+ //#region src/better-auth/mcp/revoke-grants.ts
4
+ /**
5
+ * Disconnects the user's MCP clients, or only `clientId`: deletes their consents, then their access tokens and refresh
6
+ * tokens. Without the consent no code or refresh is exchanged any more (`mcpConsentCheck`), and authorize asks the
7
+ * person again; access tokens already issued work out their 15 minutes. Access tokens go before refresh tokens, as the
8
+ * provider's own revocation does, since an access token's row may name its refresh token.
9
+ */
10
+ const revokeMcpGrants = async (adapter, userId, clientId) => {
11
+ const where = [{
12
+ field: "userId",
13
+ value: userId
14
+ }, ...clientId === void 0 ? [] : [{
15
+ field: "clientId",
16
+ value: clientId
17
+ }]];
18
+ await adapter.deleteMany({
19
+ model: consentModel,
20
+ where
21
+ });
22
+ await adapter.deleteMany({
23
+ model: "oauthAccessToken",
24
+ where
25
+ });
26
+ await adapter.deleteMany({
27
+ model: "oauthRefreshToken",
28
+ where
29
+ });
30
+ };
31
+ const ownPaths = ["/revoke-sessions", "/revoke-other-sessions"];
32
+ const adminPaths = [
33
+ "/admin/revoke-user-sessions",
34
+ "/admin/ban-user",
35
+ "/admin/set-user-password"
36
+ ];
37
+ const bodyOf = (request) => typeof request.body === "object" && request.body !== null ? request.body : {};
38
+ const securesAccount = (data) => {
39
+ if (typeof data !== "object" || data === null) return false;
40
+ const changes = data;
41
+ return "banned" in changes && changes.banned !== false || "emailVerified" in changes && changes.emailVerified !== true;
42
+ };
43
+ /**
44
+ * The user whose MCP grants a finished request ends, or `undefined`: the person who signed out everywhere, signed out
45
+ * their other devices, or changed their password signing out the other devices; the user an admin ended the sessions
46
+ * of, banned, set the password of, or marked unverified. Nobody when the request failed.
47
+ */
48
+ const grantOwnerToRevoke = (request) => {
49
+ const { path, context } = request;
50
+ if (context.returned instanceof Error || path === void 0) return void 0;
51
+ const body = bodyOf(request);
52
+ if (ownPaths.includes(path) || path === "/change-password" && body.revokeOtherSessions === true) return context.session?.user.id;
53
+ if (adminPaths.includes(path) || path === "/admin/update-user" && securesAccount(body.data)) return body.userId === void 0 ? void 0 : String(body.userId);
54
+ };
55
+ /**
56
+ * Disconnects the user's MCP clients after a request that secures their account (`grantOwnerToRevoke`). A failure to
57
+ * delete is not caught: the request answers 500 after its own work, and can be run again.
58
+ */
59
+ const mcpGrantRevocation = () => ({
60
+ id: "protobase-mcp-grant-revocation",
61
+ hooks: { after: [{
62
+ matcher: (context) => context.path !== void 0 && [
63
+ ...ownPaths,
64
+ ...adminPaths,
65
+ "/change-password",
66
+ "/admin/update-user"
67
+ ].includes(context.path),
68
+ handler: createAuthMiddleware(async (ctx) => {
69
+ const userId = grantOwnerToRevoke(ctx);
70
+ if (userId) await revokeMcpGrants(ctx.context.adapter, userId);
71
+ })
72
+ }] }
73
+ });
74
+ //#endregion
75
+ export { grantOwnerToRevoke, mcpGrantRevocation, revokeMcpGrants };
@@ -0,0 +1,38 @@
1
+ import { addressNotVerified, setupRequired } from "../sign-in-enforcement.js";
2
+ import { missingSetup } from "../sign-in-setup.js";
3
+ import { isStaffSession } from "../staff-checks.js";
4
+ import { APIError, createAuthMiddleware, getSessionFromCtx } from "better-auth/api";
5
+ //#region src/better-auth/mcp/setup-gate.ts
6
+ const gatedPaths = [
7
+ "/oauth2/authorize",
8
+ "/oauth2/continue",
9
+ "/oauth2/consent"
10
+ ];
11
+ /**
12
+ * Holds back allowing an MCP client from someone who has not set up what the sign-in policy requires: `authorize`
13
+ * sends them to the connect page, which shows the setup page; `continue` and `consent` answer `403
14
+ * SIGN_IN_SETUP_REQUIRED`. Someone whose address is not verified cannot allow a client either (`403
15
+ * ADDRESS_NOT_VERIFIED`); `authorize` lets them reach the connect page, which says so. Staff sessions are refused by
16
+ * the staff plugin, before this.
17
+ */
18
+ const mcpSetupGate = ({ mail, defaultPolicy, connectPage }) => ({
19
+ id: "protobase-mcp-setup-gate",
20
+ hooks: { before: [{
21
+ matcher: (context) => context.path !== void 0 && gatedPaths.includes(context.path),
22
+ handler: createAuthMiddleware(async (ctx) => {
23
+ const session = await getSessionFromCtx(ctx);
24
+ if (!session || isStaffSession(session.session)) return;
25
+ const authorize = ctx.path === "/oauth2/authorize";
26
+ if ((await missingSetup(ctx.context, session.user, {
27
+ mail,
28
+ defaultPolicy
29
+ })).length > 0) {
30
+ if (authorize) throw ctx.redirect(connectPage);
31
+ throw new APIError("FORBIDDEN", setupRequired);
32
+ }
33
+ if (session.user.emailVerified !== true && !authorize) throw new APIError("FORBIDDEN", addressNotVerified);
34
+ })
35
+ }] }
36
+ });
37
+ //#endregion
38
+ export { mcpSetupGate };