@better-auth/mcp 1.8.0-beta.7 → 1.8.0-beta.9

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,9 +1,8 @@
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";
6
- //#region src/handler.d.ts
7
6
  interface McpProtectedRequestHandlerOptions {
8
7
  /** Expected authorization-server issuer for the access token. */
9
8
  issuer: string;
@@ -38,6 +37,15 @@ interface McpProtectedRequestHandlerOptions {
38
37
  proofMaxAgeSeconds?: number;
39
38
  signingAlgorithms?: readonly string[];
40
39
  replayStore?: DpopReplayStore;
40
+ /**
41
+ * Require server-provided nonces in DPoP proofs (RFC 9449 §9). A proof
42
+ * without a current nonce gets a `401` `use_dpop_nonce` challenge with a
43
+ * fresh `DPoP-Nonce`, and successful DPoP responses carry the next one.
44
+ * Responses that carry a nonce are marked `Cache-Control: no-store`. Use
45
+ * the authorization server's secret to accept the nonces it issues, such
46
+ * as when the MCP server shares its origin.
47
+ */
48
+ nonce?: NonNullable<VerifyAccessTokenRequestOptions["dpop"]>["nonce"];
41
49
  };
42
50
  }
43
51
  /**
@@ -49,8 +57,6 @@ interface McpProtectedRequestHandlerOptions {
49
57
  * @external
50
58
  */
51
59
  export declare const createMcpProtectedRequestHandler: (options: McpProtectedRequestHandlerOptions, handler: (request: Request, accessTokenClaims: JWTPayload) => Awaitable<Response>) => (request: Request) => Promise<Response>;
52
- //#endregion
53
- //#region src/plugin.d.ts
54
60
  /**
55
61
  * Options for the {@link mcp} plugin: the full OAuth provider configuration plus
56
62
  * the MCP resource identifier.
@@ -125,8 +131,6 @@ interface McpOptions extends OAuthOptions<Scope[]> {
125
131
  * ```
126
132
  */
127
133
  export declare const mcp: (options: McpOptions) => ReturnType<typeof oauthProvider>;
128
- //#endregion
129
- //#region src/require-mcp-auth.d.ts
130
134
  interface RequireMcpAuthOptions {
131
135
  /**
132
136
  * The protected resource identifier the access token must be bound to.
@@ -167,6 +171,26 @@ interface RequireMcpAuthOptions {
167
171
  proofMaxAgeSeconds?: number;
168
172
  signingAlgorithms?: readonly string[];
169
173
  replayStore?: DpopReplayStore;
174
+ /**
175
+ * Require server-provided nonces in DPoP proofs (RFC 9449 §9). A proof
176
+ * without a current nonce gets a `401` `use_dpop_nonce` challenge with a
177
+ * fresh `DPoP-Nonce`, and successful DPoP responses carry the next one.
178
+ *
179
+ * Nonces are derived from the auth instance's secret, the same way the
180
+ * OAuth provider's `dpop.nonce` derives them, so the MCP server accepts
181
+ * the nonces the provider issues. Pass `secret` only to keep the MCP
182
+ * server's nonces separate, such as for an MCP server on its own origin.
183
+ */
184
+ nonce?: {
185
+ required: boolean;
186
+ /**
187
+ * Seconds a nonce is accepted after it is issued. Defaults to the OAuth
188
+ * provider's `dpop.nonce.expiresIn`, then to `300`.
189
+ */
190
+ expiresIn?: number;
191
+ /** Defaults to the auth instance's secret. */
192
+ secret?: string;
193
+ };
170
194
  };
171
195
  }
172
196
  /**
@@ -192,7 +216,7 @@ export declare const requireMcpAuth: <Auth extends {
192
216
  $context: Promise<{
193
217
  baseURL: string;
194
218
  internalAdapter: DpopReplayReservations;
219
+ secret?: string;
195
220
  }>;
196
221
  }>(auth: Auth, handler: (request: Request, accessTokenClaims: JWTPayload) => Awaitable<Response>, opts?: RequireMcpAuthOptions) => (req: Request) => Promise<Response>;
197
- //#endregion
198
222
  export type { McpOptions, McpProtectedRequestHandlerOptions, RequireMcpAuthOptions };
package/dist/index.mjs CHANGED
@@ -1,7 +1,8 @@
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";
4
- //#region src/handler.ts
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";
5
+ // src/handler.ts
5
6
  /**
6
7
  * Validates the canonical protected-resource identifier accepted by MCP.
7
8
  *
@@ -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;
@@ -69,18 +87,23 @@ const createMcpProtectedRequestHandler = (options, handler) => {
69
87
  try {
70
88
  accessTokenClaims = await verifyAccessTokenRequest(requestToResourceInput(request), accessTokenVerificationOptions);
71
89
  } catch (error) {
72
- return toChallengeResponse(error, resource, resolvedChallengeOptions);
90
+ return toChallengeResponse(error, resource, {
91
+ ...resolvedChallengeOptions,
92
+ request
93
+ });
73
94
  }
95
+ let response;
74
96
  try {
75
- return await handler(request, accessTokenClaims);
97
+ response = await handler(request, accessTokenClaims);
76
98
  } catch (error) {
77
99
  if (isInsufficientScopeError(error)) return toChallengeResponse(error, resource, resolvedChallengeOptions);
78
100
  throw error;
79
101
  }
102
+ const nonce = options.dpop?.nonce;
103
+ return nonce?.required && getDpopJktFromPayload(accessTokenClaims) ? withNextDpopNonce(response, nonce.secret) : response;
80
104
  };
81
105
  };
82
- //#endregion
83
- //#region src/plugin.ts
106
+ // src/plugin.ts
84
107
  const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource";
85
108
  const AUTHORIZATION_SERVER_ONLY_SCOPES = /* @__PURE__ */ new Set([
86
109
  "openid",
@@ -199,8 +222,11 @@ const mcp = (options) => {
199
222
  }
200
223
  };
201
224
  };
