@protobase/server 0.33.4 → 0.34.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 -363
- package/dist/better-auth/create-auth.js +39 -20
- 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/new-account-password.d.ts +6 -0
- package/dist/better-auth/new-account-password.js +6 -3
- 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/sign-up/sign-up-plugin.js +1 -1
- 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 +10 -5
- package/dist/index.js +4 -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,20 +3,23 @@ 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";
|
|
9
|
+
import { passwordLength } from "./new-account-password.js";
|
|
10
10
|
import { platformMailer } from "../mail/platform-mailer.js";
|
|
11
11
|
import { readOperatorSettings, resolveOperator } from "./operator-provider.js";
|
|
12
12
|
import { defaultInvitationUrl } from "./invitations/invitation-link-hook.js";
|
|
13
13
|
import { lastAdminGuardPlugin } from "./last-admin-guard.js";
|
|
14
|
-
import {
|
|
14
|
+
import { revokeMcpGrants } from "./mcp/revoke-grants.js";
|
|
15
|
+
import { coreNames, pluginNames } from "./snake-case-names.js";
|
|
16
|
+
import { lastOrganizationField, sessionOrganizationHooks } from "./organizations/session-organization.js";
|
|
17
|
+
import { tokenClaims } from "./token-claims.js";
|
|
18
|
+
import { mcpAuthPlugins, mcpUnusedPaths, resolveMcp } from "./mcp/mcp-plugins.js";
|
|
15
19
|
import { organizationPlugin } from "./organizations/organization-plugin.js";
|
|
16
20
|
import { protobaseOrganizationPlugin } from "./organizations/protobase-plugin.js";
|
|
17
21
|
import { resolveOrganizations } from "./organizations/options.js";
|
|
18
22
|
import { passkeyHosts, readPasskeyDomain, resolvePasskeyDomain } from "./passkey-domain.js";
|
|
19
|
-
import { coreNames, pluginNames } from "./snake-case-names.js";
|
|
20
23
|
import { passkeyPlugin } from "./passkey-plugin.js";
|
|
21
24
|
import { passwordResetOptions } from "./password-reset.js";
|
|
22
25
|
import { rolesPlugin } from "./roles/roles-plugin.js";
|
|
@@ -57,7 +60,7 @@ const organizationPaths = ["/organization/set-active", "/organization/accept-inv
|
|
|
57
60
|
* The options `createAuth` gives Better Auth, with the roles, default role, mailer, operator provider and platform
|
|
58
61
|
* sign-in provider already resolved.
|
|
59
62
|
*/
|
|
60
|
-
const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, definitions, defaultRole, appName = defaultAppName, mailer, signUpRole, operator, platformSignIn, saml = [], organizations, context = noContext, signInPolicy = defaultSignInPolicy }) => {
|
|
63
|
+
const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, definitions, defaultRole, appName = defaultAppName, mailer, signUpRole, operator, platformSignIn, saml = [], organizations, mcp, context = noContext, signInPolicy = defaultSignInPolicy }) => {
|
|
61
64
|
const secure = options.baseURL.startsWith("https://");
|
|
62
65
|
const signIn = platformSignInFor(platformSignIn, Object.keys(options.socialProviders ?? {}));
|
|
63
66
|
const hosts = passkeyHosts([options.baseURL, ...origins]);
|
|
@@ -88,14 +91,14 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
|
|
|
88
91
|
audience: options.baseURL,
|
|
89
92
|
definePayload: async ({ user, session }) => {
|
|
90
93
|
const { activeRole, activeOrganizationId } = session;
|
|
91
|
-
const
|
|
92
|
-
role: user.role,
|
|
93
|
-
...organizations && await organizationClaims((await context()).adapter, user, activeOrganizationId)
|
|
94
|
-
};
|
|
94
|
+
const wanted = [activeRole, user.lastRole];
|
|
95
95
|
return {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
96
|
+
...await tokenClaims(user, {
|
|
97
|
+
wanted,
|
|
98
|
+
organizationId: activeOrganizationId,
|
|
99
|
+
...organizations && { organizations: (await context()).adapter }
|
|
100
|
+
}),
|
|
101
|
+
sid: session.id
|
|
99
102
|
};
|
|
100
103
|
}
|
|
101
104
|
},
|
|
@@ -120,9 +123,10 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
|
|
|
120
123
|
emailAndPassword: {
|
|
121
124
|
enabled: true,
|
|
122
125
|
disableSignUp: true,
|
|
123
|
-
minPasswordLength:
|
|
126
|
+
minPasswordLength: passwordLength.min,
|
|
127
|
+
maxPasswordLength: passwordLength.max,
|
|
124
128
|
...passwordHashes.length > 0 && { password: { verify: verifyAcceptedHash(passwordHashes) } },
|
|
125
|
-
...mail && passwordResetOptions(mail)
|
|
129
|
+
...mail && passwordResetOptions(mail, mcp ? { onPasswordReset: async ({ user }) => revokeMcpGrants((await context()).adapter, user.id) } : {})
|
|
126
130
|
},
|
|
127
131
|
...options.socialProviders && { socialProviders: options.socialProviders },
|
|
128
132
|
user: Object.keys(userFields).length > 0 ? {
|
|
@@ -192,13 +196,22 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
|
|
|
192
196
|
invitationSeconds: organizations?.invitationExpiresIn ?? 604800,
|
|
193
197
|
...organizations && { organizations }
|
|
194
198
|
}),
|
|
195
|
-
...passwordHashes.length > 0 ? [rehashPasswordsPlugin(passwordHashes)] : []
|
|
199
|
+
...passwordHashes.length > 0 ? [rehashPasswordsPlugin(passwordHashes)] : [],
|
|
200
|
+
...mcp ? mcpAuthPlugins({
|
|
201
|
+
mcp,
|
|
202
|
+
organizations: Boolean(organizations),
|
|
203
|
+
mail: Boolean(mailer),
|
|
204
|
+
defaultPolicy: signInPolicy,
|
|
205
|
+
definitions,
|
|
206
|
+
context
|
|
207
|
+
}) : []
|
|
196
208
|
].map((plugin) => withUserColumns(plugin, users)),
|
|
197
209
|
disabledPaths: [
|
|
198
210
|
...unusedEmailCodePaths,
|
|
199
211
|
...adminImpersonationPaths,
|
|
200
212
|
...organizations ? organizationPaths : [],
|
|
201
|
-
...saml.length > 0 ? samlDisabledPaths : []
|
|
213
|
+
...saml.length > 0 ? samlDisabledPaths : [],
|
|
214
|
+
...mcp ? mcpUnusedPaths : []
|
|
202
215
|
],
|
|
203
216
|
databaseHooks: {
|
|
204
217
|
user: {
|
|
@@ -294,6 +307,7 @@ const createAuth = (options) => {
|
|
|
294
307
|
const origins = resolveOrigins(options.baseURL, options.origins ?? platformOrigins());
|
|
295
308
|
const passkeyDomain = resolvePasskeyDomain(options.passkeyDomain ?? platformPasskeyDomain(), [options.baseURL, ...origins]);
|
|
296
309
|
const organizations = options.organizations && resolveOrganizations(options.organizations, definitions, options.baseURL);
|
|
310
|
+
const mcp = options.mcp && resolveMcp(options.baseURL, options.mcp);
|
|
297
311
|
let instance;
|
|
298
312
|
const context = () => instance?.$context ?? Promise.reject(/* @__PURE__ */ new Error("The auth instance is not made yet"));
|
|
299
313
|
const auth = betterAuth(betterAuthOptions(options, {
|
|
@@ -309,10 +323,9 @@ const createAuth = (options) => {
|
|
|
309
323
|
platformSignIn,
|
|
310
324
|
saml,
|
|
311
325
|
signInPolicy,
|
|
312
|
-
...organizations && {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
}
|
|
326
|
+
...organizations && { organizations },
|
|
327
|
+
...mcp && { mcp },
|
|
328
|
+
...(organizations || mcp) && { context }
|
|
316
329
|
}));
|
|
317
330
|
instance = auth;
|
|
318
331
|
return Object.assign(auth, {
|
|
@@ -350,7 +363,13 @@ const createAuth = (options) => {
|
|
|
350
363
|
samlProviders: saml.map(({ id, name }) => ({
|
|
351
364
|
id,
|
|
352
365
|
name
|
|
353
|
-
}))
|
|
366
|
+
})),
|
|
367
|
+
/** The OAuth provider for MCP clients, when on: the endpoint's resource (its URL) and path, and the scope its tokens carry. */
|
|
368
|
+
mcp: mcp && {
|
|
369
|
+
resource: mcp.resource,
|
|
370
|
+
path: mcp.path,
|
|
371
|
+
scope: "mcp"
|
|
372
|
+
}
|
|
354
373
|
});
|
|
355
374
|
};
|
|
356
375
|
//#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 };
|