@better-auth/mcp 1.8.0-beta.6 → 1.8.0-beta.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/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { OAuthOptions, Scope, oauthProvider } from "@better-auth/oauth-provider";
2
- import { DpopReplayReservations, DpopReplayStore } from "better-auth/oauth2";
2
+ import { DpopReplayReservations, DpopReplayStore, VerifyAccessTokenRequestOptions } from "better-auth/oauth2";
3
3
  import { Awaitable } from "@better-auth/core";
4
4
  import { JWTPayload, JWTVerifyOptions } from "jose";
5
5
  import { BetterAuthOptions } from "better-auth/types";
@@ -38,6 +38,15 @@ interface McpProtectedRequestHandlerOptions {
38
38
  proofMaxAgeSeconds?: number;
39
39
  signingAlgorithms?: readonly string[];
40
40
  replayStore?: DpopReplayStore;
41
+ /**
42
+ * Require server-provided nonces in DPoP proofs (RFC 9449 §9). A proof
43
+ * without a current nonce gets a `401` `use_dpop_nonce` challenge with a
44
+ * fresh `DPoP-Nonce`, and successful DPoP responses carry the next one.
45
+ * Responses that carry a nonce are marked `Cache-Control: no-store`. Use
46
+ * the authorization server's secret to accept the nonces it issues, such
47
+ * as when the MCP server shares its origin.
48
+ */
49
+ nonce?: NonNullable<VerifyAccessTokenRequestOptions["dpop"]>["nonce"];
41
50
  };
42
51
  }
43
52
  /**
@@ -167,6 +176,26 @@ interface RequireMcpAuthOptions {
167
176
  proofMaxAgeSeconds?: number;
168
177
  signingAlgorithms?: readonly string[];
169
178
  replayStore?: DpopReplayStore;
179
+ /**
180
+ * Require server-provided nonces in DPoP proofs (RFC 9449 §9). A proof
181
+ * without a current nonce gets a `401` `use_dpop_nonce` challenge with a
182
+ * fresh `DPoP-Nonce`, and successful DPoP responses carry the next one.
183
+ *
184
+ * Nonces are derived from the auth instance's secret, the same way the
185
+ * OAuth provider's `dpop.nonce` derives them, so the MCP server accepts
186
+ * the nonces the provider issues. Pass `secret` only to keep the MCP
187
+ * server's nonces separate, such as for an MCP server on its own origin.
188
+ */
189
+ nonce?: {
190
+ required: boolean;
191
+ /**
192
+ * Seconds a nonce is accepted after it is issued. Defaults to the OAuth
193
+ * provider's `dpop.nonce.expiresIn`, then to `300`.
194
+ */
195
+ expiresIn?: number;
196
+ /** Defaults to the auth instance's secret. */
197
+ secret?: string;
198
+ };
170
199
  };
171
200
  }
172
201
  /**
@@ -192,6 +221,7 @@ export declare const requireMcpAuth: <Auth extends {
192
221
  $context: Promise<{
193
222
  baseURL: string;
194
223
  internalAdapter: DpopReplayReservations;
224
+ secret?: string;
195
225
  }>;
196
226
  }>(auth: Auth, handler: (request: Request, accessTokenClaims: JWTPayload) => Awaitable<Response>, opts?: RequireMcpAuthOptions) => (req: Request) => Promise<Response>;
197
227
  //#endregion
package/dist/index.mjs CHANGED
@@ -1,11 +1,8 @@
1
+ import { isLoopbackHost } from "@better-auth/core/utils/host";
1
2
  import { createResourceServerChallenge, getIssuer, metadataResponse, oauthProvider } from "@better-auth/oauth-provider";
2
- import { DPOP_SIGNING_ALGORITHMS, createDpopReplayStore, isInsufficientScopeError, requestToResourceInput, verifyAccessTokenRequest } from "better-auth/oauth2";
3
+ import { withExposedHeaders } from "@better-auth/oauth-provider/internal";
4
+ import { DPOP_NONCE_HEADER, DPOP_SIGNING_ALGORITHMS, createDpopNonce, createDpopReplayStore, getDpopJktFromPayload, isInsufficientScopeError, requestToResourceInput, verifyAccessTokenRequest } from "better-auth/oauth2";
3
5
  //#region src/handler.ts
4
- function isLoopbackHost(hostname) {
5
- const ipv4Octets = hostname.split(".");
6
- const isIpv4Loopback = ipv4Octets.length === 4 && ipv4Octets[0] === "127" && ipv4Octets.every((octet) => /^\d+$/.test(octet) && Number(octet) <= 255);
7
- return hostname === "localhost" || hostname === "[::1]" || isIpv4Loopback;
8
- }
9
6
  /**
10
7
  * Validates the canonical protected-resource identifier accepted by MCP.
11
8
  *
@@ -25,6 +22,23 @@ function validateMcpResource(resource) {
25
22
  if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHost(url.hostname))) throw new TypeError("MCP resource URL must use HTTPS, except for localhost or loopback IP development URLs");
26
23
  return resource;
27
24
  }
25
+ /**
26
+ * Returns `response` with the next DPoP nonce (RFC 9449 §8.2). The response
27
+ * gets `Cache-Control: no-store`, overriding the handler's policy, because a
28
+ * cached copy would hand clients the nonce after it expires. The headers are copied
29
+ * because a handler's `Response` may have immutable headers.
30
+ */
31
+ async function withNextDpopNonce(response, secret) {
32
+ const headers = new Headers(response.headers);
33
+ headers.set("Cache-Control", "no-store");
34
+ headers.set(DPOP_NONCE_HEADER, await createDpopNonce({ secret }));
35
+ headers.set("Access-Control-Expose-Headers", withExposedHeaders(headers.get("access-control-expose-headers"), DPOP_NONCE_HEADER));
36
+ return new Response(response.body, {
37
+ status: response.status,
38
+ statusText: response.statusText,
39
+ headers
40
+ });
41
+ }
28
42
  function toChallengeResponse(error, resource, opts) {
29
43
  const challenge = createResourceServerChallenge(error, resource, opts);
30
44
  if (!challenge) throw error;
@@ -75,12 +89,15 @@ const createMcpProtectedRequestHandler = (options, handler) => {
75
89
  } catch (error) {
76
90
  return toChallengeResponse(error, resource, resolvedChallengeOptions);
77
91
  }
92
+ let response;
78
93
  try {
79
- return await handler(request, accessTokenClaims);
94
+ response = await handler(request, accessTokenClaims);
80
95
  } catch (error) {
81
96
  if (isInsufficientScopeError(error)) return toChallengeResponse(error, resource, resolvedChallengeOptions);
82
97
  throw error;
83
98
  }
99
+ const nonce = options.dpop?.nonce;
100
+ return nonce?.required && getDpopJktFromPayload(accessTokenClaims) ? withNextDpopNonce(response, nonce.secret) : response;
84
101
  };