202
- //#endregion
203
- //#region src/require-mcp-auth.ts
225
+ // src/require-mcp-auth.ts
226
+ /** The OAuth provider's `dpop.nonce.expiresIn`, which `mcp()` passes through. */
227
+ function getProviderNonceExpiresIn(options) {
228
+ return (options.plugins?.find((plugin) => plugin.id === "oauth-provider"))?.options?.dpop?.nonce?.expiresIn;
229
+ }
204
230
  /**
205
231
  * Protects an MCP server route handler. Verifies the bearer access token
206
232
  * against the authorization server's JWKS (checking signature, issuer,
@@ -222,8 +248,11 @@ const mcp = (options) => {
222
248
  const requireMcpAuth = (auth, handler, opts) => {
223
249
  if (opts?.resource !== void 0) validateMcpResource(opts.resource);
224
250
  return async (req) => {
225
- const { baseURL, internalAdapter } = await auth.$context;
251
+ const { baseURL, internalAdapter, secret } = await auth.$context;
226
252
  if (!baseURL) throw new Error("requireMcpAuth requires a resolvable base URL. For dynamic base URLs use `createMcpProtectedRequestHandler` with explicit verification options.");
253
+ const nonce = opts?.dpop?.nonce;
254
+ const nonceSecret = nonce?.secret ?? secret;
255
+ if (nonce?.required && !nonceSecret) throw new Error("requireMcpAuth requires dpop.nonce.secret when the auth context has no secret.");
227
256
  const issuer = opts?.issuer ?? baseURL;
228
257
  const resource = opts?.resource ?? baseURL;
229
258
  const jwksUrl = opts?.jwksUrl ?? `${baseURL}/jwks`;
@@ -238,10 +267,14 @@ const requireMcpAuth = (auth, handler, opts) => {
238
267
  dpop: {
239
268
  proofMaxAgeSeconds: opts?.dpop?.proofMaxAgeSeconds,
240
269
  signingAlgorithms: opts?.dpop?.signingAlgorithms,
241
- replayStore: opts?.dpop?.replayStore ?? createDpopReplayStore(internalAdapter)
270
+ replayStore: opts?.dpop?.replayStore ?? createDpopReplayStore(internalAdapter),
271
+ nonce: nonce && nonceSecret ? {
272
+ required: nonce.required,
273
+ expiresIn: nonce.expiresIn ?? getProviderNonceExpiresIn(auth.options),
274
+ secret: nonceSecret
275
+ } : void 0
242
276
  }
243
277
  }, handler)(req);
244
278
  };
245
279
  };
246
- //#endregion
247
280
  export { createMcpProtectedRequestHandler, mcp, requireMcpAuth };
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.9",
4
4
  "description": "Model Context Protocol (MCP) plugin for Better Auth",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -46,21 +46,21 @@
46
46
  }
47
47
  },
48
48
  "dependencies": {
49
- "jose": "^6.1.3",
50
- "@better-auth/oauth-provider": "^1.8.0-beta.7"
49
+ "@better-auth/oauth-provider": "^1.8.0-beta.9",
50
+ "jose": "^6.1.3"
51
51
  },
52
52
  "devDependencies": {
53
+ "@better-auth/core": "1.8.0-beta.9",
53
54
  "@modelcontextprotocol/client": "2.3.1",
54
55
  "@modelcontextprotocol/server": "2.3.1",
56
+ "better-auth": "1.8.0-beta.9",
55
57
  "better-call": "1.4.5",
56
- "tsdown": "0.23.1",
57
- "@better-auth/core": "1.8.0-beta.7",
58
- "better-auth": "1.8.0-beta.7"
58
+ "tsdown": "0.23.1"
59
59
  },
60
60
  "peerDependencies": {
61
- "better-call": "1.4.5",
62
- "@better-auth/core": "^1.8.0-beta.7",
63
- "better-auth": "^1.8.0-beta.7"
61
+ "@better-auth/core": "^1.8.0-beta.9",
62
+ "better-auth": "^1.8.0-beta.9",
63
+ "better-call": "1.4.5"
64
64
  },
65
65
  "scripts": {
66
66
  "build": "tsdown",