@decocms/runtime 1.0.0-alpha.22 → 1.0.0-alpha.23
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/package.json +1 -1
- package/src/index.ts +32 -1
- package/src/oauth.ts +229 -0
- package/src/tools.ts +65 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { decodeJwt } from "jose";
|
|
|
3
3
|
import type { z } from "zod";
|
|
4
4
|
import { createContractBinding, createIntegrationBinding } from "./bindings.ts";
|
|
5
5
|
import { type CORSOptions, handlePreflight, withCORS } from "./cors.ts";
|
|
6
|
+
import { createOAuthHandlers } from "./oauth.ts";
|
|
6
7
|
import { State } from "./state.ts";
|
|
7
8
|
import {
|
|
8
9
|
createMCPServer,
|
|
@@ -233,6 +234,8 @@ export const withRuntime = <TEnv, TSchema extends z.ZodTypeAny = never>(
|
|
|
233
234
|
) => {
|
|
234
235
|
const server = createMCPServer<TEnv, TSchema>(userFns);
|
|
235
236
|
const corsOptions = userFns.cors;
|
|
237
|
+
const oauth = userFns.oauth;
|
|
238
|
+
const oauthHandlers = oauth ? createOAuthHandlers(oauth) : null;
|
|
236
239
|
|
|
237
240
|
const fetcher = async (
|
|
238
241
|
req: Request,
|
|
@@ -240,7 +243,35 @@ export const withRuntime = <TEnv, TSchema extends z.ZodTypeAny = never>(
|
|
|
240
243
|
ctx: any,
|
|
241
244
|
) => {
|
|
242
245
|
const url = new URL(req.url);
|
|
246
|
+
|
|
247
|
+
// OAuth routes (when configured)
|
|
248
|
+
if (oauthHandlers) {
|
|
249
|
+
// Protected resource metadata (RFC9728) - both paths MUST be supported
|
|
250
|
+
if (
|
|
251
|
+
url.pathname === "/.well-known/oauth-protected-resource" ||
|
|
252
|
+
url.pathname === "/mcp/.well-known/oauth-protected-resource"
|
|
253
|
+
) {
|
|
254
|
+
return oauthHandlers.handleProtectedResourceMetadata(req);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// OAuth callback - receives code from external OAuth provider
|
|
258
|
+
if (url.pathname === "/oauth/callback") {
|
|
259
|
+
return oauthHandlers.handleOAuthCallback(req);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Dynamic client registration (RFC7591)
|
|
263
|
+
if (url.pathname === "/mcp/register" && req.method === "POST") {
|
|
264
|
+
return oauthHandlers.handleClientRegistration(req);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// MCP endpoint
|
|
243
269
|
if (url.pathname === "/mcp") {
|
|
270
|
+
// If OAuth is configured, require authentication
|
|
271
|
+
if (oauthHandlers && !oauthHandlers.hasAuth(req)) {
|
|
272
|
+
return oauthHandlers.createUnauthorizedResponse(req);
|
|
273
|
+
}
|
|
274
|
+
|
|
244
275
|
return server.fetch(req, env, ctx);
|
|
245
276
|
}
|
|
246
277
|
|
|
@@ -273,7 +304,7 @@ export const withRuntime = <TEnv, TSchema extends z.ZodTypeAny = never>(
|
|
|
273
304
|
};
|
|
274
305
|
|
|
275
306
|
return {
|
|
276
|
-
fetch: async (req: Request, env: TEnv & DefaultEnv<TSchema>, ctx
|
|
307
|
+
fetch: async (req: Request, env: TEnv & DefaultEnv<TSchema>, ctx?: any) => {
|
|
277
308
|
// Handle CORS preflight (OPTIONS) requests
|
|
278
309
|
if (corsOptions !== false && req.method === "OPTIONS") {
|
|
279
310
|
const options = corsOptions ?? {};
|
package/src/oauth.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import type { OAuthClient, OAuthConfig, OAuthParams } from "./tools.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate a cryptographically secure random token
|
|
5
|
+
*/
|
|
6
|
+
function generateRandomToken(length = 32): string {
|
|
7
|
+
const chars =
|
|
8
|
+
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
9
|
+
const array = new Uint8Array(length);
|
|
10
|
+
crypto.getRandomValues(array);
|
|
11
|
+
return Array.from(array, (byte) => chars[byte % chars.length]).join("");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Validate redirect URI format per OAuth 2.1
|
|
16
|
+
*/
|
|
17
|
+
function isValidRedirectUri(uri: string): boolean {
|
|
18
|
+
try {
|
|
19
|
+
const url = new URL(uri);
|
|
20
|
+
return (
|
|
21
|
+
url.protocol === "https:" ||
|
|
22
|
+
url.hostname === "localhost" ||
|
|
23
|
+
url.hostname === "127.0.0.1" ||
|
|
24
|
+
// Allow custom schemes for native apps (e.g., cursor://, vscode://)
|
|
25
|
+
!url.protocol.startsWith("http")
|
|
26
|
+
);
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Create OAuth endpoint handlers for MCP servers
|
|
34
|
+
* Per MCP Authorization spec: https://modelcontextprotocol.io/specification/draft/basic/authorization
|
|
35
|
+
*/
|
|
36
|
+
export function createOAuthHandlers(oauth: OAuthConfig) {
|
|
37
|
+
/**
|
|
38
|
+
* Build OAuth 2.0 Protected Resource Metadata (RFC9728)
|
|
39
|
+
* Per MCP spec, this MUST point to the external authorization server
|
|
40
|
+
*/
|
|
41
|
+
const handleProtectedResourceMetadata = (req: Request): Response => {
|
|
42
|
+
const url = new URL(req.url);
|
|
43
|
+
const resourceUrl = `${url.origin}/mcp`;
|
|
44
|
+
|
|
45
|
+
return Response.json({
|
|
46
|
+
resource: resourceUrl,
|
|
47
|
+
authorization_servers: [oauth.authorizationServer],
|
|
48
|
+
scopes_supported: ["*"],
|
|
49
|
+
bearer_methods_supported: ["header"],
|
|
50
|
+
resource_signing_alg_values_supported: ["RS256", "none"],
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Handle OAuth callback - receives code from external OAuth provider
|
|
56
|
+
*/
|
|
57
|
+
const handleOAuthCallback = async (req: Request): Promise<Response> => {
|
|
58
|
+
const url = new URL(req.url);
|
|
59
|
+
const code = url.searchParams.get("code");
|
|
60
|
+
const codeVerifier = url.searchParams.get("code_verifier") ?? undefined;
|
|
61
|
+
const codeChallengeMethod = url.searchParams.get("code_challenge_method") as
|
|
62
|
+
| "S256"
|
|
63
|
+
| "plain"
|
|
64
|
+
| undefined;
|
|
65
|
+
|
|
66
|
+
if (!code) {
|
|
67
|
+
return Response.json(
|
|
68
|
+
{
|
|
69
|
+
error: "invalid_request",
|
|
70
|
+
error_description: "Missing code parameter",
|
|
71
|
+
},
|
|
72
|
+
{ status: 400 },
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const oauthParams: OAuthParams = {
|
|
78
|
+
code,
|
|
79
|
+
code_verifier: codeVerifier,
|
|
80
|
+
code_challenge_method: codeChallengeMethod,
|
|
81
|
+
};
|
|
82
|
+
const tokenResponse = await oauth.exchangeCode(oauthParams);
|
|
83
|
+
|
|
84
|
+
return Response.json(tokenResponse, {
|
|
85
|
+
headers: {
|
|
86
|
+
"Cache-Control": "no-store",
|
|
87
|
+
Pragma: "no-cache",
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
} catch (error) {
|
|
91
|
+
console.error("OAuth code exchange error:", error);
|
|
92
|
+
return Response.json(
|
|
93
|
+
{
|
|
94
|
+
error: "invalid_grant",
|
|
95
|
+
error_description: "Failed to exchange authorization code",
|
|
96
|
+
},
|
|
97
|
+
{ status: 400 },
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Handle dynamic client registration (RFC7591)
|
|
104
|
+
*/
|
|
105
|
+
const handleClientRegistration = async (req: Request): Promise<Response> => {
|
|
106
|
+
try {
|
|
107
|
+
const body = (await req.json()) as {
|
|
108
|
+
redirect_uris?: string[];
|
|
109
|
+
client_name?: string;
|
|
110
|
+
grant_types?: string[];
|
|
111
|
+
response_types?: string[];
|
|
112
|
+
token_endpoint_auth_method?: string;
|
|
113
|
+
scope?: string;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// Validate redirect URIs
|
|
117
|
+
if (!body.redirect_uris || body.redirect_uris.length === 0) {
|
|
118
|
+
return Response.json(
|
|
119
|
+
{
|
|
120
|
+
error: "invalid_redirect_uri",
|
|
121
|
+
error_description: "At least one redirect_uri is required",
|
|
122
|
+
},
|
|
123
|
+
{ status: 400 },
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
for (const uri of body.redirect_uris) {
|
|
128
|
+
if (!isValidRedirectUri(uri)) {
|
|
129
|
+
return Response.json(
|
|
130
|
+
{
|
|
131
|
+
error: "invalid_redirect_uri",
|
|
132
|
+
error_description: `Invalid redirect URI: ${uri}`,
|
|
133
|
+
},
|
|
134
|
+
{ status: 400 },
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const clientId = generateRandomToken(32);
|
|
140
|
+
const clientSecret =
|
|
141
|
+
body.token_endpoint_auth_method !== "none"
|
|
142
|
+
? generateRandomToken(32)
|
|
143
|
+
: undefined;
|
|
144
|
+
const now = Math.floor(Date.now() / 1000);
|
|
145
|
+
|
|
146
|
+
const client: OAuthClient = {
|
|
147
|
+
client_id: clientId,
|
|
148
|
+
client_secret: clientSecret,
|
|
149
|
+
client_name: body.client_name,
|
|
150
|
+
redirect_uris: body.redirect_uris,
|
|
151
|
+
grant_types: body.grant_types ?? ["authorization_code"],
|
|
152
|
+
response_types: body.response_types ?? ["code"],
|
|
153
|
+
token_endpoint_auth_method:
|
|
154
|
+
body.token_endpoint_auth_method ?? "client_secret_post",
|
|
155
|
+
scope: body.scope,
|
|
156
|
+
client_id_issued_at: now,
|
|
157
|
+
client_secret_expires_at: 0,
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// Save client if persistence is provided
|
|
161
|
+
if (oauth.persistence) {
|
|
162
|
+
await oauth.persistence.saveClient(client);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return new Response(JSON.stringify(client), {
|
|
166
|
+
status: 201,
|
|
167
|
+
headers: {
|
|
168
|
+
"Content-Type": "application/json",
|
|
169
|
+
"Cache-Control": "no-store",
|
|
170
|
+
Pragma: "no-cache",
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
} catch (error) {
|
|
174
|
+
console.error("Client registration error:", error);
|
|
175
|
+
return Response.json(
|
|
176
|
+
{
|
|
177
|
+
error: "invalid_client_metadata",
|
|
178
|
+
error_description: "Invalid client registration request",
|
|
179
|
+
},
|
|
180
|
+
{ status: 400 },
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Return 401 with WWW-Authenticate header for unauthenticated MCP requests
|
|
187
|
+
* Per MCP spec: MUST include resource_metadata URL
|
|
188
|
+
*/
|
|
189
|
+
const createUnauthorizedResponse = (req: Request): Response => {
|
|
190
|
+
const url = new URL(req.url);
|
|
191
|
+
const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource`;
|
|
192
|
+
const wwwAuthenticateValue = `Bearer resource_metadata="${resourceMetadataUrl}", scope="*"`;
|
|
193
|
+
|
|
194
|
+
return Response.json(
|
|
195
|
+
{
|
|
196
|
+
jsonrpc: "2.0",
|
|
197
|
+
error: {
|
|
198
|
+
code: -32000,
|
|
199
|
+
message: "Unauthorized: Authentication required",
|
|
200
|
+
},
|
|
201
|
+
id: null,
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
status: 401,
|
|
205
|
+
headers: {
|
|
206
|
+
"WWW-Authenticate": wwwAuthenticateValue,
|
|
207
|
+
"Access-Control-Expose-Headers": "WWW-Authenticate",
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Check if request has authentication token
|
|
215
|
+
*/
|
|
216
|
+
const hasAuth = (req: Request): boolean => {
|
|
217
|
+
const authHeader = req.headers.get("Authorization");
|
|
218
|
+
const meshToken = req.headers.get("x-mesh-token");
|
|
219
|
+
return !!(authHeader || meshToken);
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
handleProtectedResourceMetadata,
|
|
224
|
+
handleOAuthCallback,
|
|
225
|
+
handleClientRegistration,
|
|
226
|
+
createUnauthorizedResponse,
|
|
227
|
+
hasAuth,
|
|
228
|
+
};
|
|
229
|
+
}
|
package/src/tools.ts
CHANGED
|
@@ -149,11 +149,76 @@ export interface OnChangeCallback<TSchema extends z.ZodTypeAny = never> {
|
|
|
149
149
|
state: z.infer<TSchema>;
|
|
150
150
|
scopes: string[];
|
|
151
151
|
}
|
|
152
|
+
|
|
153
|
+
export interface OAuthParams {
|
|
154
|
+
code: string;
|
|
155
|
+
code_verifier?: string;
|
|
156
|
+
code_challenge_method?: "S256" | "plain";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface OAuthTokenResponse {
|
|
160
|
+
access_token: string;
|
|
161
|
+
token_type: string;
|
|
162
|
+
expires_in?: number;
|
|
163
|
+
refresh_token?: string;
|
|
164
|
+
scope?: string;
|
|
165
|
+
[key: string]: unknown;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* OAuth client for dynamic client registration (RFC7591)
|
|
170
|
+
*/
|
|
171
|
+
export interface OAuthClient {
|
|
172
|
+
client_id: string;
|
|
173
|
+
client_secret?: string;
|
|
174
|
+
client_name?: string;
|
|
175
|
+
redirect_uris: string[];
|
|
176
|
+
grant_types?: string[];
|
|
177
|
+
response_types?: string[];
|
|
178
|
+
token_endpoint_auth_method?: string;
|
|
179
|
+
scope?: string;
|
|
180
|
+
client_id_issued_at?: number;
|
|
181
|
+
client_secret_expires_at?: number;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* OAuth configuration for MCP servers implementing PKCE flow
|
|
186
|
+
* Per MCP Authorization spec: https://modelcontextprotocol.io/specification/draft/basic/authorization
|
|
187
|
+
*/
|
|
188
|
+
export interface OAuthConfig {
|
|
189
|
+
mode: "PKCE";
|
|
190
|
+
/**
|
|
191
|
+
* The external authorization server URL (e.g., "https://openrouter.ai")
|
|
192
|
+
* Used in protected resource metadata to indicate where clients should authenticate
|
|
193
|
+
*/
|
|
194
|
+
authorizationServer: string;
|
|
195
|
+
/**
|
|
196
|
+
* Generates the authorization URL where users should be redirected
|
|
197
|
+
* @param callbackUrl - The URL the OAuth provider will redirect back to with the code
|
|
198
|
+
* @returns The full authorization URL to redirect the user to
|
|
199
|
+
*/
|
|
200
|
+
authorizationUrl: (callbackUrl: string) => string;
|
|
201
|
+
/**
|
|
202
|
+
* Exchanges the authorization code for access tokens
|
|
203
|
+
* Called when the OAuth callback is received with a code
|
|
204
|
+
*/
|
|
205
|
+
exchangeCode: (oauthParams: OAuthParams) => Promise<OAuthTokenResponse>;
|
|
206
|
+
/**
|
|
207
|
+
* Optional: persistence for dynamic client registration (RFC7591)
|
|
208
|
+
* If not provided, clients are accepted without validation
|
|
209
|
+
*/
|
|
210
|
+
persistence?: {
|
|
211
|
+
getClient: (clientId: string) => Promise<OAuthClient | null>;
|
|
212
|
+
saveClient: (client: OAuthClient) => Promise<void>;
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
152
216
|
export interface CreateMCPServerOptions<
|
|
153
217
|
Env = unknown,
|
|
154
218
|
TSchema extends z.ZodTypeAny = never,
|
|
155
219
|
> {
|
|
156
220
|
before?: (env: Env & DefaultEnv<TSchema>) => Promise<void> | void;
|
|
221
|
+
oauth?: OAuthConfig;
|
|
157
222
|
configuration?: {
|
|
158
223
|
onChange?: (
|
|
159
224
|
env: Env & DefaultEnv<TSchema>,
|