@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.
- package/dist/assistant/query-tools.js +1 -1
- package/dist/auth/jwt.d.ts +15 -1
- package/dist/auth/jwt.js +41 -5
- package/dist/better-auth/authenticator.d.ts +16 -1
- package/dist/better-auth/authenticator.js +41 -24
- package/dist/better-auth/create-auth.d.ts +3063 -364
- package/dist/better-auth/create-auth.js +36 -19
- package/dist/better-auth/mcp/connected-apps.d.ts +13 -0
- package/dist/better-auth/mcp/connected-apps.js +86 -0
- package/dist/better-auth/mcp/consent-check.js +38 -0
- package/dist/better-auth/mcp/consent-reference.js +16 -0
- package/dist/better-auth/mcp/discovery-routes.js +19 -0
- package/dist/better-auth/mcp/grant-reference.js +25 -0
- package/dist/better-auth/mcp/mcp-plugins.d.ts +13 -0
- package/dist/better-auth/mcp/mcp-plugins.js +107 -0
- package/dist/better-auth/mcp/mcp-token-claims.js +39 -0
- package/dist/better-auth/mcp/request-defaults.js +57 -0
- package/dist/better-auth/mcp/revoke-grants.js +75 -0
- package/dist/better-auth/mcp/setup-gate.js +38 -0
- package/dist/better-auth/mcp/signed-query-guard.js +29 -0
- package/dist/better-auth/origins.d.ts +7 -0
- package/dist/better-auth/origins.js +8 -1
- package/dist/better-auth/password-reset.js +4 -3
- package/dist/better-auth/policy-plugin.js +1 -24
- package/dist/better-auth/sign-in-enforcement.js +6 -1
- package/dist/better-auth/sign-in-setup.js +38 -0
- package/dist/better-auth/snake-case-names.js +102 -1
- package/dist/better-auth/staff-checks.js +9 -2
- package/dist/better-auth/staff-plugin.js +9 -2
- package/dist/better-auth/status-route.js +2 -1
- package/dist/better-auth/token-claims.js +19 -0
- package/dist/better-auth/users.d.ts +4 -1
- package/dist/better-auth/users.js +6 -1
- package/dist/check-roles.js +1 -1
- package/dist/create-admin.js +8 -0
- package/dist/deps.d.ts +1 -0
- package/dist/encryption/apply.d.ts +40 -0
- package/dist/encryption/apply.js +99 -0
- package/dist/encryption/binding.d.ts +2 -0
- package/dist/encryption/binding.js +53 -0
- package/dist/encryption/capture-off.js +12 -0
- package/dist/encryption/cascading-keys.d.ts +9 -0
- package/dist/encryption/cascading-keys.js +21 -0
- package/dist/encryption/envelope-open.d.ts +5 -0
- package/dist/encryption/envelope-open.js +57 -0
- package/dist/encryption/envelope-seal.js +38 -0
- package/dist/encryption/keyring.js +60 -0
- package/dist/encryption/mask-stored.js +16 -0
- package/dist/encryption/rebind-check.js +28 -0
- package/dist/encryption/remnants.js +53 -0
- package/dist/encryption/runtime.d.ts +1 -0
- package/dist/encryption/runtime.js +18 -0
- package/dist/encryption/scan.d.ts +4 -0
- package/dist/encryption/scan.js +61 -0
- package/dist/encryption/seal-values.js +30 -0
- package/dist/encryption/shared-columns.js +19 -0
- package/dist/encryption/status.d.ts +39 -0
- package/dist/encryption/status.js +92 -0
- package/dist/encryption/stored-value.js +16 -0
- package/dist/encryption/tools.d.ts +26 -0
- package/dist/encryption/tools.js +34 -0
- package/dist/forms/check-forms.js +1 -1
- package/dist/gotrue/user-routes.js +6 -0
- package/dist/index.d.ts +9 -5
- package/dist/index.js +3 -2
- package/dist/records.js +3 -3
- package/dist/request-access.js +9 -5
- package/dist/rest/encrypted-columns.js +37 -0
- package/dist/rest/table-routes.js +25 -13
- package/dist/reveal-service.js +17 -3
- package/dist/types.d.ts +5 -0
- package/dist/validate-body.js +4 -2
- package/dist/webhooks/subscriber-view.js +14 -1
- package/dist/write-pipeline.js +14 -2
- package/dist/write-shared.js +7 -3
- package/dist/write-update.js +12 -2
- 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 {
|
|
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
|
|
92
|
-
role: user.role,
|
|
93
|
-
...organizations && await organizationClaims((await context()).adapter, user, activeOrganizationId)
|
|
94
|
-
};
|
|
93
|
+
const wanted = [activeRole, user.lastRole];
|
|
95
94
|
return {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
314
|
-
|
|
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 };
|