@decocms/runtime 1.0.0-alpha.21 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/runtime",
3
- "version": "1.0.0-alpha.21",
3
+ "version": "1.0.0-alpha.23",
4
4
  "type": "module",
5
5
  "dependencies": {
6
6
  "@cloudflare/workers-types": "^4.20250617.0",
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: any) => {
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>,
@@ -181,7 +246,7 @@ export interface CreateMCPServerOptions<
181
246
  export type Fetch<TEnv = unknown> = (
182
247
  req: Request,
183
248
  env: TEnv,
184
- ctx: ExecutionContext,
249
+ ctx: any,
185
250
  ) => Promise<Response> | Response;
186
251
 
187
252
  export interface AppContext<TEnv extends DefaultEnv = DefaultEnv> {