@better-auth/mcp 1.5.0-beta.9 → 1.7.0-beta.7
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 +21 -0
- package/dist/client/adapters.d.mts +56 -0
- package/dist/client/adapters.mjs +126 -0
- package/dist/client/index.d.mts +52 -0
- package/dist/client/index.mjs +244 -0
- package/dist/index.d.mts +138 -1
- package/dist/index.mjs +203 -1025
- package/package.json +64 -28
package/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# @better-auth/mcp
|
|
2
|
+
|
|
3
|
+
Model Context Protocol (MCP) plugin for [Better Auth](https://www.better-auth.com).
|
|
4
|
+
|
|
5
|
+
`mcp()` turns your Better Auth app into an OAuth 2.1 authorization server for MCP
|
|
6
|
+
clients, built on [`@better-auth/oauth-provider`](https://www.better-auth.com/docs/plugins/oauth-provider).
|
|
7
|
+
It serves the RFC 9728 protected resource metadata so MCP clients can discover it.
|
|
8
|
+
To protect an MCP route, wrap its handler with `requireMcpAuth` (or `mcpHandler`),
|
|
9
|
+
which verifies bearer tokens against the published JWKS.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { betterAuth } from "better-auth";
|
|
13
|
+
import { jwt } from "better-auth/plugins";
|
|
14
|
+
import { mcp } from "@better-auth/mcp";
|
|
15
|
+
|
|
16
|
+
export const auth = betterAuth({
|
|
17
|
+
plugins: [jwt(), mcp({ loginPage: "/login", consentPage: "/consent" })],
|
|
18
|
+
});
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
See the [MCP plugin documentation](https://www.better-auth.com/docs/plugins/mcp).
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { McpResourceClient, McpResourceClientOptions, McpSession } from "./index.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/client/adapters.d.ts
|
|
4
|
+
interface HonoContext {
|
|
5
|
+
req: {
|
|
6
|
+
header: (name: string) => string | undefined;
|
|
7
|
+
raw: Request;
|
|
8
|
+
};
|
|
9
|
+
set: (key: string, value: unknown) => void;
|
|
10
|
+
json: (data: unknown, status?: number, headers?: Record<string, string>) => Response;
|
|
11
|
+
header: (name: string, value: string) => void;
|
|
12
|
+
}
|
|
13
|
+
type HonoNext = () => Promise<void>;
|
|
14
|
+
type HonoMiddleware = (c: HonoContext, next: HonoNext) => Promise<Response | void>;
|
|
15
|
+
interface HonoApp {
|
|
16
|
+
get: (path: string, handler: (c: HonoContext) => Promise<Response>) => void;
|
|
17
|
+
}
|
|
18
|
+
declare function mcpAuthHono(options: McpResourceClientOptions): {
|
|
19
|
+
client: McpResourceClient;
|
|
20
|
+
middleware: HonoMiddleware;
|
|
21
|
+
discoveryRoutes: (app: HonoApp, serverURL: string) => void;
|
|
22
|
+
};
|
|
23
|
+
declare function mcpAuthOfficial(options: McpResourceClientOptions): {
|
|
24
|
+
client: McpResourceClient;
|
|
25
|
+
handler: McpResourceClient["handler"];
|
|
26
|
+
verifyToken: McpResourceClient["verifyToken"];
|
|
27
|
+
};
|
|
28
|
+
type OAuthMode = "direct" | "proxy";
|
|
29
|
+
interface McpUseUserInfo {
|
|
30
|
+
userId: string;
|
|
31
|
+
roles?: string[];
|
|
32
|
+
permissions?: string[];
|
|
33
|
+
scopes?: string;
|
|
34
|
+
clientId?: string;
|
|
35
|
+
[key: string]: unknown;
|
|
36
|
+
}
|
|
37
|
+
interface OAuthProvider {
|
|
38
|
+
verifyToken(token: string): Promise<{
|
|
39
|
+
payload: Record<string, unknown>;
|
|
40
|
+
}>;
|
|
41
|
+
getUserInfo(payload: Record<string, unknown>): McpUseUserInfo;
|
|
42
|
+
getIssuer(): string;
|
|
43
|
+
getAuthEndpoint(): string;
|
|
44
|
+
getTokenEndpoint(): string;
|
|
45
|
+
getScopesSupported(): string[];
|
|
46
|
+
getGrantTypesSupported(): string[];
|
|
47
|
+
getMode(): OAuthMode;
|
|
48
|
+
getRegistrationEndpoint?(): string;
|
|
49
|
+
}
|
|
50
|
+
interface McpUseBetterAuthConfig {
|
|
51
|
+
authURL: string;
|
|
52
|
+
getUserInfo?: (payload: Record<string, unknown>) => McpUseUserInfo;
|
|
53
|
+
}
|
|
54
|
+
declare function mcpAuthMcpUse(config: McpUseBetterAuthConfig): OAuthProvider;
|
|
55
|
+
//#endregion
|
|
56
|
+
export { type McpResourceClient, type McpResourceClientOptions, type McpSession, McpUseBetterAuthConfig, mcpAuthHono, mcpAuthMcpUse, mcpAuthOfficial };
|
|
@@ -0,0 +1,126 @@
|
|
|
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
|
+
return `${PROTECTED_RESOURCE_METADATA_PATH}${resourceUrl.pathname === "/" ? "" : resourceUrl.pathname.replace(/\/$/, "")}`;
|
|
9
|
+
}
|
|
10
|
+
function getProtectedResourceMetadataURL(resource) {
|
|
11
|
+
const resourceUrl = new URL(resource);
|
|
12
|
+
if (resourceUrl.origin === "null") throw new Error("MCP resource_metadata requires an origin-based resource URL");
|
|
13
|
+
return `${resourceUrl.origin}${getProtectedResourceMetadataPath(resource)}${resourceUrl.search}`;
|
|
14
|
+
}
|
|
15
|
+
function mcpAuthHono(options) {
|
|
16
|
+
const client = createMcpResourceClient(options);
|
|
17
|
+
const resourceMetadata = getProtectedResourceMetadataURL(options.resource ?? client.authURL);
|
|
18
|
+
const dpopChallenge = makeDpopWWWAuthenticate(options.dpop?.signingAlgorithms ?? DPOP_SIGNING_ALGORITHMS);
|
|
19
|
+
const unauthorized = (c, challenge, message) => {
|
|
20
|
+
c.header("WWW-Authenticate", challenge);
|
|
21
|
+
return c.json({
|
|
22
|
+
jsonrpc: "2.0",
|
|
23
|
+
error: {
|
|
24
|
+
code: -32e3,
|
|
25
|
+
message
|
|
26
|
+
},
|
|
27
|
+
id: null
|
|
28
|
+
}, 401);
|
|
29
|
+
};
|
|
30
|
+
const middleware = async (c, next) => {
|
|
31
|
+
const authHeader = c.req.header("Authorization");
|
|
32
|
+
let session;
|
|
33
|
+
try {
|
|
34
|
+
session = await client.verifyRequest(c.req.raw);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (isDpopBindingError(error)) return unauthorized(c, dpopChallenge, "Invalid or expired token");
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
if (!session) return unauthorized(c, parseAccessTokenAuthorization(authHeader)?.scheme === "DPoP" || !!c.req.header("DPoP") ? dpopChallenge : `Bearer resource_metadata="${resourceMetadata}"`, authHeader ? "Invalid or expired token" : "Unauthorized: Authentication required");
|
|
40
|
+
c.set("mcpSession", session);
|
|
41
|
+
await next();
|
|
42
|
+
};
|
|
43
|
+
const discoveryRoutes = (app, serverURL) => {
|
|
44
|
+
const discoveryFn = client.discoveryHandler();
|
|
45
|
+
const protectedResourceFn = client.protectedResourceHandler(serverURL);
|
|
46
|
+
const protectedResourcePaths = new Set([PROTECTED_RESOURCE_METADATA_PATH, getProtectedResourceMetadataPath(options.resource ?? client.authURL)]);
|
|
47
|
+
app.get("/.well-known/oauth-authorization-server", async (c) => {
|
|
48
|
+
const response = await discoveryFn(c.req.raw);
|
|
49
|
+
const data = await response.json().catch(() => ({ error: "Invalid response from auth server" }));
|
|
50
|
+
return c.json(data, response.status);
|
|
51
|
+
});
|
|
52
|
+
for (const path of protectedResourcePaths) app.get(path, async (c) => {
|
|
53
|
+
const response = await protectedResourceFn(c.req.raw);
|
|
54
|
+
const data = await response.json().catch(() => ({ error: "Invalid response from auth server" }));
|
|
55
|
+
return c.json(data, response.status);
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
return {
|
|
59
|
+
client,
|
|
60
|
+
middleware,
|
|
61
|
+
discoveryRoutes
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function mcpAuthOfficial(options) {
|
|
65
|
+
const client = createMcpResourceClient(options);
|
|
66
|
+
return {
|
|
67
|
+
client,
|
|
68
|
+
handler: client.handler,
|
|
69
|
+
verifyToken: client.verifyToken
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function mcpAuthMcpUse(config) {
|
|
73
|
+
const authURL = normalizeURL(config.authURL);
|
|
74
|
+
if (!authURL) throw new Error("Better Auth authURL is required. Pass authURL in config, e.g.: mcpAuthMcpUse({ authURL: 'http://localhost:3000/api/auth' })");
|
|
75
|
+
const client = createMcpResourceClient({ authURL });
|
|
76
|
+
return {
|
|
77
|
+
async verifyToken(token) {
|
|
78
|
+
const session = await client.verifyToken(token);
|
|
79
|
+
if (!session) throw new Error("Invalid or expired token");
|
|
80
|
+
return { payload: session };
|
|
81
|
+
},
|
|
82
|
+
getUserInfo(payload) {
|
|
83
|
+
if (config.getUserInfo) return config.getUserInfo(payload);
|
|
84
|
+
const scopes = typeof payload.scope === "string" ? payload.scope.split(" ") : [];
|
|
85
|
+
return {
|
|
86
|
+
userId: payload.sub,
|
|
87
|
+
roles: [],
|
|
88
|
+
permissions: scopes,
|
|
89
|
+
scopes: payload.scope,
|
|
90
|
+
clientId: payload.azp ?? payload.client_id
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
getIssuer() {
|
|
94
|
+
return authURL;
|
|
95
|
+
},
|
|
96
|
+
getAuthEndpoint() {
|
|
97
|
+
return `${authURL}/oauth2/authorize`;
|
|
98
|
+
},
|
|
99
|
+
getTokenEndpoint() {
|
|
100
|
+
return `${authURL}/oauth2/token`;
|
|
101
|
+
},
|
|
102
|
+
getScopesSupported() {
|
|
103
|
+
return [
|
|
104
|
+
"openid",
|
|
105
|
+
"profile",
|
|
106
|
+
"email",
|
|
107
|
+
"offline_access"
|
|
108
|
+
];
|
|
109
|
+
},
|
|
110
|
+
getGrantTypesSupported() {
|
|
111
|
+
return ["authorization_code", "refresh_token"];
|
|
112
|
+
},
|
|
113
|
+
getMode() {
|
|
114
|
+
return "direct";
|
|
115
|
+
},
|
|
116
|
+
getRegistrationEndpoint() {
|
|
117
|
+
return `${authURL}/oauth2/register`;
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function normalizeURL(url) {
|
|
122
|
+
if (!url || url.trim() === "") return void 0;
|
|
123
|
+
return url.endsWith("/") ? url.slice(0, -1) : url;
|
|
124
|
+
}
|
|
125
|
+
//#endregion
|
|
126
|
+
export { mcpAuthHono, mcpAuthMcpUse, mcpAuthOfficial };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { VerifyAccessTokenRequestOptions } from "better-auth/oauth2";
|
|
2
|
+
import { JWTPayload } from "jose";
|
|
3
|
+
|
|
4
|
+
//#region src/client/index.d.ts
|
|
5
|
+
interface McpResourceClientOptions {
|
|
6
|
+
authURL: string;
|
|
7
|
+
resource?: string;
|
|
8
|
+
allowedOrigin?: string;
|
|
9
|
+
fetch?: typeof globalThis.fetch;
|
|
10
|
+
dpop?: VerifyAccessTokenRequestOptions["dpop"];
|
|
11
|
+
}
|
|
12
|
+
interface McpSession extends JWTPayload {
|
|
13
|
+
sub?: string;
|
|
14
|
+
scope?: string;
|
|
15
|
+
client_id?: string;
|
|
16
|
+
}
|
|
17
|
+
interface NodeLikeRequest {
|
|
18
|
+
headers: Record<string, string | string[] | undefined> & {
|
|
19
|
+
get?: (name: string) => string | undefined;
|
|
20
|
+
authorization?: string;
|
|
21
|
+
host?: string;
|
|
22
|
+
"x-forwarded-proto"?: string;
|
|
23
|
+
};
|
|
24
|
+
get?: (name: string) => string | undefined;
|
|
25
|
+
method?: string;
|
|
26
|
+
originalUrl?: string;
|
|
27
|
+
protocol?: string;
|
|
28
|
+
url?: string;
|
|
29
|
+
mcpSession?: McpSession;
|
|
30
|
+
}
|
|
31
|
+
interface NodeLikeResponse {
|
|
32
|
+
set?: (name: string, value: string) => void;
|
|
33
|
+
setHeader?: (name: string, value: string) => void;
|
|
34
|
+
status?: (code: number) => {
|
|
35
|
+
json: (body: unknown) => void;
|
|
36
|
+
};
|
|
37
|
+
writeHead?: (code: number, headers: Record<string, string>) => void;
|
|
38
|
+
end?: (body: string) => void;
|
|
39
|
+
}
|
|
40
|
+
interface McpResourceClient {
|
|
41
|
+
verifyToken: (token: string) => Promise<McpSession | null>;
|
|
42
|
+
verifyRequest: (req: Request) => Promise<McpSession | null>;
|
|
43
|
+
handler: (fn: (req: Request, session: McpSession) => Response | Promise<Response>) => (req: Request) => Promise<Response>;
|
|
44
|
+
discoveryHandler: () => (req: Request) => Promise<Response>;
|
|
45
|
+
protectedResourceHandler: (serverURL: string) => (req: Request) => Promise<Response>;
|
|
46
|
+
middleware: () => (req: NodeLikeRequest, res: NodeLikeResponse, next: () => void) => Promise<void>;
|
|
47
|
+
authURL: string;
|
|
48
|
+
}
|
|
49
|
+
declare function makeDpopWWWAuthenticate(algorithms: readonly string[]): string;
|
|
50
|
+
declare function createMcpResourceClient(options: McpResourceClientOptions): McpResourceClient;
|
|
51
|
+
//#endregion
|
|
52
|
+
export { McpResourceClient, McpResourceClientOptions, McpSession, createMcpResourceClient, makeDpopWWWAuthenticate };
|
|
@@ -0,0 +1,244 @@
|
|
|
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 make401Response(wwwAuth) {
|
|
34
|
+
return Response.json({
|
|
35
|
+
jsonrpc: "2.0",
|
|
36
|
+
error: {
|
|
37
|
+
code: -32e3,
|
|
38
|
+
message: "Unauthorized: Authentication required",
|
|
39
|
+
"www-authenticate": wwwAuth
|
|
40
|
+
},
|
|
41
|
+
id: null
|
|
42
|
+
}, {
|
|
43
|
+
status: 401,
|
|
44
|
+
headers: { "WWW-Authenticate": wwwAuth }
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
function send401Node(res, wwwAuth, message) {
|
|
48
|
+
const body = JSON.stringify({
|
|
49
|
+
jsonrpc: "2.0",
|
|
50
|
+
error: {
|
|
51
|
+
code: -32e3,
|
|
52
|
+
message
|
|
53
|
+
},
|
|
54
|
+
id: null
|
|
55
|
+
});
|
|
56
|
+
if (typeof res.set === "function") {
|
|
57
|
+
res.set("WWW-Authenticate", wwwAuth);
|
|
58
|
+
res.status?.(401).json(JSON.parse(body));
|
|
59
|
+
} else if (typeof res.writeHead === "function") {
|
|
60
|
+
res.writeHead(401, {
|
|
61
|
+
"Content-Type": "application/json",
|
|
62
|
+
"WWW-Authenticate": wwwAuth
|
|
63
|
+
});
|
|
64
|
+
res.end?.(body);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function createMcpResourceClient(options) {
|
|
68
|
+
const authURL = options.authURL.endsWith("/") ? options.authURL.slice(0, -1) : options.authURL;
|
|
69
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
70
|
+
const corsHeaders = buildCorsHeaders(authURL, options.allowedOrigin);
|
|
71
|
+
const expectedAudience = options.resource ?? authURL;
|
|
72
|
+
const dpopReplayStore = options.dpop?.replayStore ?? createInMemoryDpopReplayStore();
|
|
73
|
+
const dpopSigningAlgorithms = options.dpop?.signingAlgorithms ?? DPOP_SIGNING_ALGORITHMS;
|
|
74
|
+
const selectChallenge = (authHeader, dpopHeaderPresent) => {
|
|
75
|
+
return parseAccessTokenAuthorization(authHeader)?.scheme === "DPoP" || dpopHeaderPresent ? makeDpopWWWAuthenticate(dpopSigningAlgorithms) : makeWWWAuthenticate(authURL, options.resource);
|
|
76
|
+
};
|
|
77
|
+
let discovery = null;
|
|
78
|
+
let jwks = null;
|
|
79
|
+
const loadVerifier = async () => {
|
|
80
|
+
if (discovery && jwks) return {
|
|
81
|
+
discovery,
|
|
82
|
+
jwks
|
|
83
|
+
};
|
|
84
|
+
const response = await fetchFn(`${authURL}/.well-known/oauth-authorization-server`);
|
|
85
|
+
if (!response.ok) throw new Error("Failed to fetch discovery metadata");
|
|
86
|
+
const metadata = await response.json();
|
|
87
|
+
if (!metadata.jwks_uri || !metadata.issuer) throw new Error("Discovery metadata missing jwks_uri or issuer");
|
|
88
|
+
discovery = {
|
|
89
|
+
issuer: metadata.issuer,
|
|
90
|
+
jwks_uri: metadata.jwks_uri
|
|
91
|
+
};
|
|
92
|
+
jwks = createRemoteJWKSet(new URL(metadata.jwks_uri));
|
|
93
|
+
return {
|
|
94
|
+
discovery,
|
|
95
|
+
jwks
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
const verifyJwtToken = async (token) => {
|
|
99
|
+
try {
|
|
100
|
+
const { discovery: meta, jwks: keySet } = await loadVerifier();
|
|
101
|
+
const { payload } = await jwtVerify(token, keySet, {
|
|
102
|
+
issuer: meta.issuer,
|
|
103
|
+
audience: expectedAudience
|
|
104
|
+
});
|
|
105
|
+
return payload;
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
const verifyToken = async (token) => {
|
|
111
|
+
const session = await verifyJwtToken(token);
|
|
112
|
+
if (!session || getDpopJktFromPayload(session)) return null;
|
|
113
|
+
return session;
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* Verifies a request's access token and, when the token is DPoP-bound, its
|
|
117
|
+
* RFC 9449 sender-constraint. Returns `null` when there is no usable token
|
|
118
|
+
* or the JWT itself is invalid. Throws a `DpopBindingError` when a
|
|
119
|
+
* DPoP-bound token fails the binding check, so the caller can answer with a
|
|
120
|
+
* `WWW-Authenticate: DPoP` challenge rather than a bearer one.
|
|
121
|
+
*/
|
|
122
|
+
const verifyRequest = async (req) => {
|
|
123
|
+
const authorization = parseAccessTokenAuthorization(req.headers.get("Authorization"));
|
|
124
|
+
if (!authorization?.token || authorization.scheme === "Unknown") return null;
|
|
125
|
+
const session = await verifyJwtToken(authorization.token);
|
|
126
|
+
if (!session) return null;
|
|
127
|
+
await enforceDpopBinding({
|
|
128
|
+
payload: session,
|
|
129
|
+
authorization,
|
|
130
|
+
proofJwt: req.headers.get("DPoP"),
|
|
131
|
+
method: req.method,
|
|
132
|
+
url: req.url,
|
|
133
|
+
proofMaxAgeSeconds: options.dpop?.proofMaxAgeSeconds,
|
|
134
|
+
signingAlgorithms: dpopSigningAlgorithms,
|
|
135
|
+
replayStore: dpopReplayStore
|
|
136
|
+
});
|
|
137
|
+
return session;
|
|
138
|
+
};
|
|
139
|
+
const getHeader = (req, name) => {
|
|
140
|
+
const lower = name.toLowerCase();
|
|
141
|
+
const value = req.headers?.[lower] ?? req.headers?.[name] ?? req.headers?.get?.(name) ?? req.get?.(name);
|
|
142
|
+
if (Array.isArray(value)) return value[0];
|
|
143
|
+
return value;
|
|
144
|
+
};
|
|
145
|
+
const getNodeRequestUrl = (req) => {
|
|
146
|
+
const rawUrl = req.originalUrl ?? req.url ?? "/";
|
|
147
|
+
if (URL.canParse(rawUrl)) return rawUrl;
|
|
148
|
+
const fallbackUrl = new URL(authURL);
|
|
149
|
+
const host = getHeader(req, "host") ?? fallbackUrl.host;
|
|
150
|
+
return `${getHeader(req, "x-forwarded-proto")?.split(",")[0]?.trim() ?? req.protocol ?? fallbackUrl.protocol.replace(":", "")}://${host}${rawUrl.startsWith("/") ? rawUrl : `/${rawUrl}`}`;
|
|
151
|
+
};
|
|
152
|
+
const handler = (fn) => {
|
|
153
|
+
return async (req) => {
|
|
154
|
+
if (req.method === "OPTIONS") return new Response(null, {
|
|
155
|
+
status: 204,
|
|
156
|
+
headers: corsHeaders
|
|
157
|
+
});
|
|
158
|
+
let session;
|
|
159
|
+
try {
|
|
160
|
+
session = await verifyRequest(req);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (isDpopBindingError(error)) return make401Response(makeDpopWWWAuthenticate(dpopSigningAlgorithms));
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
if (!session) return make401Response(selectChallenge(req.headers.get("Authorization"), req.headers.has("DPoP")));
|
|
166
|
+
return fn(req, session);
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
const discoveryHandler = () => {
|
|
170
|
+
let cachedMetadata = null;
|
|
171
|
+
let cacheTime = 0;
|
|
172
|
+
const CACHE_TTL = 6e4;
|
|
173
|
+
return async (_req) => {
|
|
174
|
+
const now = Date.now();
|
|
175
|
+
if (cachedMetadata && now - cacheTime < CACHE_TTL) return Response.json(cachedMetadata, { headers: corsHeaders });
|
|
176
|
+
try {
|
|
177
|
+
const response = await fetchFn(`${authURL}/.well-known/oauth-authorization-server`);
|
|
178
|
+
if (!response.ok) return Response.json({ error: "Failed to fetch discovery metadata" }, {
|
|
179
|
+
status: 502,
|
|
180
|
+
headers: corsHeaders
|
|
181
|
+
});
|
|
182
|
+
cachedMetadata = await response.json();
|
|
183
|
+
cacheTime = now;
|
|
184
|
+
return Response.json(cachedMetadata, { headers: corsHeaders });
|
|
185
|
+
} catch {
|
|
186
|
+
return Response.json({ error: "Better Auth server unreachable" }, {
|
|
187
|
+
status: 502,
|
|
188
|
+
headers: corsHeaders
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
const protectedResourceHandler = (serverURL) => {
|
|
194
|
+
const metadata = {
|
|
195
|
+
resource: options.resource ?? new URL(serverURL).origin,
|
|
196
|
+
authorization_servers: [authURL],
|
|
197
|
+
bearer_methods_supported: ["header"],
|
|
198
|
+
dpop_signing_alg_values_supported: [...dpopSigningAlgorithms]
|
|
199
|
+
};
|
|
200
|
+
return async (_req) => {
|
|
201
|
+
return Response.json(metadata, { headers: corsHeaders });
|
|
202
|
+
};
|
|
203
|
+
};
|
|
204
|
+
const middleware = () => {
|
|
205
|
+
return async (req, res, next) => {
|
|
206
|
+
const authHeader = getHeader(req, "authorization");
|
|
207
|
+
const requestHeaders = new Headers();
|
|
208
|
+
if (authHeader) requestHeaders.set("Authorization", authHeader);
|
|
209
|
+
const dpop = getHeader(req, "dpop");
|
|
210
|
+
if (dpop) requestHeaders.set("DPoP", dpop);
|
|
211
|
+
const request = new Request(getNodeRequestUrl(req), {
|
|
212
|
+
method: req.method ?? "GET",
|
|
213
|
+
headers: requestHeaders
|
|
214
|
+
});
|
|
215
|
+
let session;
|
|
216
|
+
try {
|
|
217
|
+
session = await verifyRequest(request);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
if (isDpopBindingError(error)) {
|
|
220
|
+
send401Node(res, makeDpopWWWAuthenticate(dpopSigningAlgorithms), "Invalid or expired token");
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
if (!session) {
|
|
226
|
+
send401Node(res, selectChallenge(authHeader, !!getHeader(req, "dpop")), authHeader ? "Invalid or expired token" : "Unauthorized: Authentication required");
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
req.mcpSession = session;
|
|
230
|
+
next();
|
|
231
|
+
};
|
|
232
|
+
};
|
|
233
|
+
return {
|
|
234
|
+
verifyToken,
|
|
235
|
+
verifyRequest,
|
|
236
|
+
handler,
|
|
237
|
+
discoveryHandler,
|
|
238
|
+
protectedResourceHandler,
|
|
239
|
+
middleware,
|
|
240
|
+
authURL
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
//#endregion
|
|
244
|
+
export { createMcpResourceClient, makeDpopWWWAuthenticate };
|
package/dist/index.d.mts
CHANGED
|
@@ -1 +1,138 @@
|
|
|
1
|
-
|
|
1
|
+
import { OAuthOptions, Scope, oauthProvider } from "@better-auth/oauth-provider";
|
|
2
|
+
import { DpopReplayReservations, DpopReplayStore, verifyAccessTokenRequest } from "better-auth/oauth2";
|
|
3
|
+
import { JWTPayload } from "jose";
|
|
4
|
+
import { Awaitable } from "@better-auth/core";
|
|
5
|
+
import { BetterAuthOptions } from "better-auth/types";
|
|
6
|
+
|
|
7
|
+
//#region src/handler.d.ts
|
|
8
|
+
/**
|
|
9
|
+
* A request middleware handler that verifies an MCP access token and responds
|
|
10
|
+
* with an RFC 9728 `WWW-Authenticate` header for unauthenticated requests.
|
|
11
|
+
*
|
|
12
|
+
* @external
|
|
13
|
+
*/
|
|
14
|
+
declare const mcpHandler: (/** Verifier options. `audience` must match the protected resource identifier. */
|
|
15
|
+
|
|
16
|
+
verifyOptions: Parameters<typeof verifyAccessTokenRequest>[1], handler: (req: Request, jwt: JWTPayload) => Awaitable<Response>, opts?: {
|
|
17
|
+
/** Maps non-url (ie urn, client) resources to resource_metadata */resourceMetadataMappings: Record<string, string>;
|
|
18
|
+
}) => (req: Request) => Promise<Response>;
|
|
19
|
+
//#endregion
|
|
20
|
+
//#region src/plugin.d.ts
|
|
21
|
+
/**
|
|
22
|
+
* Options for the {@link mcp} plugin: the full OAuth provider configuration plus
|
|
23
|
+
* the MCP resource identifier.
|
|
24
|
+
*/
|
|
25
|
+
interface McpOptions extends OAuthOptions<Scope[]> {
|
|
26
|
+
/**
|
|
27
|
+
* Seconds that a rotated refresh token can be reused to receive the same
|
|
28
|
+
* token response for the same effective scopes, requested resources, and
|
|
29
|
+
* sender constraint.
|
|
30
|
+
*
|
|
31
|
+
* MCP overrides the OAuth Provider default because native/public MCP clients
|
|
32
|
+
* can have multiple local sessions racing the same refresh token. Set to `0`
|
|
33
|
+
* to keep strict replay handling.
|
|
34
|
+
*
|
|
35
|
+
* @default 30
|
|
36
|
+
*/
|
|
37
|
+
refreshTokenReuseInterval?: OAuthOptions<Scope[]>["refreshTokenReuseInterval"];
|
|
38
|
+
/**
|
|
39
|
+
* The protected resource identifier (RFC 8707 / RFC 9728) that access tokens
|
|
40
|
+
* are bound to. Published as `resource` in the protected resource metadata,
|
|
41
|
+
* added to `resources`, and used as the expected token audience.
|
|
42
|
+
*/
|
|
43
|
+
resource: string;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Model Context Protocol authorization server.
|
|
47
|
+
*
|
|
48
|
+
* `mcp()` is the OAuth 2.1 / OIDC provider ({@link oauthProvider}) configured for
|
|
49
|
+
* MCP: it enables dynamic client registration, binds issued tokens to the MCP
|
|
50
|
+
* `resource`, and, as the resource server, serves the RFC 9728 protected resource
|
|
51
|
+
* metadata so MCP clients discover and use it through standard OAuth discovery.
|
|
52
|
+
* It also defaults `refreshTokenReuseInterval` to 30 seconds for native/public
|
|
53
|
+
* MCP clients that may retry a refresh after a local process loses the rotated
|
|
54
|
+
* response.
|
|
55
|
+
* Because it is the OAuth provider, it cannot be combined with a separate
|
|
56
|
+
* {@link oauthProvider}.
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```ts
|
|
60
|
+
* import { betterAuth } from "better-auth";
|
|
61
|
+
* import { jwt } from "better-auth/plugins";
|
|
62
|
+
* import { mcp } from "@better-auth/mcp";
|
|
63
|
+
*
|
|
64
|
+
* export const auth = betterAuth({
|
|
65
|
+
* plugins: [
|
|
66
|
+
* jwt(),
|
|
67
|
+
* mcp({
|
|
68
|
+
* loginPage: "/login",
|
|
69
|
+
* consentPage: "/consent",
|
|
70
|
+
* resource: "https://api.example.com/mcp",
|
|
71
|
+
* }),
|
|
72
|
+
* ],
|
|
73
|
+
* });
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
declare const mcp: (options: McpOptions) => ReturnType<typeof oauthProvider>;
|
|
77
|
+
//#endregion
|
|
78
|
+
//#region src/require-mcp-auth.d.ts
|
|
79
|
+
interface RequireMcpAuthOptions {
|
|
80
|
+
/**
|
|
81
|
+
* The protected resource identifier the access token must be bound to.
|
|
82
|
+
* Defaults to the server's resolved base URL.
|
|
83
|
+
*/
|
|
84
|
+
resource?: string;
|
|
85
|
+
/**
|
|
86
|
+
* Expected token issuer. Defaults to the server's resolved base URL. Override
|
|
87
|
+
* when the JWT plugin is configured with a custom `jwt.issuer`.
|
|
88
|
+
*/
|
|
89
|
+
issuer?: string;
|
|
90
|
+
/**
|
|
91
|
+
* URL of the authorization server's JWKS. Defaults to `/jwks` under the
|
|
92
|
+
* server's resolved base URL.
|
|
93
|
+
*/
|
|
94
|
+
jwksUrl?: string;
|
|
95
|
+
/**
|
|
96
|
+
* Space-delimited scopes to advertise in the `WWW-Authenticate` challenge
|
|
97
|
+
* (RFC 6750), hinting which scopes the client should request.
|
|
98
|
+
*/
|
|
99
|
+
scope?: string;
|
|
100
|
+
/**
|
|
101
|
+
* Maps a non-URL `resource` (an RFC 8707 `urn:` identifier or a client id) to
|
|
102
|
+
* the URL of its protected resource metadata. Required when `resource` is not
|
|
103
|
+
* an origin-based URL, so the `WWW-Authenticate` challenge can point at it.
|
|
104
|
+
*/
|
|
105
|
+
resourceMetadataMappings?: Record<string, string>;
|
|
106
|
+
/**
|
|
107
|
+
* DPoP proof validation settings. By default the replay store is backed by
|
|
108
|
+
* the auth instance's database adapter, so anti-replay holds across multiple
|
|
109
|
+
* server instances. Override `replayStore` only to point at a different store.
|
|
110
|
+
*/
|
|
111
|
+
dpop?: {
|
|
112
|
+
proofMaxAgeSeconds?: number;
|
|
113
|
+
signingAlgorithms?: readonly string[];
|
|
114
|
+
replayStore?: DpopReplayStore;
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Protects an MCP server route handler. Verifies the bearer access token
|
|
119
|
+
* against the authorization server's JWKS (checking signature, issuer,
|
|
120
|
+
* audience, and expiry) and forwards the verified JWT payload to the handler.
|
|
121
|
+
* Unauthenticated requests receive a JSON-RPC 401 with the RFC 9728
|
|
122
|
+
* `WWW-Authenticate` header so MCP clients can start the authorization flow.
|
|
123
|
+
*
|
|
124
|
+
* For a resource server that runs separately from the authorization server, or
|
|
125
|
+
* a server using a dynamic `baseURL`, use {@link mcpHandler} with explicit
|
|
126
|
+
* verification options instead.
|
|
127
|
+
*
|
|
128
|
+
* @external
|
|
129
|
+
*/
|
|
130
|
+
declare const requireMcpAuth: <Auth extends {
|
|
131
|
+
options: BetterAuthOptions;
|
|
132
|
+
$context: Promise<{
|
|
133
|
+
baseURL: string;
|
|
134
|
+
internalAdapter: DpopReplayReservations;
|
|
135
|
+
}>;
|
|
136
|
+
}>(auth: Auth, handler: (req: Request, jwt: JWTPayload) => Awaitable<Response>, opts?: RequireMcpAuthOptions) => (req: Request) => Promise<Response>;
|
|
137
|
+
//#endregion
|
|
138
|
+
export { type McpOptions, mcp, mcpHandler, requireMcpAuth };
|