@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 +31 -1
- package/dist/index.mjs +37 -4
- package/package.json +6 -6
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 {
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
58
|
-
"better-auth": "1.8.0-beta.
|
|
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.
|
|
63
|
-
"better-auth": "^1.8.0-beta.
|
|
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",
|