@better-auth/mcp 1.8.0-beta.7 → 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,6 +1,7 @@
1
1
  import { isLoopbackHost } from "@better-auth/core/utils/host";
2
2
  import { createResourceServerChallenge, getIssuer, metadataResponse, oauthProvider } from "@better-auth/oauth-provider";
3
- 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";
4
5
  //#region src/handler.ts
5
6
  /**
6
7
  * Validates the canonical protected-resource identifier accepted by MCP.
@@ -21,6 +22,23 @@ function validateMcpResource(resource) {
21
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");
22
23
  return resource;
23
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
+ }
24
42
  function toChallengeResponse(error, resource, opts) {
25
43
  const challenge = createResourceServerChallenge(error, resource, opts);
26
44
  if (!challenge) throw error;
@@ -71,12 +89,15 @@ const createMcpProtectedRequestHandler = (options, handler) => {
71
89
  } catch (error) {
72
90
  return toChallengeResponse(error, resource, resolvedChallengeOptions);
73
91
  }
92
+ let response;
74
93
  try {
75
- return await handler(request, accessTokenClaims);
94
+ response = await handler(request, accessTokenClaims);
76
95
  } catch (error) {
77
96
  if (isInsufficientScopeError(error)) return toChallengeResponse(error, resource, resolvedChallengeOptions);
78
97
  throw error;
79
98
  }
99
+ const nonce = options.dpop?.nonce;
100
+ return nonce?.required && getDpopJktFromPayload(accessTokenClaims) ? withNextDpopNonce(response, nonce.secret) : response;
80
101
  };
81
102
  };
82
103
  //#endregion
@@ -201,6 +222,10 @@ const mcp = (options) => {
201
222
  };
202
223
  //#endregion
203
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
+ }
204
229
  /**
205
230
  * Protects an MCP server route handler. Verifies the bearer access token
206
231
  * against the authorization server's JWKS (checking signature, issuer,
@@ -222,8 +247,11 @@ const mcp = (options) => {
222
247
  const requireMcpAuth = (auth, handler, opts) => {
223
248
  if (opts?.resource !== void 0) validateMcpResource(opts.resource);
224
249
  return async (req) => {
225
- const { baseURL, internalAdapter } = await auth.$context;
250
+ const { baseURL, internalAdapter, secret } = await auth.$context;
226
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.");
227
255
  const issuer = opts?.issuer ?? baseURL;
228
256
  const resource = opts?.resource ?? baseURL;
229
257
  const jwksUrl = opts?.jwksUrl ?? `${baseURL}/jwks`;
@@ -238,7 +266,12 @@ const requireMcpAuth = (auth, handler, opts) => {
238
266
  dpop: {
239
267
  proofMaxAgeSeconds: opts?.dpop?.proofMaxAgeSeconds,
240
268
  signingAlgorithms: opts?.dpop?.signingAlgorithms,
241
- 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
242
275
  }
243
276
  }, handler)(req);
244
277
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better-auth/mcp",
3
- "version": "1.8.0-beta.7",
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.7"
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.7",
58
- "better-auth": "1.8.0-beta.7"
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.7",
63
- "better-auth": "^1.8.0-beta.7"
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",