85
102
  };
86
103
  //#endregion
@@ -205,6 +222,10 @@ const mcp = (options) => {
205
222
  };
206
223
  //#endregion
207
224
  //#region src/require-mcp-auth.ts
225
+ /** The OAuth provider's `dpop.nonce.expiresIn`, which `mcp()` passes through. */
226
+ function getProviderNonceExpiresIn(options) {
227
+ return (options.plugins?.find((plugin) => plugin.id === "oauth-provider"))?.options?.dpop?.nonce?.expiresIn;
228
+ }
208
229
  /**
209
230
  * Protects an MCP server route handler. Verifies the bearer access token
210
231
  * against the authorization server's JWKS (checking signature, issuer,
@@ -226,8 +247,11 @@ const mcp = (options) => {
226
247
  const requireMcpAuth = (auth, handler, opts) => {
227
248
  if (opts?.resource !== void 0) validateMcpResource(opts.resource);
228
249
  return async (req) => {
229
- const { baseURL, internalAdapter } = await auth.$context;
250
+ const { baseURL, internalAdapter, secret } = await auth.$context;
230
251
  if (!baseURL) throw new Error("requireMcpAuth requires a resolvable base URL. For dynamic base URLs use `createMcpProtectedRequestHandler` with explicit verification options.");
252
+ const nonce = opts?.dpop?.nonce;
253
+ const nonceSecret = nonce?.secret ?? secret;
254
+ if (nonce?.required && !nonceSecret) throw new Error("requireMcpAuth requires dpop.nonce.secret when the auth context has no secret.");
231
255
  const issuer = opts?.issuer ?? baseURL;
232
256
  const resource = opts?.resource ?? baseURL;
233
257
  const jwksUrl = opts?.jwksUrl ?? `${baseURL}/jwks`;
@@ -242,7 +266,12 @@ const requireMcpAuth = (auth, handler, opts) => {
242
266
  dpop: {
243
267
  proofMaxAgeSeconds: opts?.dpop?.proofMaxAgeSeconds,
244
268
  signingAlgorithms: opts?.dpop?.signingAlgorithms,
245
- replayStore: opts?.dpop?.replayStore ?? createDpopReplayStore(internalAdapter)
269
+ replayStore: opts?.dpop?.replayStore ?? createDpopReplayStore(internalAdapter),
270
+ nonce: nonce && nonceSecret ? {
271
+ required: nonce.required,
272
+ expiresIn: nonce.expiresIn ?? getProviderNonceExpiresIn(auth.options),
273
+ secret: nonceSecret
274
+ } : void 0
246
275
  }
247
276
  }, handler)(req);
248
277
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better-auth/mcp",
3
- "version": "1.8.0-beta.6",
3
+ "version": "1.8.0-beta.8",
4
4
  "description": "Model Context Protocol (MCP) plugin for Better Auth",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -47,20 +47,20 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "jose": "^6.1.3",
50
- "@better-auth/oauth-provider": "^1.8.0-beta.6"
50
+ "@better-auth/oauth-provider": "^1.8.0-beta.8"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@modelcontextprotocol/client": "2.3.1",
54
54
  "@modelcontextprotocol/server": "2.3.1",
55
55
  "better-call": "1.4.5",
56
56
  "tsdown": "0.23.1",
57
- "@better-auth/core": "1.8.0-beta.6",
58
- "better-auth": "1.8.0-beta.6"
57
+ "@better-auth/core": "1.8.0-beta.8",
58
+ "better-auth": "1.8.0-beta.8"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "better-call": "1.4.5",
62
- "@better-auth/core": "^1.8.0-beta.6",
63
- "better-auth": "^1.8.0-beta.6"
62
+ "@better-auth/core": "^1.8.0-beta.8",
63
+ "better-auth": "^1.8.0-beta.8"
64
64
  },
65
65
  "scripts": {
66
66
  "build": "tsdown",