@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 +31 -7
- package/dist/index.mjs +44 -11
- package/package.json +9 -9
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 {
|
|
4
|
-
|
|
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,
|
|
90
|
+
return toChallengeResponse(error, resource, {
|
|
91
|
+
...resolvedChallengeOptions,
|
|
92
|
+
request
|
|
93
|
+
});
|
|
73
94
|
}
|
|
95
|
+
let response;
|
|
74
96
|
try {
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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.
|
|
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
|
-
"
|
|
50
|
-
"
|
|
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-
|
|
62
|
-
"
|
|
63
|
-
"better-
|
|
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",
|