@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.
@@ -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 `jwksUrl` instead of `auth`, a DPoP proof's single use holds in this process only.
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 && standing) await checkTokenStanding(await auth.$context, tokenClaims(payload, session.user.roles, organizations ? session.tenant : void 0), organizations);
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 own = sessionOf({ audience: options.audience ?? issuer }, true);
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
- }, false) });
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`), and the person's list of connected
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`), 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.
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
- /** The X-Meta-Version header: the models, and the roles the access depends on, so a role change moves it. */
102
- const version = async (roles) => (await sha256Hex(`${await base}|${[...roles].sort().join(",")}`)).slice(0, 32);
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
@@ -0,0 +1,4 @@
1
+ //#region package.json
2
+ var version = "0.40.8";
3
+ //#endregion
4
+ export { version };
@@ -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.6",
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.6",
40
- "@protobase/query": "0.40.6",
41
- "@protobase/schema": "0.40.6",
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",