@protobase/server 0.40.6 → 0.40.8
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/better-auth/authenticator.d.ts +3 -1
- package/dist/better-auth/authenticator.js +10 -3
- package/dist/better-auth/create-auth.d.ts +18 -0
- package/dist/better-auth/mcp/grant-standing.js +26 -0
- package/dist/better-auth/mcp/mcp-plugins.js +4 -1
- package/dist/better-auth/mcp/refresh-reuse.js +66 -0
- package/dist/better-auth/mcp/revoke-grants.js +3 -3
- package/dist/better-auth/operator-provider.js +3 -3
- package/dist/meta.d.ts +2 -0
- package/dist/meta.js +7 -2
- package/dist/package.js +4 -0
- package/dist/protobase-version.js +6 -0
- package/package.json +4 -4
|
@@ -31,7 +31,9 @@ export type BetterAuthAuthenticator = Authenticator & {
|
|
|
31
31
|
* An authenticator for the OAuth access tokens issued for `audience` alone: the app's keys, a `typ` of `at+jwt`, and
|
|
32
32
|
* proof of the key for a DPoP-bound token, checked against `audience` as the URL it guards (the endpoint must answer
|
|
33
33
|
* at exactly that URL). The app's own tokens and trusted apps' are refused; the session keeps the token's `client`.
|
|
34
|
-
* With `
|
|
34
|
+
* With `auth`, every request also reads the store to check that the person still allows the client
|
|
35
|
+
* (`checkMcpGrant`), so a disconnected client or a secured account ends its tokens at once. With `jwksUrl` instead of
|
|
36
|
+
* `auth`, a token works until it expires, and a DPoP proof's single use holds in this process only.
|
|
35
37
|
*/
|
|
36
38
|
forAudience: (audience: string) => Authenticator;
|
|
37
39
|
};
|
|
@@ -2,6 +2,7 @@ import { jwtSessions } from "../auth/jwt.js";
|
|
|
2
2
|
import { noGlobalRole } from "./organizations/role-definitions.js";
|
|
3
3
|
import { cachedKeys } from "./jwks-cache.js";
|
|
4
4
|
import { withTrustedApps } from "./app-tokens.js";
|
|
5
|
+
import { checkMcpGrant } from "./mcp/grant-standing.js";
|
|
5
6
|
import { checkTokenStanding, tokenClaims } from "./token-standing.js";
|
|
6
7
|
import { createDpopReplayStore, createInMemoryDpopReplayStore } from "better-auth/oauth2";
|
|
7
8
|
//#region src/better-auth/authenticator.ts
|
|
@@ -43,7 +44,7 @@ const betterAuthAuthenticator = (options) => {
|
|
|
43
44
|
});
|
|
44
45
|
const verify = async (request) => {
|
|
45
46
|
const { payload, session } = await sessions(request);
|
|
46
|
-
if (auth
|
|
47
|
+
if (auth) await standing(await auth.$context, payload, session);
|
|
47
48
|
return session;
|
|
48
49
|
};
|
|
49
50
|
if (!organizations) return verify;
|
|
@@ -62,7 +63,13 @@ const betterAuthAuthenticator = (options) => {
|
|
|
62
63
|
};
|
|
63
64
|
let replayStore;
|
|
64
65
|
const dpopReplayStore = () => replayStore ??= options.auth ? options.auth.$context.then(({ internalAdapter }) => createDpopReplayStore(internalAdapter)) : Promise.resolve(createInMemoryDpopReplayStore());
|
|
65
|
-
const
|
|
66
|
+
const ownStanding = (context, payload, session) => checkTokenStanding(context, tokenClaims(payload, session.user.roles, organizations ? session.tenant : void 0), organizations);
|
|
67
|
+
const own = sessionOf({ audience: options.audience ?? issuer }, ownStanding);
|
|
68
|
+
const grantStanding = (context, payload, session) => checkMcpGrant(context.adapter, {
|
|
69
|
+
userId: payload.sub,
|
|
70
|
+
clientId: session.client?.id,
|
|
71
|
+
issuedAt: payload.iat
|
|
72
|
+
});
|
|
66
73
|
return Object.assign(withTrustedApps(own, issuer, options.apps ?? []), { forAudience: (audience) => sessionOf({
|
|
67
74
|
audience,
|
|
68
75
|
typ: "at+jwt",
|
|
@@ -71,7 +78,7 @@ const betterAuthAuthenticator = (options) => {
|
|
|
71
78
|
replayStore: dpopReplayStore
|
|
72
79
|
},
|
|
73
80
|
oauthClient: true
|
|
74
|
-
},
|
|
81
|
+
}, grantStanding) });
|
|
75
82
|
};
|
|
76
83
|
//#endregion
|
|
77
84
|
export { betterAuthAuthenticator, betterAuthTokens };
|
|
@@ -8446,6 +8446,24 @@ export declare const createAuth: (options: CreateAuthOptions) => import("better-
|
|
|
8446
8446
|
handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
|
|
8447
8447
|
}[];
|
|
8448
8448
|
};
|
|
8449
|
+
} | {
|
|
8450
|
+
id: "protobase-mcp-refresh-reuse";
|
|
8451
|
+
hooks: {
|
|
8452
|
+
before: {
|
|
8453
|
+
matcher: (context: {
|
|
8454
|
+
path?: string;
|
|
8455
|
+
body?: unknown;
|
|
8456
|
+
}) => boolean;
|
|
8457
|
+
handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
|
|
8458
|
+
}[];
|
|
8459
|
+
after: {
|
|
8460
|
+
matcher: (context: {
|
|
8461
|
+
path?: string;
|
|
8462
|
+
body?: unknown;
|
|
8463
|
+
}) => boolean;
|
|
8464
|
+
handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
|
|
8465
|
+
}[];
|
|
8466
|
+
};
|
|
8449
8467
|
} | {
|
|
8450
8468
|
id: "protobase-mcp-connected-apps";
|
|
8451
8469
|
endpoints: {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { unauthorized } from "../../problem.js";
|
|
2
|
+
import "./consent-check.js";
|
|
3
|
+
//#region src/better-auth/mcp/grant-standing.ts
|
|
4
|
+
/**
|
|
5
|
+
* Whether an MCP access token's grant still stands, read from the store on every request: the person still allows its
|
|
6
|
+
* client with a consent made no later than the token. Disconnecting the client, or securing the account, deletes the
|
|
7
|
+
* consents (`revokeMcpGrants`), so its access tokens stop at once instead of working out their 15 minutes, and stay
|
|
8
|
+
* stopped when the person allows the client again, whose new consent is newer than them. Not the browser session that
|
|
9
|
+
* allowed the client: the grant outlives it. A lookup that fails is not caught.
|
|
10
|
+
*/
|
|
11
|
+
const checkMcpGrant = async (adapter, { userId, clientId, issuedAt }) => {
|
|
12
|
+
if (clientId === void 0) throw unauthorized("The access token names no client");
|
|
13
|
+
if (issuedAt === void 0) throw unauthorized("The access token has no issue time");
|
|
14
|
+
if (!(await adapter.findMany({
|
|
15
|
+
model: "oauthConsent",
|
|
16
|
+
where: [{
|
|
17
|
+
field: "userId",
|
|
18
|
+
value: userId
|
|
19
|
+
}, {
|
|
20
|
+
field: "clientId",
|
|
21
|
+
value: clientId
|
|
22
|
+
}]
|
|
23
|
+
})).some((consent) => new Date(consent.createdAt).getTime() <= issuedAt * 1e3)) throw unauthorized("The client was disconnected: connect it again");
|
|
24
|
+
};
|
|
25
|
+
//#endregion
|
|
26
|
+
export { checkMcpGrant };
|
|
@@ -4,6 +4,7 @@ import { pluginNames } from "../snake-case-names.js";
|
|
|
4
4
|
import { mcpConnectedApps } from "./connected-apps.js";
|
|
5
5
|
import { consentReference } from "./consent-reference.js";
|
|
6
6
|
import { mcpTokenClaims } from "./mcp-token-claims.js";
|
|
7
|
+
import { mcpRefreshReuse } from "./refresh-reuse.js";
|
|
7
8
|
import { mcpRequestDefaults } from "./request-defaults.js";
|
|
8
9
|
import { mcpSetupGate } from "./setup-gate.js";
|
|
9
10
|
import { mcpSignedQueryGuard } from "./signed-query-guard.js";
|
|
@@ -56,7 +57,8 @@ const mcpUnusedPaths = [
|
|
|
56
57
|
/**
|
|
57
58
|
* The Better Auth plugins of an OAuth provider for MCP clients, after the `jwt` plugin, in this order: the guard on
|
|
58
59
|
* 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`)
|
|
60
|
+
* (`cimd()`), the revocation when the account is secured (`mcpGrantRevocation`) or a refresh token is reused
|
|
61
|
+
* (`mcpRefreshReuse`), and the person's list of connected
|
|
60
62
|
* 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
63
|
* page, and the client gets 15-minute access tokens for the MCP resource only, with refresh tokens, carrying the role
|
|
62
64
|
* and organization the person worked in when they allowed it.
|
|
@@ -101,6 +103,7 @@ const mcpAuthPlugins = ({ mcp: resolved, organizations, mail, defaultPolicy, def
|
|
|
101
103
|
metadataProfile: "mcp-2026-07-28"
|
|
102
104
|
}),
|
|
103
105
|
mcpGrantRevocation(),
|
|
106
|
+
mcpRefreshReuse(),
|
|
104
107
|
mcpConnectedApps({ definitions })
|
|
105
108
|
];
|
|
106
109
|
//#endregion
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { revokeMcpGrants } from "./revoke-grants.js";
|
|
2
|
+
import { base64url } from "jose";
|
|
3
|
+
import { createAuthMiddleware } from "better-auth/api";
|
|
4
|
+
//#region src/better-auth/mcp/refresh-reuse.ts
|
|
5
|
+
const refreshModel = "oauthRefreshToken";
|
|
6
|
+
/** The refresh token a request to the token or revocation endpoint presents, or `undefined`. */
|
|
7
|
+
const presentedRefreshToken = (path, body) => {
|
|
8
|
+
if (typeof body !== "object" || body === null) return void 0;
|
|
9
|
+
const params = body;
|
|
10
|
+
if (path === "/oauth2/token" && params.grant_type === "refresh_token" && typeof params.refresh_token === "string") return params.refresh_token;
|
|
11
|
+
if (path === "/oauth2/revoke" && params.token_type_hint !== "access_token" && typeof params.token === "string") return params.token;
|
|
12
|
+
};
|
|
13
|
+
const storedToken = async (token) => base64url.encode(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token))));
|
|
14
|
+
/** The stored row of a refresh token, before the provider handles it. */
|
|
15
|
+
const findRefreshToken = async (adapter, token) => await adapter.findOne({
|
|
16
|
+
model: refreshModel,
|
|
17
|
+
where: [{
|
|
18
|
+
field: "token",
|
|
19
|
+
value: await storedToken(token)
|
|
20
|
+
}]
|
|
21
|
+
}) ?? void 0;
|
|
22
|
+
/**
|
|
23
|
+
* Ends the grant after the provider took a refresh token for stolen: when a rotated one is presented again, it deletes
|
|
24
|
+
* every refresh token of that client and person (`invalidateRefreshFamily`) but leaves the consent, so the access
|
|
25
|
+
* tokens would still be taken. A row the request found before and that is gone after was deleted that way, so the
|
|
26
|
+
* grant is revoked (`revokeMcpGrants`), and the client's access tokens stop at once (`checkMcpGrant`).
|
|
27
|
+
*/
|
|
28
|
+
const endGrantIfFamilyInvalidated = async (adapter, before) => {
|
|
29
|
+
if (await adapter.findOne({
|
|
30
|
+
model: refreshModel,
|
|
31
|
+
where: [{
|
|
32
|
+
field: "id",
|
|
33
|
+
value: before.id
|
|
34
|
+
}]
|
|
35
|
+
})) return;
|
|
36
|
+
await revokeMcpGrants(adapter, before.userId, before.clientId);
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Hooks around the token and revocation endpoints that disconnect a client whose refresh token was reused
|
|
40
|
+
* (`endGrantIfFamilyInvalidated`). A failure to read or delete is not caught: the request answers 500.
|
|
41
|
+
*/
|
|
42
|
+
const mcpRefreshReuse = () => {
|
|
43
|
+
const found = /* @__PURE__ */ new WeakMap();
|
|
44
|
+
const matcher = (context) => presentedRefreshToken(context.path, context.body) !== void 0;
|
|
45
|
+
return {
|
|
46
|
+
id: "protobase-mcp-refresh-reuse",
|
|
47
|
+
hooks: {
|
|
48
|
+
before: [{
|
|
49
|
+
matcher,
|
|
50
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
51
|
+
const row = await findRefreshToken(ctx.context.adapter, presentedRefreshToken(ctx.path, ctx.body));
|
|
52
|
+
if (row) found.set(ctx.context, row);
|
|
53
|
+
})
|
|
54
|
+
}],
|
|
55
|
+
after: [{
|
|
56
|
+
matcher,
|
|
57
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
58
|
+
const row = found.get(ctx.context);
|
|
59
|
+
if (row) await endGrantIfFamilyInvalidated(ctx.context.adapter, row);
|
|
60
|
+
})
|
|
61
|
+
}]
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
//#endregion
|
|
66
|
+
export { endGrantIfFamilyInvalidated, findRefreshToken, mcpRefreshReuse, presentedRefreshToken };
|
|
@@ -3,9 +3,9 @@ import { createAuthMiddleware } from "better-auth/api";
|
|
|
3
3
|
//#region src/better-auth/mcp/revoke-grants.ts
|
|
4
4
|
/**
|
|
5
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`),
|
|
7
|
-
*
|
|
8
|
-
* provider's own revocation does, since an access token's row may name its refresh token.
|
|
6
|
+
* tokens. Without the consent no code or refresh is exchanged any more (`mcpConsentCheck`), access tokens already
|
|
7
|
+
* issued are refused at the next request (`checkMcpGrant`), and authorize asks the person again. Access tokens go
|
|
8
|
+
* before refresh tokens, as the provider's own revocation does, since an access token's row may name its refresh token.
|
|
9
9
|
*/
|
|
10
10
|
const revokeMcpGrants = async (adapter, userId, clientId) => {
|
|
11
11
|
const where = [{
|
|
@@ -47,10 +47,10 @@ const resolveOperator = (provider) => {
|
|
|
47
47
|
sessionMinutes
|
|
48
48
|
};
|
|
49
49
|
};
|
|
50
|
-
const base64url = (bytes) => btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
50
|
+
const base64url$1 = (bytes) => btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
51
51
|
/** A random value for `state`, `nonce` and the PKCE verifier. */
|
|
52
|
-
const randomValue = () => base64url(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32)));
|
|
53
|
-
const challengeOf = async (verifier) => base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))));
|
|
52
|
+
const randomValue = () => base64url$1(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32)));
|
|
53
|
+
const challengeOf = async (verifier) => base64url$1(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))));
|
|
54
54
|
/**
|
|
55
55
|
* An OpenID Connect client of the operator provider: the authorization code flow with PKCE, a client secret, and an
|
|
56
56
|
* ID token verified against the provider's keys, issuer, this client and the nonce. The provider's settings and keys
|
package/dist/meta.d.ts
CHANGED
|
@@ -19,6 +19,8 @@ export type ResourcePermissions = {
|
|
|
19
19
|
export type CallerMeta = {
|
|
20
20
|
/** The app's name, which ends the admin app's tab titles. */
|
|
21
21
|
appName: string;
|
|
22
|
+
/** The Protobase version the server runs: the admin app offers a reload when it changes under an open page. */
|
|
23
|
+
protobaseVersion: string;
|
|
22
24
|
/** The app's own icon, its favicon's path, when the project has one: the sidebar's logo draws it beside the name. */
|
|
23
25
|
appIcon?: string;
|
|
24
26
|
resources: ResourceModel[];
|
package/dist/meta.js
CHANGED
|
@@ -5,6 +5,7 @@ import { pruneView } from "./view-access.js";
|
|
|
5
5
|
import { activeRole } from "./active-role.js";
|
|
6
6
|
import { pluginsFor } from "./plugins/plugin-settings.js";
|
|
7
7
|
import { metaModel } from "./meta-model.js";
|
|
8
|
+
import { protobaseVersion } from "./protobase-version.js";
|
|
8
9
|
import { seesRuntime } from "./runtime/runtime-settings.js";
|
|
9
10
|
import { sourcesFor } from "./sources/source-settings.js";
|
|
10
11
|
import { prunePage } from "@protobase/layout";
|
|
@@ -63,6 +64,7 @@ const createMeta = (deps, appName, appIcon) => {
|
|
|
63
64
|
const sources = sourcesFor(deps.sources, session.user.roles);
|
|
64
65
|
const body = {
|
|
65
66
|
appName,
|
|
67
|
+
protobaseVersion,
|
|
66
68
|
...appIcon && { appIcon },
|
|
67
69
|
resources: visible.map(metaModel),
|
|
68
70
|
views,
|
|
@@ -98,8 +100,11 @@ const createMeta = (deps, appName, appIcon) => {
|
|
|
98
100
|
etag: `"${(await sha256Hex(stableJson(body))).slice(0, 32)}"`
|
|
99
101
|
};
|
|
100
102
|
};
|
|
101
|
-
/**
|
|
102
|
-
|
|
103
|
+
/**
|
|
104
|
+
* The X-Meta-Version header: the models, the Protobase version, so the admin app reads `/meta` again on the first
|
|
105
|
+
* answer after an upgrade, and the roles the access depends on, so a role change moves it.
|
|
106
|
+
*/
|
|
107
|
+
const version = async (roles) => (await sha256Hex(`${await base}|${protobaseVersion}|${[...roles].sort().join(",")}`)).slice(0, 32);
|
|
103
108
|
return {
|
|
104
109
|
forCaller,
|
|
105
110
|
version
|
package/dist/package.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { version } from "./package.js";
|
|
2
|
+
//#region src/protobase-version.ts
|
|
3
|
+
/** The Protobase version this server is, from @protobase/server's package.json: `/meta` names it to the admin app. */
|
|
4
|
+
const protobaseVersion = version;
|
|
5
|
+
//#endregion
|
|
6
|
+
export { protobaseVersion };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.40.
|
|
3
|
+
"version": "0.40.8",
|
|
4
4
|
"description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
"@better-auth/passkey": "^1.7.12",
|
|
37
37
|
"@better-auth/sso": "^1.7.12",
|
|
38
38
|
"@modelcontextprotocol/server": "^2.3.1",
|
|
39
|
-
"@protobase/layout": "0.40.
|
|
40
|
-
"@protobase/query": "0.40.
|
|
41
|
-
"@protobase/schema": "0.40.
|
|
39
|
+
"@protobase/layout": "0.40.8",
|
|
40
|
+
"@protobase/query": "0.40.8",
|
|
41
|
+
"@protobase/schema": "0.40.8",
|
|
42
42
|
"@react-email/render": "^2.1.0",
|
|
43
43
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
44
44
|
"@sentry/core": "^11.7.0",
|