@better-auth/mcp 1.7.0-rc.2 → 1.7.0-rc.4
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/README.md +30 -6
- package/dist/index.d.mts +91 -31
- package/dist/index.mjs +119 -87
- package/package.json +8 -26
- package/dist/client/adapters.d.mts +0 -55
- package/dist/client/adapters.mjs +0 -130
- package/dist/client/index.d.mts +0 -53
- package/dist/client/index.mjs +0 -267
package/README.md
CHANGED
|
@@ -2,19 +2,43 @@
|
|
|
2
2
|
|
|
3
3
|
Model Context Protocol (MCP) plugin for [Better Auth](https://www.better-auth.com).
|
|
4
4
|
|
|
5
|
-
`mcp()` turns your Better Auth app into an OAuth 2.1 authorization server
|
|
6
|
-
clients, built on
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
`mcp()` turns your Better Auth app into an OAuth 2.1 authorization server and
|
|
6
|
+
protected resource for MCP clients, built on
|
|
7
|
+
[`@better-auth/oauth-provider`](https://www.better-auth.com/docs/plugins/oauth-provider).
|
|
8
|
+
It serves RFC 9728 protected resource metadata and binds issued tokens to the
|
|
9
|
+
configured resource. Compose it with `cimd()` for the MCP 2026-07-28 Client ID
|
|
10
|
+
Metadata Document flow, which pins CIMD draft-00 through an explicit metadata
|
|
11
|
+
profile. Dynamic Client Registration is disabled unless explicitly enabled.
|
|
12
|
+
|
|
13
|
+
`@better-auth/mcp` owns authorization, not MCP protocol transport. Serve MCP
|
|
14
|
+
2026-07-28 requests with version 2 of the official
|
|
15
|
+
[`@modelcontextprotocol/server`](https://www.npmjs.com/package/@modelcontextprotocol/server)
|
|
16
|
+
package, configure `createMcpHandler` with `legacy: "reject"`, mount it behind
|
|
17
|
+
`requireMcpAuth`, and expose only the HTTP `POST` route. The modern protocol
|
|
18
|
+
handles each request independently and does not need a Redis-backed MCP
|
|
19
|
+
session store. Multi-instance `subscriptions/listen` deployments can supply a
|
|
20
|
+
shared SDK event bus without introducing protocol-level sessions.
|
|
10
21
|
|
|
11
22
|
```ts
|
|
12
23
|
import { betterAuth } from "better-auth";
|
|
13
24
|
import { jwt } from "better-auth/plugins";
|
|
25
|
+
import { cimd } from "@better-auth/cimd";
|
|
26
|
+
import { fetchClientMetadataResource } from "@better-auth/cimd/node";
|
|
14
27
|
import { mcp } from "@better-auth/mcp";
|
|
15
28
|
|
|
16
29
|
export const auth = betterAuth({
|
|
17
|
-
plugins: [
|
|
30
|
+
plugins: [
|
|
31
|
+
jwt(),
|
|
32
|
+
mcp({
|
|
33
|
+
loginPage: "/login",
|
|
34
|
+
consentPage: "/consent",
|
|
35
|
+
resource: "https://api.example.com/mcp",
|
|
36
|
+
}),
|
|
37
|
+
cimd({
|
|
38
|
+
fetchClientMetadataResource,
|
|
39
|
+
metadataProfile: "mcp-2026-07-28",
|
|
40
|
+
}),
|
|
41
|
+
],
|
|
18
42
|
});
|
|
19
43
|
```
|
|
20
44
|
|
package/dist/index.d.mts
CHANGED
|
@@ -1,21 +1,54 @@
|
|
|
1
1
|
import { OAuthOptions, Scope, oauthProvider } from "@better-auth/oauth-provider";
|
|
2
|
-
import { DpopReplayReservations, DpopReplayStore
|
|
3
|
-
import { JWTPayload } from "jose";
|
|
2
|
+
import { DpopReplayReservations, DpopReplayStore } from "better-auth/oauth2";
|
|
4
3
|
import { Awaitable } from "@better-auth/core";
|
|
4
|
+
import { JWTPayload, JWTVerifyOptions } from "jose";
|
|
5
5
|
import { BetterAuthOptions } from "better-auth/types";
|
|
6
6
|
//#region src/handler.d.ts
|
|
7
|
+
interface McpProtectedRequestHandlerOptions {
|
|
8
|
+
/** Expected authorization-server issuer for the access token. */
|
|
9
|
+
issuer: string;
|
|
10
|
+
/** Canonical MCP protected-resource URL expected in the token audience. */
|
|
11
|
+
audience: string;
|
|
12
|
+
/**
|
|
13
|
+
* Additional JOSE verification constraints. `issuer` and `audience` remain
|
|
14
|
+
* authoritative from the top-level fields.
|
|
15
|
+
*/
|
|
16
|
+
jwtVerifyOptions?: Omit<JWTVerifyOptions, "issuer" | "audience">;
|
|
17
|
+
/** URL of the authorization server's JSON Web Key Set. */
|
|
18
|
+
jwksUrl?: string;
|
|
19
|
+
/** Remote introspection settings for opaque or remotely checked tokens. */
|
|
20
|
+
remoteVerify?: {
|
|
21
|
+
introspectUrl: string;
|
|
22
|
+
clientId: string;
|
|
23
|
+
clientSecret: string;
|
|
24
|
+
force?: boolean;
|
|
25
|
+
allowMissingAudience?: boolean;
|
|
26
|
+
};
|
|
27
|
+
/** Scopes every accepted access token must satisfy. */
|
|
28
|
+
requiredScopes?: readonly string[];
|
|
29
|
+
/**
|
|
30
|
+
* Scopes to advertise in unauthenticated `WWW-Authenticate` challenges.
|
|
31
|
+
* Defaults to `requiredScopes`.
|
|
32
|
+
*/
|
|
33
|
+
challengeScopes?: readonly string[];
|
|
34
|
+
/** Custom required-scope matcher. Defaults to exact membership. */
|
|
35
|
+
isScopeSatisfied?: (requiredScope: string, grantedScopes: ReadonlySet<string>) => boolean;
|
|
36
|
+
/** DPoP proof validation and replay-protection settings. */
|
|
37
|
+
dpop?: {
|
|
38
|
+
proofMaxAgeSeconds?: number;
|
|
39
|
+
signingAlgorithms?: readonly string[];
|
|
40
|
+
replayStore?: DpopReplayStore;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
7
43
|
/**
|
|
8
44
|
* A request middleware handler that verifies an MCP access token and responds
|
|
9
45
|
* with an RFC 9728 `WWW-Authenticate` header for unauthenticated requests.
|
|
46
|
+
* When `options.requiredScopes` is set, tokens missing a required scope receive
|
|
47
|
+
* a 403 with an RFC 6750 `insufficient_scope` challenge naming them instead.
|
|
10
48
|
*
|
|
11
49
|
* @external
|
|
12
50
|
*/
|
|
13
|
-
declare const
|
|
14
|
-
/** Verifier options. `audience` must match the protected resource identifier. */
|
|
15
|
-
verifyOptions: Parameters<typeof verifyAccessTokenRequest>[1], handler: (req: Request, jwt: JWTPayload) => Awaitable<Response>, opts?: {
|
|
16
|
-
/** Maps non-url (ie urn, client) resources to resource_metadata */
|
|
17
|
-
resourceMetadataMappings: Record<string, string>;
|
|
18
|
-
}) => (req: Request) => Promise<Response>;
|
|
51
|
+
declare const createMcpProtectedRequestHandler: (options: McpProtectedRequestHandlerOptions, handler: (request: Request, accessTokenClaims: JWTPayload) => Awaitable<Response>) => (request: Request) => Promise<Response>;
|
|
19
52
|
//#endregion
|
|
20
53
|
//#region src/plugin.d.ts
|
|
21
54
|
/**
|
|
@@ -28,17 +61,22 @@ interface McpOptions extends OAuthOptions<Scope[]> {
|
|
|
28
61
|
* token response for the same effective scopes, requested resources, and
|
|
29
62
|
* sender constraint.
|
|
30
63
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
64
|
+
* OAuth Provider remains strict by default. MCP overrides that default for
|
|
65
|
+
* every client configured through this plugin so a retried refresh can
|
|
66
|
+
* recover the response produced by an earlier request. Set to `0` to disable
|
|
67
|
+
* the overlap window and keep strict replay handling.
|
|
34
68
|
*
|
|
35
69
|
* @default 30
|
|
36
70
|
*/
|
|
37
71
|
refreshTokenReuseInterval?: OAuthOptions<Scope[]>["refreshTokenReuseInterval"];
|
|
38
72
|
/**
|
|
39
|
-
* The protected resource identifier (RFC 8707 / RFC 9728)
|
|
40
|
-
* are bound to
|
|
41
|
-
* added to `resources`, and
|
|
73
|
+
* The canonical protected resource identifier (RFC 8707 / RFC 9728) for this
|
|
74
|
+
* MCP server. Issued tokens are audience-bound to it, and it is published as
|
|
75
|
+
* `resource` in the protected resource metadata, added to `resources`, and
|
|
76
|
+
* used as the expected token audience.
|
|
77
|
+
*
|
|
78
|
+
* Must be an HTTPS URL with no query, fragment, or credentials. HTTP is
|
|
79
|
+
* accepted only on loopback hosts for local development.
|
|
42
80
|
*/
|
|
43
81
|
resource: string;
|
|
44
82
|
}
|
|
@@ -46,12 +84,19 @@ interface McpOptions extends OAuthOptions<Scope[]> {
|
|
|
46
84
|
* Model Context Protocol authorization server.
|
|
47
85
|
*
|
|
48
86
|
* `mcp()` is the OAuth 2.1 / OIDC provider ({@link oauthProvider}) configured for
|
|
49
|
-
* MCP: it
|
|
50
|
-
*
|
|
51
|
-
* metadata so MCP clients discover and use it through
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
87
|
+
* MCP: it binds issued tokens to the MCP `resource`, links that resource to
|
|
88
|
+
* newly registered clients, and, as the resource server, serves the RFC 9728
|
|
89
|
+
* protected resource metadata so MCP clients discover and use it through
|
|
90
|
+
* standard OAuth discovery. Client registration is opt-in: compose with
|
|
91
|
+
* `cimd()` for Client ID Metadata Documents, or explicitly enable the OAuth
|
|
92
|
+
* Provider dynamic-registration options.
|
|
93
|
+
* It also defaults `refreshTokenReuseInterval` to 30 seconds for all MCP
|
|
94
|
+
* clients, allowing a retried refresh to recover a rotated response. OAuth
|
|
95
|
+
* Provider remains strict by default; set the MCP option to `0` to disable the
|
|
96
|
+
* overlap window.
|
|
97
|
+
* MCP 2026-07-28 pins Client ID Metadata Documents draft-00. Configure the
|
|
98
|
+
* CIMD plugin with `metadataProfile: "mcp-2026-07-28"` and an application-owned
|
|
99
|
+
* metadata-resource transport.
|
|
55
100
|
* Because it is the OAuth provider, it cannot be combined with a separate
|
|
56
101
|
* {@link oauthProvider}.
|
|
57
102
|
*
|
|
@@ -59,7 +104,9 @@ interface McpOptions extends OAuthOptions<Scope[]> {
|
|
|
59
104
|
* ```ts
|
|
60
105
|
* import { betterAuth } from "better-auth";
|
|
61
106
|
* import { jwt } from "better-auth/plugins";
|
|
107
|
+
* import { cimd } from "@better-auth/cimd";
|
|
62
108
|
* import { mcp } from "@better-auth/mcp";
|
|
109
|
+
* import { fetchClientMetadataResource } from "./oauth-network";
|
|
63
110
|
*
|
|
64
111
|
* export const auth = betterAuth({
|
|
65
112
|
* plugins: [
|
|
@@ -69,6 +116,10 @@ interface McpOptions extends OAuthOptions<Scope[]> {
|
|
|
69
116
|
* consentPage: "/consent",
|
|
70
117
|
* resource: "https://api.example.com/mcp",
|
|
71
118
|
* }),
|
|
119
|
+
* cimd({
|
|
120
|
+
* fetchClientMetadataResource,
|
|
121
|
+
* metadataProfile: "mcp-2026-07-28",
|
|
122
|
+
* }),
|
|
72
123
|
* ],
|
|
73
124
|
* });
|
|
74
125
|
* ```
|
|
@@ -93,16 +144,20 @@ interface RequireMcpAuthOptions {
|
|
|
93
144
|
*/
|
|
94
145
|
jwksUrl?: string;
|
|
95
146
|
/**
|
|
96
|
-
*
|
|
97
|
-
*
|
|
147
|
+
* Scopes to advertise in the `WWW-Authenticate` challenge (RFC 6750),
|
|
148
|
+
* hinting which scopes the client should request. Defaults to the enforced
|
|
149
|
+
* `requiredScopes` when those are set.
|
|
98
150
|
*/
|
|
99
|
-
|
|
151
|
+
challengeScopes?: readonly string[];
|
|
100
152
|
/**
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
153
|
+
* Scopes the access token must include, enforced against the token's
|
|
154
|
+
* `scope` claim. A token missing any of them is rejected with a 403 and an
|
|
155
|
+
* RFC 6750 `insufficient_scope` challenge naming every missing scope, so MCP
|
|
156
|
+
* clients can step up their authorization in one round-trip.
|
|
104
157
|
*/
|
|
105
|
-
|
|
158
|
+
requiredScopes?: readonly string[];
|
|
159
|
+
/** Custom required-scope matcher. Defaults to exact membership. */
|
|
160
|
+
isScopeSatisfied?: (requiredScope: string, grantedScopes: ReadonlySet<string>) => boolean;
|
|
106
161
|
/**
|
|
107
162
|
* DPoP proof validation settings. By default the replay store is backed by
|
|
108
163
|
* the auth instance's database adapter, so anti-replay holds across multiple
|
|
@@ -120,10 +175,15 @@ interface RequireMcpAuthOptions {
|
|
|
120
175
|
* audience, and expiry) and forwards the verified JWT payload to the handler.
|
|
121
176
|
* Unauthenticated requests receive a JSON-RPC 401 with the RFC 9728
|
|
122
177
|
* `WWW-Authenticate` header so MCP clients can start the authorization flow.
|
|
178
|
+
* Tokens missing a required scope receive a 403 with an RFC 6750
|
|
179
|
+
* `insufficient_scope` challenge naming the missing scopes, so clients can step
|
|
180
|
+
* up their authorization; a handler can raise the same challenge for scopes only
|
|
181
|
+
* it knows about by throwing `createInsufficientScopeError`.
|
|
123
182
|
*
|
|
124
183
|
* For a resource server that runs separately from the authorization server, or
|
|
125
|
-
* a server using a dynamic `baseURL`, use
|
|
126
|
-
* verification options
|
|
184
|
+
* a server using a dynamic `baseURL`, use
|
|
185
|
+
* {@link createMcpProtectedRequestHandler} with explicit verification options
|
|
186
|
+
* instead.
|
|
127
187
|
*
|
|
128
188
|
* @external
|
|
129
189
|
*/
|
|
@@ -133,6 +193,6 @@ declare const requireMcpAuth: <Auth extends {
|
|
|
133
193
|
baseURL: string;
|
|
134
194
|
internalAdapter: DpopReplayReservations;
|
|
135
195
|
}>;
|
|
136
|
-
}>(auth: Auth, handler: (
|
|
196
|
+
}>(auth: Auth, handler: (request: Request, accessTokenClaims: JWTPayload) => Awaitable<Response>, opts?: RequireMcpAuthOptions) => (req: Request) => Promise<Response>;
|
|
137
197
|
//#endregion
|
|
138
|
-
export { type McpOptions,
|
|
198
|
+
export { type McpOptions, type McpProtectedRequestHandlerOptions, type RequireMcpAuthOptions, createMcpProtectedRequestHandler, mcp, requireMcpAuth };
|
package/dist/index.mjs
CHANGED
|
@@ -1,42 +1,85 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { DPOP_SIGNING_ALGORITHMS, createDpopReplayStore, requestToResourceInput, verifyAccessTokenRequest } from "better-auth/oauth2";
|
|
3
|
-
import { APIError } from "better-call";
|
|
1
|
+
import { createResourceServerChallenge, getIssuer, metadataResponse, oauthProvider } from "@better-auth/oauth-provider";
|
|
2
|
+
import { DPOP_SIGNING_ALGORITHMS, createDpopReplayStore, isInsufficientScopeError, requestToResourceInput, verifyAccessTokenRequest } from "better-auth/oauth2";
|
|
4
3
|
//#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
|
+
/**
|
|
10
|
+
* Validates the canonical protected-resource identifier accepted by MCP.
|
|
11
|
+
*
|
|
12
|
+
* @internal
|
|
13
|
+
*/
|
|
14
|
+
function validateMcpResource(resource) {
|
|
15
|
+
if (typeof resource !== "string") throw new TypeError("MCP resource must be a single URL string");
|
|
16
|
+
let url;
|
|
17
|
+
try {
|
|
18
|
+
url = new URL(resource);
|
|
19
|
+
} catch {
|
|
20
|
+
throw new TypeError("MCP resource must be an absolute URL");
|
|
21
|
+
}
|
|
22
|
+
if (url.username || url.password) throw new TypeError("MCP resource URL must not contain credentials");
|
|
23
|
+
if (resource.includes("#")) throw new TypeError("MCP resource URL must not contain a fragment");
|
|
24
|
+
if (resource.includes("?")) throw new TypeError("MCP resource URL must not contain a query; to protect a query-carrying resource, verify tokens with verifyAccessTokenRequest and build challenges with createResourceServerChallenge");
|
|
25
|
+
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
|
+
return resource;
|
|
27
|
+
}
|
|
28
|
+
function toChallengeResponse(error, resource, opts) {
|
|
29
|
+
const challenge = createResourceServerChallenge(error, resource, opts);
|
|
30
|
+
if (!challenge) throw error;
|
|
31
|
+
const headers = new Headers(challenge.headers);
|
|
32
|
+
headers.set("Content-Type", "application/json");
|
|
33
|
+
return new Response(JSON.stringify({
|
|
34
|
+
jsonrpc: "2.0",
|
|
35
|
+
error: {
|
|
36
|
+
code: -32e3,
|
|
37
|
+
message: challenge.message
|
|
38
|
+
},
|
|
39
|
+
id: null
|
|
40
|
+
}), {
|
|
41
|
+
status: challenge.statusCode,
|
|
42
|
+
headers
|
|
43
|
+
});
|
|
44
|
+
}
|
|
5
45
|
/**
|
|
6
46
|
* A request middleware handler that verifies an MCP access token and responds
|
|
7
47
|
* with an RFC 9728 `WWW-Authenticate` header for unauthenticated requests.
|
|
48
|
+
* When `options.requiredScopes` is set, tokens missing a required scope receive
|
|
49
|
+
* a 403 with an RFC 6750 `insufficient_scope` challenge naming them instead.
|
|
8
50
|
*
|
|
9
51
|
* @external
|
|
10
52
|
*/
|
|
11
|
-
const
|
|
12
|
-
|
|
53
|
+
const createMcpProtectedRequestHandler = (options, handler) => {
|
|
54
|
+
const resource = validateMcpResource(options.audience);
|
|
55
|
+
const resolvedChallengeOptions = {
|
|
56
|
+
challengeScopes: options.challengeScopes ?? options.requiredScopes,
|
|
57
|
+
dpopSigningAlgorithms: options.dpop?.signingAlgorithms
|
|
58
|
+
};
|
|
59
|
+
const accessTokenVerificationOptions = {
|
|
60
|
+
verifyOptions: {
|
|
61
|
+
...options.jwtVerifyOptions,
|
|
62
|
+
issuer: options.issuer,
|
|
63
|
+
audience: options.audience
|
|
64
|
+
},
|
|
65
|
+
jwksUrl: options.jwksUrl,
|
|
66
|
+
remoteVerify: options.remoteVerify,
|
|
67
|
+
requiredScopes: options.requiredScopes,
|
|
68
|
+
isScopeSatisfied: options.isScopeSatisfied,
|
|
69
|
+
dpop: options.dpop
|
|
70
|
+
};
|
|
71
|
+
return async (request) => {
|
|
72
|
+
let accessTokenClaims;
|
|
13
73
|
try {
|
|
14
|
-
|
|
74
|
+
accessTokenClaims = await verifyAccessTokenRequest(requestToResourceInput(request), accessTokenVerificationOptions);
|
|
15
75
|
} catch (error) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const headers = new Headers(err.headers);
|
|
24
|
-
headers.set("Content-Type", "application/json");
|
|
25
|
-
return new Response(JSON.stringify({
|
|
26
|
-
jsonrpc: "2.0",
|
|
27
|
-
error: {
|
|
28
|
-
code: -32e3,
|
|
29
|
-
message: err.message
|
|
30
|
-
},
|
|
31
|
-
id: null
|
|
32
|
-
}), {
|
|
33
|
-
status: err.statusCode,
|
|
34
|
-
headers
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
throw new Error(String(err));
|
|
38
|
-
}
|
|
39
|
-
throw new Error(String(error));
|
|
76
|
+
return toChallengeResponse(error, resource, resolvedChallengeOptions);
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
return await handler(request, accessTokenClaims);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (isInsufficientScopeError(error)) return toChallengeResponse(error, resource, resolvedChallengeOptions);
|
|
82
|
+
throw error;
|
|
40
83
|
}
|
|
41
84
|
};
|
|
42
85
|
};
|
|
@@ -57,6 +100,7 @@ const appendProtectedResource = (resources, resource) => {
|
|
|
57
100
|
if (configuredResources.some((configuredResource) => resourceIdentifier(configuredResource) === resource)) return configuredResources;
|
|
58
101
|
return [...configuredResources, resource];
|
|
59
102
|
};
|
|
103
|
+
const appendResourceIdentifier = (resources, resource) => resources?.includes(resource) ? resources : [...resources ?? [], resource];
|
|
60
104
|
/**
|
|
61
105
|
* Build the RFC 9728 Protected Resource Metadata document. The MCP server is the
|
|
62
106
|
* resource server, and its authorization server is this same provider, so
|
|
@@ -82,12 +126,19 @@ const buildResourceServerMetadata = (ctx, providerOptions, resource) => {
|
|
|
82
126
|
* Model Context Protocol authorization server.
|
|
83
127
|
*
|
|
84
128
|
* `mcp()` is the OAuth 2.1 / OIDC provider ({@link oauthProvider}) configured for
|
|
85
|
-
* MCP: it
|
|
86
|
-
*
|
|
87
|
-
* metadata so MCP clients discover and use it through
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
129
|
+
* MCP: it binds issued tokens to the MCP `resource`, links that resource to
|
|
130
|
+
* newly registered clients, and, as the resource server, serves the RFC 9728
|
|
131
|
+
* protected resource metadata so MCP clients discover and use it through
|
|
132
|
+
* standard OAuth discovery. Client registration is opt-in: compose with
|
|
133
|
+
* `cimd()` for Client ID Metadata Documents, or explicitly enable the OAuth
|
|
134
|
+
* Provider dynamic-registration options.
|
|
135
|
+
* It also defaults `refreshTokenReuseInterval` to 30 seconds for all MCP
|
|
136
|
+
* clients, allowing a retried refresh to recover a rotated response. OAuth
|
|
137
|
+
* Provider remains strict by default; set the MCP option to `0` to disable the
|
|
138
|
+
* overlap window.
|
|
139
|
+
* MCP 2026-07-28 pins Client ID Metadata Documents draft-00. Configure the
|
|
140
|
+
* CIMD plugin with `metadataProfile: "mcp-2026-07-28"` and an application-owned
|
|
141
|
+
* metadata-resource transport.
|
|
91
142
|
* Because it is the OAuth provider, it cannot be combined with a separate
|
|
92
143
|
* {@link oauthProvider}.
|
|
93
144
|
*
|
|
@@ -95,7 +146,9 @@ const buildResourceServerMetadata = (ctx, providerOptions, resource) => {
|
|
|
95
146
|
* ```ts
|
|
96
147
|
* import { betterAuth } from "better-auth";
|
|
97
148
|
* import { jwt } from "better-auth/plugins";
|
|
149
|
+
* import { cimd } from "@better-auth/cimd";
|
|
98
150
|
* import { mcp } from "@better-auth/mcp";
|
|
151
|
+
* import { fetchClientMetadataResource } from "./oauth-network";
|
|
99
152
|
*
|
|
100
153
|
* export const auth = betterAuth({
|
|
101
154
|
* plugins: [
|
|
@@ -105,19 +158,22 @@ const buildResourceServerMetadata = (ctx, providerOptions, resource) => {
|
|
|
105
158
|
* consentPage: "/consent",
|
|
106
159
|
* resource: "https://api.example.com/mcp",
|
|
107
160
|
* }),
|
|
161
|
+
* cimd({
|
|
162
|
+
* fetchClientMetadataResource,
|
|
163
|
+
* metadataProfile: "mcp-2026-07-28",
|
|
164
|
+
* }),
|
|
108
165
|
* ],
|
|
109
166
|
* });
|
|
110
167
|
* ```
|
|
111
168
|
*/
|
|
112
169
|
const mcp = (options) => {
|
|
113
|
-
const { resource, refreshTokenReuseInterval = 30, ...oauthOptions } = options;
|
|
114
|
-
|
|
170
|
+
const { resource: configuredResource, refreshTokenReuseInterval = 30, ...oauthOptions } = options;
|
|
171
|
+
const resource = validateMcpResource(configuredResource);
|
|
115
172
|
const provider = oauthProvider({
|
|
116
|
-
allowDynamicClientRegistration: true,
|
|
117
|
-
allowUnauthenticatedClientRegistration: true,
|
|
118
173
|
refreshTokenReuseInterval,
|
|
119
174
|
...oauthOptions,
|
|
120
|
-
resources: appendProtectedResource(oauthOptions.resources, resource)
|
|
175
|
+
resources: appendProtectedResource(oauthOptions.resources, resource),
|
|
176
|
+
clientRegistrationDefaultResources: appendResourceIdentifier(oauthOptions.clientRegistrationDefaultResources, resource)
|
|
121
177
|
});
|
|
122
178
|
const serveProviderDiscovery = provider.onRequest;
|
|
123
179
|
return {
|
|
@@ -149,70 +205,46 @@ const mcp = (options) => {
|
|
|
149
205
|
};
|
|
150
206
|
//#endregion
|
|
151
207
|
//#region src/require-mcp-auth.ts
|
|
152
|
-
const unauthorized = (error) => {
|
|
153
|
-
const headers = new Headers(error.headers);
|
|
154
|
-
headers.set("Content-Type", "application/json");
|
|
155
|
-
return new Response(JSON.stringify({
|
|
156
|
-
jsonrpc: "2.0",
|
|
157
|
-
error: {
|
|
158
|
-
code: -32e3,
|
|
159
|
-
message: error.message
|
|
160
|
-
},
|
|
161
|
-
id: null
|
|
162
|
-
}), {
|
|
163
|
-
status: error.statusCode,
|
|
164
|
-
headers
|
|
165
|
-
});
|
|
166
|
-
};
|
|
167
208
|
/**
|
|
168
209
|
* Protects an MCP server route handler. Verifies the bearer access token
|
|
169
210
|
* against the authorization server's JWKS (checking signature, issuer,
|
|
170
211
|
* audience, and expiry) and forwards the verified JWT payload to the handler.
|
|
171
212
|
* Unauthenticated requests receive a JSON-RPC 401 with the RFC 9728
|
|
172
213
|
* `WWW-Authenticate` header so MCP clients can start the authorization flow.
|
|
214
|
+
* Tokens missing a required scope receive a 403 with an RFC 6750
|
|
215
|
+
* `insufficient_scope` challenge naming the missing scopes, so clients can step
|
|
216
|
+
* up their authorization; a handler can raise the same challenge for scopes only
|
|
217
|
+
* it knows about by throwing `createInsufficientScopeError`.
|
|
173
218
|
*
|
|
174
219
|
* For a resource server that runs separately from the authorization server, or
|
|
175
|
-
* a server using a dynamic `baseURL`, use
|
|
176
|
-
* verification options
|
|
220
|
+
* a server using a dynamic `baseURL`, use
|
|
221
|
+
* {@link createMcpProtectedRequestHandler} with explicit verification options
|
|
222
|
+
* instead.
|
|
177
223
|
*
|
|
178
224
|
* @external
|
|
179
225
|
*/
|
|
180
226
|
const requireMcpAuth = (auth, handler, opts) => {
|
|
181
|
-
if (opts?.resource !== void 0)
|
|
227
|
+
if (opts?.resource !== void 0) validateMcpResource(opts.resource);
|
|
182
228
|
return async (req) => {
|
|
183
229
|
const { baseURL, internalAdapter } = await auth.$context;
|
|
184
|
-
if (!baseURL) throw new Error("requireMcpAuth requires a resolvable base URL. For dynamic base URLs use `
|
|
230
|
+
if (!baseURL) throw new Error("requireMcpAuth requires a resolvable base URL. For dynamic base URLs use `createMcpProtectedRequestHandler` with explicit verification options.");
|
|
185
231
|
const issuer = opts?.issuer ?? baseURL;
|
|
186
232
|
const resource = opts?.resource ?? baseURL;
|
|
187
233
|
const jwksUrl = opts?.jwksUrl ?? `${baseURL}/jwks`;
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
}
|
|
200
|
-
}));
|
|
201
|
-
} catch (error) {
|
|
202
|
-
try {
|
|
203
|
-
raiseResourceServerChallenge(error, resource, {
|
|
204
|
-
scope: opts?.scope,
|
|
205
|
-
resourceMetadataMappings: opts?.resourceMetadataMappings,
|
|
206
|
-
dpopSigningAlgorithms: opts?.dpop?.signingAlgorithms
|
|
207
|
-
});
|
|
208
|
-
} catch (challengeError) {
|
|
209
|
-
if (challengeError instanceof APIError) return unauthorized(challengeError);
|
|
210
|
-
if (challengeError instanceof Error) throw challengeError;
|
|
211
|
-
throw new Error(String(challengeError));
|
|
234
|
+
return createMcpProtectedRequestHandler({
|
|
235
|
+
issuer,
|
|
236
|
+
audience: resource,
|
|
237
|
+
requiredScopes: opts?.requiredScopes,
|
|
238
|
+
challengeScopes: opts?.challengeScopes,
|
|
239
|
+
isScopeSatisfied: opts?.isScopeSatisfied,
|
|
240
|
+
jwksUrl,
|
|
241
|
+
dpop: {
|
|
242
|
+
proofMaxAgeSeconds: opts?.dpop?.proofMaxAgeSeconds,
|
|
243
|
+
signingAlgorithms: opts?.dpop?.signingAlgorithms,
|
|
244
|
+
replayStore: opts?.dpop?.replayStore ?? createDpopReplayStore(internalAdapter)
|
|
212
245
|
}
|
|
213
|
-
|
|
214
|
-
}
|
|
246
|
+
}, handler)(req);
|
|
215
247
|
};
|
|
216
248
|
};
|
|
217
249
|
//#endregion
|
|
218
|
-
export {
|
|
250
|
+
export { createMcpProtectedRequestHandler, mcp, requireMcpAuth };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@better-auth/mcp",
|
|
3
|
-
"version": "1.7.0-rc.
|
|
3
|
+
"version": "1.7.0-rc.4",
|
|
4
4
|
"description": "Model Context Protocol (MCP) plugin for Better Auth",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,49 +36,31 @@
|
|
|
36
36
|
"dev-source": "./src/index.ts",
|
|
37
37
|
"types": "./dist/index.d.mts",
|
|
38
38
|
"default": "./dist/index.mjs"
|
|
39
|
-
},
|
|
40
|
-
"./client": {
|
|
41
|
-
"dev-source": "./src/client/index.ts",
|
|
42
|
-
"types": "./dist/client/index.d.mts",
|
|
43
|
-
"default": "./dist/client/index.mjs"
|
|
44
|
-
},
|
|
45
|
-
"./client/adapters": {
|
|
46
|
-
"dev-source": "./src/client/adapters.ts",
|
|
47
|
-
"types": "./dist/client/adapters.d.mts",
|
|
48
|
-
"default": "./dist/client/adapters.mjs"
|
|
49
39
|
}
|
|
50
40
|
},
|
|
51
41
|
"typesVersions": {
|
|
52
42
|
"*": {
|
|
53
43
|
"*": [
|
|
54
44
|
"./dist/index.d.mts"
|
|
55
|
-
],
|
|
56
|
-
"client": [
|
|
57
|
-
"./dist/client/index.d.mts"
|
|
58
|
-
],
|
|
59
|
-
"client/adapters": [
|
|
60
|
-
"./dist/client/adapters.d.mts"
|
|
61
45
|
]
|
|
62
46
|
}
|
|
63
47
|
},
|
|
64
48
|
"dependencies": {
|
|
65
49
|
"jose": "^6.1.3",
|
|
66
|
-
"@better-auth/oauth-provider": "^1.7.0-rc.
|
|
50
|
+
"@better-auth/oauth-provider": "^1.7.0-rc.4"
|
|
67
51
|
},
|
|
68
52
|
"devDependencies": {
|
|
69
|
-
"@modelcontextprotocol/
|
|
53
|
+
"@modelcontextprotocol/client": "2.0.0",
|
|
54
|
+
"@modelcontextprotocol/server": "2.0.0",
|
|
70
55
|
"better-call": "1.3.7",
|
|
71
|
-
"listhen": "1.9.0",
|
|
72
56
|
"tsdown": "0.22.7",
|
|
73
|
-
"better-auth": "1.7.0-rc.
|
|
74
|
-
"
|
|
57
|
+
"@better-auth/core": "1.7.0-rc.4",
|
|
58
|
+
"better-auth": "1.7.0-rc.4"
|
|
75
59
|
},
|
|
76
60
|
"peerDependencies": {
|
|
77
|
-
"@better-auth/utils": "0.4.2",
|
|
78
|
-
"@better-fetch/fetch": "1.3.1",
|
|
79
61
|
"better-call": "1.3.7",
|
|
80
|
-
"better-auth": "^1.7.0-rc.
|
|
81
|
-
"
|
|
62
|
+
"@better-auth/core": "^1.7.0-rc.4",
|
|
63
|
+
"better-auth": "^1.7.0-rc.4"
|
|
82
64
|
},
|
|
83
65
|
"scripts": {
|
|
84
66
|
"build": "tsdown",
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import { McpResourceClient, McpResourceClientOptions, McpSession } from "./index.mjs";
|
|
2
|
-
//#region src/client/adapters.d.ts
|
|
3
|
-
interface HonoContext {
|
|
4
|
-
req: {
|
|
5
|
-
header: (name: string) => string | undefined;
|
|
6
|
-
raw: Request;
|
|
7
|
-
};
|
|
8
|
-
set: (key: string, value: unknown) => void;
|
|
9
|
-
json: (data: unknown, status?: number, headers?: Record<string, string>) => Response;
|
|
10
|
-
header: (name: string, value: string) => void;
|
|
11
|
-
}
|
|
12
|
-
type HonoNext = () => Promise<void>;
|
|
13
|
-
type HonoMiddleware = (c: HonoContext, next: HonoNext) => Promise<Response | void>;
|
|
14
|
-
interface HonoApp {
|
|
15
|
-
get: (path: string, handler: (c: HonoContext) => Promise<Response>) => void;
|
|
16
|
-
}
|
|
17
|
-
declare function mcpAuthHono(options: McpResourceClientOptions): {
|
|
18
|
-
client: McpResourceClient;
|
|
19
|
-
middleware: HonoMiddleware;
|
|
20
|
-
discoveryRoutes: (app: HonoApp, serverURL: string) => void;
|
|
21
|
-
};
|
|
22
|
-
declare function mcpAuthOfficial(options: McpResourceClientOptions): {
|
|
23
|
-
client: McpResourceClient;
|
|
24
|
-
handler: McpResourceClient["handler"];
|
|
25
|
-
verifyToken: McpResourceClient["verifyToken"];
|
|
26
|
-
};
|
|
27
|
-
type OAuthMode = "direct" | "proxy";
|
|
28
|
-
interface McpUseUserInfo {
|
|
29
|
-
userId: string;
|
|
30
|
-
roles?: string[];
|
|
31
|
-
permissions?: string[];
|
|
32
|
-
scopes?: string;
|
|
33
|
-
clientId?: string;
|
|
34
|
-
[key: string]: unknown;
|
|
35
|
-
}
|
|
36
|
-
interface OAuthProvider {
|
|
37
|
-
verifyToken(token: string): Promise<{
|
|
38
|
-
payload: Record<string, unknown>;
|
|
39
|
-
}>;
|
|
40
|
-
getUserInfo(payload: Record<string, unknown>): McpUseUserInfo;
|
|
41
|
-
getIssuer(): string;
|
|
42
|
-
getAuthEndpoint(): string;
|
|
43
|
-
getTokenEndpoint(): string;
|
|
44
|
-
getScopesSupported(): string[];
|
|
45
|
-
getGrantTypesSupported(): string[];
|
|
46
|
-
getMode(): OAuthMode;
|
|
47
|
-
getRegistrationEndpoint?(): string;
|
|
48
|
-
}
|
|
49
|
-
interface McpUseBetterAuthConfig {
|
|
50
|
-
authURL: string;
|
|
51
|
-
getUserInfo?: (payload: Record<string, unknown>) => McpUseUserInfo;
|
|
52
|
-
}
|
|
53
|
-
declare function mcpAuthMcpUse(config: McpUseBetterAuthConfig): OAuthProvider;
|
|
54
|
-
//#endregion
|
|
55
|
-
export { type McpResourceClient, type McpResourceClientOptions, type McpSession, McpUseBetterAuthConfig, mcpAuthHono, mcpAuthMcpUse, mcpAuthOfficial };
|
package/dist/client/adapters.mjs
DELETED
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
import { createMcpResourceClient, makeDpopWWWAuthenticate } from "./index.mjs";
|
|
2
|
-
import { DPOP_SIGNING_ALGORITHMS, isDpopBindingError, parseAccessTokenAuthorization } from "better-auth/oauth2";
|
|
3
|
-
//#region src/client/adapters.ts
|
|
4
|
-
const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource";
|
|
5
|
-
function getProtectedResourceMetadataPath(resource) {
|
|
6
|
-
const resourceUrl = new URL(resource);
|
|
7
|
-
if (resourceUrl.origin === "null") return PROTECTED_RESOURCE_METADATA_PATH;
|
|
8
|
-
const resourcePath = resourceUrl.pathname === "/" ? "" : resourceUrl.pathname.replace(/\/$/, "");
|
|
9
|
-
return `${PROTECTED_RESOURCE_METADATA_PATH}${resourcePath}`;
|
|
10
|
-
}
|
|
11
|
-
function getProtectedResourceMetadataURL(resource) {
|
|
12
|
-
const resourceUrl = new URL(resource);
|
|
13
|
-
if (resourceUrl.origin === "null") throw new Error("MCP resource_metadata requires an origin-based resource URL");
|
|
14
|
-
return `${resourceUrl.origin}${getProtectedResourceMetadataPath(resource)}${resourceUrl.search}`;
|
|
15
|
-
}
|
|
16
|
-
function mcpAuthHono(options) {
|
|
17
|
-
const client = createMcpResourceClient(options);
|
|
18
|
-
const resourceMetadata = getProtectedResourceMetadataURL(options.resource ?? client.authURL);
|
|
19
|
-
const dpopChallenge = makeDpopWWWAuthenticate(options.dpop?.signingAlgorithms ?? DPOP_SIGNING_ALGORITHMS);
|
|
20
|
-
const unauthorized = (c, challenge, message) => {
|
|
21
|
-
c.header("WWW-Authenticate", challenge);
|
|
22
|
-
return c.json({
|
|
23
|
-
jsonrpc: "2.0",
|
|
24
|
-
error: {
|
|
25
|
-
code: -32e3,
|
|
26
|
-
message
|
|
27
|
-
},
|
|
28
|
-
id: null
|
|
29
|
-
}, 401);
|
|
30
|
-
};
|
|
31
|
-
const middleware = async (c, next) => {
|
|
32
|
-
const authHeader = c.req.header("Authorization");
|
|
33
|
-
let session;
|
|
34
|
-
try {
|
|
35
|
-
session = await client.verifyRequest(c.req.raw);
|
|
36
|
-
} catch (error) {
|
|
37
|
-
if (isDpopBindingError(error)) return unauthorized(c, dpopChallenge, "Invalid or expired token");
|
|
38
|
-
throw error;
|
|
39
|
-
}
|
|
40
|
-
if (!session) {
|
|
41
|
-
const challenge = parseAccessTokenAuthorization(authHeader)?.scheme === "DPoP" || !!c.req.header("DPoP") ? dpopChallenge : `Bearer resource_metadata="${resourceMetadata}"`;
|
|
42
|
-
return unauthorized(c, challenge, authHeader ? "Invalid or expired token" : "Unauthorized: Authentication required");
|
|
43
|
-
}
|
|
44
|
-
c.set("mcpSession", session);
|
|
45
|
-
await next();
|
|
46
|
-
};
|
|
47
|
-
const discoveryRoutes = (app, serverURL) => {
|
|
48
|
-
const discoveryFn = client.discoveryHandler();
|
|
49
|
-
const protectedResourceFn = client.protectedResourceHandler(serverURL);
|
|
50
|
-
const protectedResourcePaths = /* @__PURE__ */ new Set([PROTECTED_RESOURCE_METADATA_PATH, getProtectedResourceMetadataPath(options.resource ?? client.authURL)]);
|
|
51
|
-
app.get("/.well-known/oauth-authorization-server", async (c) => {
|
|
52
|
-
const response = await discoveryFn(c.req.raw);
|
|
53
|
-
const data = await response.json().catch(() => ({ error: "Invalid response from auth server" }));
|
|
54
|
-
return c.json(data, response.status);
|
|
55
|
-
});
|
|
56
|
-
for (const path of protectedResourcePaths) app.get(path, async (c) => {
|
|
57
|
-
const response = await protectedResourceFn(c.req.raw);
|
|
58
|
-
const data = await response.json().catch(() => ({ error: "Invalid response from auth server" }));
|
|
59
|
-
return c.json(data, response.status);
|
|
60
|
-
});
|
|
61
|
-
};
|
|
62
|
-
return {
|
|
63
|
-
client,
|
|
64
|
-
middleware,
|
|
65
|
-
discoveryRoutes
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
function mcpAuthOfficial(options) {
|
|
69
|
-
const client = createMcpResourceClient(options);
|
|
70
|
-
return {
|
|
71
|
-
client,
|
|
72
|
-
handler: client.handler,
|
|
73
|
-
verifyToken: client.verifyToken
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
function mcpAuthMcpUse(config) {
|
|
77
|
-
const authURL = normalizeURL(config.authURL);
|
|
78
|
-
if (!authURL) throw new Error("Better Auth authURL is required. Pass authURL in config, e.g.: mcpAuthMcpUse({ authURL: 'http://localhost:3000/api/auth' })");
|
|
79
|
-
const client = createMcpResourceClient({ authURL });
|
|
80
|
-
return {
|
|
81
|
-
async verifyToken(token) {
|
|
82
|
-
const session = await client.verifyToken(token);
|
|
83
|
-
if (!session) throw new Error("Invalid or expired token");
|
|
84
|
-
return { payload: session };
|
|
85
|
-
},
|
|
86
|
-
getUserInfo(payload) {
|
|
87
|
-
if (config.getUserInfo) return config.getUserInfo(payload);
|
|
88
|
-
const scopes = typeof payload.scope === "string" ? payload.scope.split(" ") : [];
|
|
89
|
-
return {
|
|
90
|
-
userId: payload.sub,
|
|
91
|
-
roles: [],
|
|
92
|
-
permissions: scopes,
|
|
93
|
-
scopes: payload.scope,
|
|
94
|
-
clientId: payload.azp ?? payload.client_id
|
|
95
|
-
};
|
|
96
|
-
},
|
|
97
|
-
getIssuer() {
|
|
98
|
-
return authURL;
|
|
99
|
-
},
|
|
100
|
-
getAuthEndpoint() {
|
|
101
|
-
return `${authURL}/oauth2/authorize`;
|
|
102
|
-
},
|
|
103
|
-
getTokenEndpoint() {
|
|
104
|
-
return `${authURL}/oauth2/token`;
|
|
105
|
-
},
|
|
106
|
-
getScopesSupported() {
|
|
107
|
-
return [
|
|
108
|
-
"openid",
|
|
109
|
-
"profile",
|
|
110
|
-
"email",
|
|
111
|
-
"offline_access"
|
|
112
|
-
];
|
|
113
|
-
},
|
|
114
|
-
getGrantTypesSupported() {
|
|
115
|
-
return ["authorization_code", "refresh_token"];
|
|
116
|
-
},
|
|
117
|
-
getMode() {
|
|
118
|
-
return "direct";
|
|
119
|
-
},
|
|
120
|
-
getRegistrationEndpoint() {
|
|
121
|
-
return `${authURL}/oauth2/register`;
|
|
122
|
-
}
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
function normalizeURL(url) {
|
|
126
|
-
if (!url || url.trim() === "") return void 0;
|
|
127
|
-
return url.endsWith("/") ? url.slice(0, -1) : url;
|
|
128
|
-
}
|
|
129
|
-
//#endregion
|
|
130
|
-
export { mcpAuthHono, mcpAuthMcpUse, mcpAuthOfficial };
|
package/dist/client/index.d.mts
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import { VerifyAccessTokenRequestOptions } from "better-auth/oauth2";
|
|
2
|
-
import { JWTPayload } from "jose";
|
|
3
|
-
//#region src/client/index.d.ts
|
|
4
|
-
interface McpResourceClientOptions {
|
|
5
|
-
authURL: string;
|
|
6
|
-
resource?: string;
|
|
7
|
-
allowedOrigin?: string;
|
|
8
|
-
fetch?: typeof globalThis.fetch;
|
|
9
|
-
dpop?: VerifyAccessTokenRequestOptions["dpop"];
|
|
10
|
-
}
|
|
11
|
-
interface McpSession extends JWTPayload {
|
|
12
|
-
sub?: string;
|
|
13
|
-
scope?: string;
|
|
14
|
-
client_id?: string;
|
|
15
|
-
}
|
|
16
|
-
interface NodeLikeRequest {
|
|
17
|
-
headers: Record<string, string | string[] | undefined> & {
|
|
18
|
-
get?: (name: string) => string | undefined;
|
|
19
|
-
authorization?: string;
|
|
20
|
-
host?: string;
|
|
21
|
-
"x-forwarded-proto"?: string;
|
|
22
|
-
};
|
|
23
|
-
get?: (name: string) => string | undefined;
|
|
24
|
-
method?: string;
|
|
25
|
-
originalUrl?: string;
|
|
26
|
-
protocol?: string;
|
|
27
|
-
url?: string;
|
|
28
|
-
mcpSession?: McpSession;
|
|
29
|
-
}
|
|
30
|
-
interface NodeLikeResponse {
|
|
31
|
-
set?: (name: string, value: string) => void;
|
|
32
|
-
get?: (name: string) => string | undefined;
|
|
33
|
-
setHeader?: (name: string, value: string) => void;
|
|
34
|
-
getHeader?: (name: string) => string | number | string[] | undefined;
|
|
35
|
-
status?: (code: number) => {
|
|
36
|
-
json: (body: unknown) => void;
|
|
37
|
-
};
|
|
38
|
-
writeHead?: (code: number, headers: Record<string, string>) => void;
|
|
39
|
-
end?: (body: string) => void;
|
|
40
|
-
}
|
|
41
|
-
interface McpResourceClient {
|
|
42
|
-
verifyToken: (token: string) => Promise<McpSession | null>;
|
|
43
|
-
verifyRequest: (req: Request) => Promise<McpSession | null>;
|
|
44
|
-
handler: (fn: (req: Request, session: McpSession) => Response | Promise<Response>) => (req: Request) => Promise<Response>;
|
|
45
|
-
discoveryHandler: () => (req: Request) => Promise<Response>;
|
|
46
|
-
protectedResourceHandler: (serverURL: string) => (req: Request) => Promise<Response>;
|
|
47
|
-
middleware: () => (req: NodeLikeRequest, res: NodeLikeResponse, next: () => void) => Promise<void>;
|
|
48
|
-
authURL: string;
|
|
49
|
-
}
|
|
50
|
-
declare function makeDpopWWWAuthenticate(algorithms: readonly string[]): string;
|
|
51
|
-
declare function createMcpResourceClient(options: McpResourceClientOptions): McpResourceClient;
|
|
52
|
-
//#endregion
|
|
53
|
-
export { McpResourceClient, McpResourceClientOptions, McpSession, createMcpResourceClient, makeDpopWWWAuthenticate };
|
package/dist/client/index.mjs
DELETED
|
@@ -1,267 +0,0 @@
|
|
|
1
|
-
import { DPOP_SIGNING_ALGORITHMS, createInMemoryDpopReplayStore, enforceDpopBinding, getDpopJktFromPayload, isDpopBindingError, parseAccessTokenAuthorization } from "better-auth/oauth2";
|
|
2
|
-
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
3
|
-
//#region src/client/index.ts
|
|
4
|
-
const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource";
|
|
5
|
-
function buildCorsHeaders(authURL, allowedOrigin) {
|
|
6
|
-
let origin;
|
|
7
|
-
if (allowedOrigin) origin = allowedOrigin;
|
|
8
|
-
else try {
|
|
9
|
-
origin = new URL(authURL).origin;
|
|
10
|
-
} catch {
|
|
11
|
-
origin = authURL;
|
|
12
|
-
}
|
|
13
|
-
return {
|
|
14
|
-
"Access-Control-Allow-Origin": origin,
|
|
15
|
-
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
16
|
-
"Access-Control-Allow-Headers": "Content-Type, Authorization, DPoP",
|
|
17
|
-
"Access-Control-Expose-Headers": "WWW-Authenticate",
|
|
18
|
-
"Access-Control-Max-Age": "86400"
|
|
19
|
-
};
|
|
20
|
-
}
|
|
21
|
-
function getProtectedResourceMetadataURL(resource) {
|
|
22
|
-
const resourceUrl = new URL(resource);
|
|
23
|
-
if (resourceUrl.origin === "null") throw new Error("MCP resource_metadata requires an origin-based resource URL");
|
|
24
|
-
const resourcePath = resourceUrl.pathname === "/" ? "" : resourceUrl.pathname.replace(/\/$/, "");
|
|
25
|
-
return `${resourceUrl.origin}${PROTECTED_RESOURCE_METADATA_PATH}${resourcePath}${resourceUrl.search}`;
|
|
26
|
-
}
|
|
27
|
-
function makeWWWAuthenticate(authURL, resource) {
|
|
28
|
-
return `Bearer resource_metadata="${getProtectedResourceMetadataURL(resource ?? authURL)}"`;
|
|
29
|
-
}
|
|
30
|
-
function makeDpopWWWAuthenticate(algorithms) {
|
|
31
|
-
return `DPoP algs="${algorithms.map((alg) => alg.replace(/[\r\n"\\]/g, "")).join(" ")}"`;
|
|
32
|
-
}
|
|
33
|
-
function addExposeHeader(headers, headerName) {
|
|
34
|
-
const exposedHeaders = headers["Access-Control-Expose-Headers"];
|
|
35
|
-
if (!exposedHeaders) return {
|
|
36
|
-
...headers,
|
|
37
|
-
"Access-Control-Expose-Headers": headerName
|
|
38
|
-
};
|
|
39
|
-
const alreadyExposed = exposedHeaders.split(",").some((header) => header.trim().toLowerCase() === headerName.toLowerCase());
|
|
40
|
-
return {
|
|
41
|
-
...headers,
|
|
42
|
-
"Access-Control-Expose-Headers": alreadyExposed ? exposedHeaders : `${exposedHeaders}, ${headerName}`
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
function make401Response(wwwAuth, corsHeaders) {
|
|
46
|
-
return Response.json({
|
|
47
|
-
jsonrpc: "2.0",
|
|
48
|
-
error: {
|
|
49
|
-
code: -32e3,
|
|
50
|
-
message: "Unauthorized: Authentication required",
|
|
51
|
-
"www-authenticate": wwwAuth
|
|
52
|
-
},
|
|
53
|
-
id: null
|
|
54
|
-
}, {
|
|
55
|
-
status: 401,
|
|
56
|
-
headers: {
|
|
57
|
-
...addExposeHeader(corsHeaders, "WWW-Authenticate"),
|
|
58
|
-
"WWW-Authenticate": wwwAuth
|
|
59
|
-
}
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
function send401Node(res, wwwAuth, message, corsHeaders) {
|
|
63
|
-
const body = JSON.stringify({
|
|
64
|
-
jsonrpc: "2.0",
|
|
65
|
-
error: {
|
|
66
|
-
code: -32e3,
|
|
67
|
-
message
|
|
68
|
-
},
|
|
69
|
-
id: null
|
|
70
|
-
});
|
|
71
|
-
const existingExposedHeaders = res.get?.("Access-Control-Expose-Headers") ?? res.getHeader?.("Access-Control-Expose-Headers") ?? res.getHeader?.("access-control-expose-headers");
|
|
72
|
-
const headers = {
|
|
73
|
-
...addExposeHeader({
|
|
74
|
-
...corsHeaders,
|
|
75
|
-
...existingExposedHeaders ? { "Access-Control-Expose-Headers": Array.isArray(existingExposedHeaders) ? existingExposedHeaders.join(", ") : String(existingExposedHeaders) } : {}
|
|
76
|
-
}, "WWW-Authenticate"),
|
|
77
|
-
"WWW-Authenticate": wwwAuth
|
|
78
|
-
};
|
|
79
|
-
if (typeof res.set === "function") {
|
|
80
|
-
for (const [name, value] of Object.entries(headers)) res.set(name, value);
|
|
81
|
-
res.status?.(401).json(JSON.parse(body));
|
|
82
|
-
} else if (typeof res.writeHead === "function") {
|
|
83
|
-
res.writeHead(401, {
|
|
84
|
-
"Content-Type": "application/json",
|
|
85
|
-
...headers
|
|
86
|
-
});
|
|
87
|
-
res.end?.(body);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
function createMcpResourceClient(options) {
|
|
91
|
-
const authURL = options.authURL.endsWith("/") ? options.authURL.slice(0, -1) : options.authURL;
|
|
92
|
-
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
93
|
-
const corsHeaders = buildCorsHeaders(authURL, options.allowedOrigin);
|
|
94
|
-
const expectedAudience = options.resource ?? authURL;
|
|
95
|
-
const dpopReplayStore = options.dpop?.replayStore ?? createInMemoryDpopReplayStore();
|
|
96
|
-
const dpopSigningAlgorithms = options.dpop?.signingAlgorithms ?? DPOP_SIGNING_ALGORITHMS;
|
|
97
|
-
const selectChallenge = (authHeader, dpopHeaderPresent) => {
|
|
98
|
-
return parseAccessTokenAuthorization(authHeader)?.scheme === "DPoP" || dpopHeaderPresent ? makeDpopWWWAuthenticate(dpopSigningAlgorithms) : makeWWWAuthenticate(authURL, options.resource);
|
|
99
|
-
};
|
|
100
|
-
let discovery = null;
|
|
101
|
-
let jwks = null;
|
|
102
|
-
const loadVerifier = async () => {
|
|
103
|
-
if (discovery && jwks) return {
|
|
104
|
-
discovery,
|
|
105
|
-
jwks
|
|
106
|
-
};
|
|
107
|
-
const response = await fetchFn(`${authURL}/.well-known/oauth-authorization-server`);
|
|
108
|
-
if (!response.ok) throw new Error("Failed to fetch discovery metadata");
|
|
109
|
-
const metadata = await response.json();
|
|
110
|
-
if (!metadata.jwks_uri || !metadata.issuer) throw new Error("Discovery metadata missing jwks_uri or issuer");
|
|
111
|
-
discovery = {
|
|
112
|
-
issuer: metadata.issuer,
|
|
113
|
-
jwks_uri: metadata.jwks_uri
|
|
114
|
-
};
|
|
115
|
-
jwks = createRemoteJWKSet(new URL(metadata.jwks_uri));
|
|
116
|
-
return {
|
|
117
|
-
discovery,
|
|
118
|
-
jwks
|
|
119
|
-
};
|
|
120
|
-
};
|
|
121
|
-
const verifyJwtToken = async (token) => {
|
|
122
|
-
try {
|
|
123
|
-
const { discovery: meta, jwks: keySet } = await loadVerifier();
|
|
124
|
-
const { payload } = await jwtVerify(token, keySet, {
|
|
125
|
-
issuer: meta.issuer,
|
|
126
|
-
audience: expectedAudience
|
|
127
|
-
});
|
|
128
|
-
return payload;
|
|
129
|
-
} catch {
|
|
130
|
-
return null;
|
|
131
|
-
}
|
|
132
|
-
};
|
|
133
|
-
const verifyToken = async (token) => {
|
|
134
|
-
const session = await verifyJwtToken(token);
|
|
135
|
-
if (!session || getDpopJktFromPayload(session)) return null;
|
|
136
|
-
return session;
|
|
137
|
-
};
|
|
138
|
-
/**
|
|
139
|
-
* Verifies a request's access token and, when the token is DPoP-bound, its
|
|
140
|
-
* RFC 9449 sender-constraint. Returns `null` when there is no usable token
|
|
141
|
-
* or the JWT itself is invalid. Throws a `DpopBindingError` when a
|
|
142
|
-
* DPoP-bound token fails the binding check, so the caller can answer with a
|
|
143
|
-
* `WWW-Authenticate: DPoP` challenge rather than a bearer one.
|
|
144
|
-
*/
|
|
145
|
-
const verifyRequest = async (req) => {
|
|
146
|
-
const authorization = parseAccessTokenAuthorization(req.headers.get("Authorization"));
|
|
147
|
-
if (!authorization?.token || authorization.scheme === "Unknown") return null;
|
|
148
|
-
const session = await verifyJwtToken(authorization.token);
|
|
149
|
-
if (!session) return null;
|
|
150
|
-
await enforceDpopBinding({
|
|
151
|
-
payload: session,
|
|
152
|
-
authorization,
|
|
153
|
-
proofJwt: req.headers.get("DPoP"),
|
|
154
|
-
method: req.method,
|
|
155
|
-
url: req.url,
|
|
156
|
-
proofMaxAgeSeconds: options.dpop?.proofMaxAgeSeconds,
|
|
157
|
-
signingAlgorithms: dpopSigningAlgorithms,
|
|
158
|
-
replayStore: dpopReplayStore
|
|
159
|
-
});
|
|
160
|
-
return session;
|
|
161
|
-
};
|
|
162
|
-
const getHeader = (req, name) => {
|
|
163
|
-
const lower = name.toLowerCase();
|
|
164
|
-
const value = req.headers?.[lower] ?? req.headers?.[name] ?? req.headers?.get?.(name) ?? req.get?.(name);
|
|
165
|
-
if (Array.isArray(value)) return value[0];
|
|
166
|
-
return value;
|
|
167
|
-
};
|
|
168
|
-
const getNodeRequestUrl = (req) => {
|
|
169
|
-
const rawUrl = req.originalUrl ?? req.url ?? "/";
|
|
170
|
-
if (URL.canParse(rawUrl)) return rawUrl;
|
|
171
|
-
const fallbackUrl = new URL(authURL);
|
|
172
|
-
const host = getHeader(req, "host") ?? fallbackUrl.host;
|
|
173
|
-
return `${getHeader(req, "x-forwarded-proto")?.split(",")[0]?.trim() ?? req.protocol ?? fallbackUrl.protocol.replace(":", "")}://${host}${rawUrl.startsWith("/") ? rawUrl : `/${rawUrl}`}`;
|
|
174
|
-
};
|
|
175
|
-
const handler = (fn) => {
|
|
176
|
-
return async (req) => {
|
|
177
|
-
if (req.method === "OPTIONS") return new Response(null, {
|
|
178
|
-
status: 204,
|
|
179
|
-
headers: corsHeaders
|
|
180
|
-
});
|
|
181
|
-
let session;
|
|
182
|
-
try {
|
|
183
|
-
session = await verifyRequest(req);
|
|
184
|
-
} catch (error) {
|
|
185
|
-
if (isDpopBindingError(error)) return make401Response(makeDpopWWWAuthenticate(dpopSigningAlgorithms), corsHeaders);
|
|
186
|
-
throw error;
|
|
187
|
-
}
|
|
188
|
-
if (!session) return make401Response(selectChallenge(req.headers.get("Authorization"), req.headers.has("DPoP")), corsHeaders);
|
|
189
|
-
return fn(req, session);
|
|
190
|
-
};
|
|
191
|
-
};
|
|
192
|
-
const discoveryHandler = () => {
|
|
193
|
-
let cachedMetadata = null;
|
|
194
|
-
let cacheTime = 0;
|
|
195
|
-
const CACHE_TTL = 6e4;
|
|
196
|
-
return async (_req) => {
|
|
197
|
-
const now = Date.now();
|
|
198
|
-
if (cachedMetadata && now - cacheTime < CACHE_TTL) return Response.json(cachedMetadata, { headers: corsHeaders });
|
|
199
|
-
try {
|
|
200
|
-
const response = await fetchFn(`${authURL}/.well-known/oauth-authorization-server`);
|
|
201
|
-
if (!response.ok) return Response.json({ error: "Failed to fetch discovery metadata" }, {
|
|
202
|
-
status: 502,
|
|
203
|
-
headers: corsHeaders
|
|
204
|
-
});
|
|
205
|
-
cachedMetadata = await response.json();
|
|
206
|
-
cacheTime = now;
|
|
207
|
-
return Response.json(cachedMetadata, { headers: corsHeaders });
|
|
208
|
-
} catch {
|
|
209
|
-
return Response.json({ error: "Better Auth server unreachable" }, {
|
|
210
|
-
status: 502,
|
|
211
|
-
headers: corsHeaders
|
|
212
|
-
});
|
|
213
|
-
}
|
|
214
|
-
};
|
|
215
|
-
};
|
|
216
|
-
const protectedResourceHandler = (serverURL) => {
|
|
217
|
-
const metadata = {
|
|
218
|
-
resource: options.resource ?? new URL(serverURL).origin,
|
|
219
|
-
authorization_servers: [authURL],
|
|
220
|
-
bearer_methods_supported: ["header"],
|
|
221
|
-
dpop_signing_alg_values_supported: [...dpopSigningAlgorithms]
|
|
222
|
-
};
|
|
223
|
-
return async (_req) => {
|
|
224
|
-
return Response.json(metadata, { headers: corsHeaders });
|
|
225
|
-
};
|
|
226
|
-
};
|
|
227
|
-
const middleware = () => {
|
|
228
|
-
return async (req, res, next) => {
|
|
229
|
-
const authHeader = getHeader(req, "authorization");
|
|
230
|
-
const requestHeaders = new Headers();
|
|
231
|
-
if (authHeader) requestHeaders.set("Authorization", authHeader);
|
|
232
|
-
const dpop = getHeader(req, "dpop");
|
|
233
|
-
if (dpop) requestHeaders.set("DPoP", dpop);
|
|
234
|
-
const request = new Request(getNodeRequestUrl(req), {
|
|
235
|
-
method: req.method ?? "GET",
|
|
236
|
-
headers: requestHeaders
|
|
237
|
-
});
|
|
238
|
-
let session;
|
|
239
|
-
try {
|
|
240
|
-
session = await verifyRequest(request);
|
|
241
|
-
} catch (error) {
|
|
242
|
-
if (isDpopBindingError(error)) {
|
|
243
|
-
send401Node(res, makeDpopWWWAuthenticate(dpopSigningAlgorithms), "Invalid or expired token", corsHeaders);
|
|
244
|
-
return;
|
|
245
|
-
}
|
|
246
|
-
throw error;
|
|
247
|
-
}
|
|
248
|
-
if (!session) {
|
|
249
|
-
send401Node(res, selectChallenge(authHeader, !!getHeader(req, "dpop")), authHeader ? "Invalid or expired token" : "Unauthorized: Authentication required", corsHeaders);
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
252
|
-
req.mcpSession = session;
|
|
253
|
-
next();
|
|
254
|
-
};
|
|
255
|
-
};
|
|
256
|
-
return {
|
|
257
|
-
verifyToken,
|
|
258
|
-
verifyRequest,
|
|
259
|
-
handler,
|
|
260
|
-
discoveryHandler,
|
|
261
|
-
protectedResourceHandler,
|
|
262
|
-
middleware,
|
|
263
|
-
authURL
|
|
264
|
-
};
|
|
265
|
-
}
|
|
266
|
-
//#endregion
|
|
267
|
-
export { createMcpResourceClient, makeDpopWWWAuthenticate };
|