@decocms/runtime 1.0.0-alpha.5 → 1.0.0
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/config-schema.json +19 -8
- package/package.json +11 -17
- package/src/asset-server/index.test.ts +306 -0
- package/src/asset-server/index.ts +217 -34
- package/src/bindings/binder.ts +2 -5
- package/src/bindings/index.ts +0 -33
- package/src/bindings/language-model/utils.ts +0 -91
- package/src/bindings.ts +146 -139
- package/src/client.ts +1 -145
- package/src/cors.ts +140 -0
- package/src/events.ts +472 -0
- package/src/index.ts +206 -202
- package/src/mcp.ts +7 -166
- package/src/oauth.ts +495 -0
- package/src/proxy.ts +1 -208
- package/src/state.ts +3 -31
- package/src/tools.ts +645 -0
- package/src/wrangler.ts +6 -5
- package/tsconfig.json +1 -1
- package/src/admin.ts +0 -16
- package/src/auth.ts +0 -233
- package/src/bindings/deconfig/helpers.ts +0 -107
- package/src/bindings/deconfig/index.ts +0 -1
- package/src/bindings/deconfig/resources.ts +0 -689
- package/src/bindings/deconfig/types.ts +0 -106
- package/src/bindings/language-model/ai-sdk.ts +0 -90
- package/src/bindings/language-model/index.ts +0 -4
- package/src/bindings/resources/bindings.ts +0 -99
- package/src/bindings/resources/helpers.ts +0 -95
- package/src/bindings/resources/schemas.ts +0 -265
- package/src/bindings/views.ts +0 -14
- package/src/drizzle.ts +0 -201
- package/src/http-client-transport.ts +0 -1
- package/src/mastra.ts +0 -670
- package/src/mcp-client.ts +0 -139
- package/src/resources.ts +0 -168
- package/src/views.ts +0 -26
- package/src/well-known.ts +0 -20
package/src/mcp.ts
CHANGED
|
@@ -1,170 +1,11 @@
|
|
|
1
1
|
/* oxlint-disable no-explicit-any */
|
|
2
|
-
import { z } from "zod";
|
|
3
|
-
import type { MCPConnection } from "./connection.ts";
|
|
4
|
-
import { createMCPClientProxy } from "./proxy.ts";
|
|
5
2
|
import type { ToolBinder } from "@decocms/bindings";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
sql_duration_ms: z.number().optional(),
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
const Meta = z.object({
|
|
17
|
-
changed_db: z.boolean().optional(),
|
|
18
|
-
changes: z.number().optional(),
|
|
19
|
-
duration: z.number().optional(),
|
|
20
|
-
last_row_id: z.number().optional(),
|
|
21
|
-
rows_read: z.number().optional(),
|
|
22
|
-
rows_written: z.number().optional(),
|
|
23
|
-
served_by_primary: z.boolean().optional(),
|
|
24
|
-
served_by_region: z
|
|
25
|
-
.enum(["WNAM", "ENAM", "WEUR", "EEUR", "APAC", "OC"])
|
|
26
|
-
.optional(),
|
|
27
|
-
size_after: z.number().optional(),
|
|
28
|
-
timings: Timings.optional(),
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
const QueryResult = z.object({
|
|
32
|
-
meta: Meta.optional(),
|
|
33
|
-
results: z.array(z.unknown()).optional(),
|
|
34
|
-
success: z.boolean().optional(),
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
export type QueryResult = z.infer<typeof QueryResult>;
|
|
38
|
-
|
|
39
|
-
const workspaceTools = [
|
|
40
|
-
{
|
|
41
|
-
name: "INTEGRATIONS_GET" as const,
|
|
42
|
-
inputSchema: z.object({
|
|
43
|
-
id: z.string(),
|
|
44
|
-
}),
|
|
45
|
-
outputSchema: z.object({
|
|
46
|
-
connection: z.object({}),
|
|
47
|
-
}),
|
|
48
|
-
},
|
|
49
|
-
{
|
|
50
|
-
name: "DATABASES_RUN_SQL" as const,
|
|
51
|
-
inputSchema: z.object({
|
|
52
|
-
sql: z.string().describe("The SQL query to run"),
|
|
53
|
-
params: z
|
|
54
|
-
.array(z.string())
|
|
55
|
-
.describe("The parameters to pass to the SQL query"),
|
|
56
|
-
}),
|
|
57
|
-
outputSchema: z.object({
|
|
58
|
-
result: z.array(QueryResult),
|
|
59
|
-
}),
|
|
60
|
-
},
|
|
61
|
-
] satisfies ToolBinder<string, unknown, object>[];
|
|
62
|
-
|
|
63
|
-
// Default fetcher instance with API_SERVER_URL and API_HEADERS
|
|
64
|
-
const global = createMCPFetchStub<[]>({});
|
|
65
|
-
export const MCPClient = new Proxy(
|
|
66
|
-
{} as typeof global & {
|
|
67
|
-
forWorkspace: (
|
|
68
|
-
workspace: string,
|
|
69
|
-
token?: string,
|
|
70
|
-
decoCmsApiUrl?: string,
|
|
71
|
-
) => MCPClientFetchStub<typeof workspaceTools>;
|
|
72
|
-
forConnection: <TDefinition extends readonly ToolBinder[]>(
|
|
73
|
-
connection: MCPConnectionProvider,
|
|
74
|
-
decoCmsApiUrl?: string,
|
|
75
|
-
) => MCPClientFetchStub<TDefinition>;
|
|
76
|
-
},
|
|
77
|
-
{
|
|
78
|
-
get(_, name) {
|
|
79
|
-
if (name === "toJSON") {
|
|
80
|
-
return null;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
if (name === "forWorkspace") {
|
|
84
|
-
return (workspace: string, token?: string, decoCmsApiUrl?: string) =>
|
|
85
|
-
createMCPFetchStub<[]>({
|
|
86
|
-
workspace,
|
|
87
|
-
token,
|
|
88
|
-
decoCmsApiUrl,
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
if (name === "forConnection") {
|
|
92
|
-
return <TDefinition extends readonly ToolBinder[]>(
|
|
93
|
-
connection: MCPConnectionProvider,
|
|
94
|
-
decoCmsApiUrl?: string,
|
|
95
|
-
) =>
|
|
96
|
-
createMCPFetchStub<TDefinition>({
|
|
97
|
-
connection,
|
|
98
|
-
decoCmsApiUrl,
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
return global[name as keyof typeof global];
|
|
102
|
-
},
|
|
103
|
-
},
|
|
104
|
-
);
|
|
3
|
+
export {
|
|
4
|
+
createMCPFetchStub,
|
|
5
|
+
MCPClient,
|
|
6
|
+
type CreateStubAPIOptions,
|
|
7
|
+
type MCPClientFetchStub,
|
|
8
|
+
type MCPClientStub,
|
|
9
|
+
} from "@decocms/bindings/client"; // Default fetcher instance with API_SERVER_URL and API_HEADERS
|
|
105
10
|
|
|
106
11
|
export type { ToolBinder };
|
|
107
|
-
|
|
108
|
-
export const isStreamableToolBinder = (
|
|
109
|
-
toolBinder: ToolBinder,
|
|
110
|
-
): toolBinder is ToolBinder<string, any, any, true> => {
|
|
111
|
-
return toolBinder.streamable === true;
|
|
112
|
-
};
|
|
113
|
-
export type MCPClientStub<TDefinition extends readonly ToolBinder[]> = {
|
|
114
|
-
[K in TDefinition[number] as K["name"]]: K extends ToolBinder<
|
|
115
|
-
string,
|
|
116
|
-
infer TInput,
|
|
117
|
-
infer TReturn
|
|
118
|
-
>
|
|
119
|
-
? (params: TInput, init?: RequestInit) => Promise<TReturn>
|
|
120
|
-
: never;
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
export type MCPClientFetchStub<TDefinition extends readonly ToolBinder[]> = {
|
|
124
|
-
[K in TDefinition[number] as K["name"]]: K["streamable"] extends true
|
|
125
|
-
? K extends ToolBinder<string, infer TInput, any, true>
|
|
126
|
-
? (params: TInput, init?: RequestInit) => Promise<Response>
|
|
127
|
-
: never
|
|
128
|
-
: K extends ToolBinder<string, infer TInput, infer TReturn, any>
|
|
129
|
-
? (params: TInput, init?: RequestInit) => Promise<Awaited<TReturn>>
|
|
130
|
-
: never;
|
|
131
|
-
};
|
|
132
|
-
|
|
133
|
-
export type MCPConnectionProvider = MCPConnection;
|
|
134
|
-
|
|
135
|
-
export interface MCPClientRaw {
|
|
136
|
-
callTool: (tool: string, args: unknown) => Promise<unknown>;
|
|
137
|
-
listTools: () => Promise<
|
|
138
|
-
{
|
|
139
|
-
name: string;
|
|
140
|
-
inputSchema: any;
|
|
141
|
-
outputSchema?: any;
|
|
142
|
-
description: string;
|
|
143
|
-
}[]
|
|
144
|
-
>;
|
|
145
|
-
}
|
|
146
|
-
export type JSONSchemaToZodConverter = (jsonSchema: any) => z.ZodTypeAny;
|
|
147
|
-
export interface CreateStubAPIOptions {
|
|
148
|
-
mcpPath?: string;
|
|
149
|
-
decoCmsApiUrl?: string;
|
|
150
|
-
workspace?: string;
|
|
151
|
-
token?: string;
|
|
152
|
-
connection?: MCPConnectionProvider;
|
|
153
|
-
streamable?: Record<string, boolean>;
|
|
154
|
-
debugId?: () => string;
|
|
155
|
-
getErrorByStatusCode?: (
|
|
156
|
-
statusCode: number,
|
|
157
|
-
message?: string,
|
|
158
|
-
traceId?: string,
|
|
159
|
-
errorObject?: unknown,
|
|
160
|
-
) => Error;
|
|
161
|
-
supportsToolName?: boolean;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
export function createMCPFetchStub<TDefinition extends readonly ToolBinder[]>(
|
|
165
|
-
options?: CreateStubAPIOptions,
|
|
166
|
-
): MCPClientFetchStub<TDefinition> {
|
|
167
|
-
return createMCPClientProxy<MCPClientFetchStub<TDefinition>>({
|
|
168
|
-
...(options ?? {}),
|
|
169
|
-
});
|
|
170
|
-
}
|
package/src/oauth.ts
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
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
|
+
* Encode data as base64url JSON
|
|
34
|
+
*/
|
|
35
|
+
function encodeState<T>(data: T): string {
|
|
36
|
+
return btoa(JSON.stringify(data))
|
|
37
|
+
.replace(/\+/g, "-")
|
|
38
|
+
.replace(/\//g, "_")
|
|
39
|
+
.replace(/=+$/, "");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Decode base64url JSON data
|
|
44
|
+
*/
|
|
45
|
+
function decodeState<T>(encoded: string): T | null {
|
|
46
|
+
try {
|
|
47
|
+
const base64 = encoded.replace(/-/g, "+").replace(/_/g, "/");
|
|
48
|
+
return JSON.parse(atob(base64)) as T;
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface PendingAuthState {
|
|
55
|
+
redirectUri: string;
|
|
56
|
+
clientState?: string;
|
|
57
|
+
codeChallenge?: string;
|
|
58
|
+
codeChallengeMethod?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface CodePayload {
|
|
62
|
+
accessToken: string;
|
|
63
|
+
tokenType: string;
|
|
64
|
+
codeChallenge?: string;
|
|
65
|
+
codeChallengeMethod?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const forceHttps = (url: URL) => {
|
|
69
|
+
const isLocal = url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
|
70
|
+
if (!isLocal) {
|
|
71
|
+
// force http if not local
|
|
72
|
+
url.protocol = "https:";
|
|
73
|
+
}
|
|
74
|
+
return url;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Create OAuth endpoint handlers for MCP servers
|
|
79
|
+
* The MCP server acts as an OAuth Authorization Server proxy
|
|
80
|
+
* Stateless implementation - no persistence required
|
|
81
|
+
* Per MCP Authorization spec: https://modelcontextprotocol.io/specification/draft/basic/authorization
|
|
82
|
+
*/
|
|
83
|
+
export function createOAuthHandlers(oauth: OAuthConfig) {
|
|
84
|
+
/**
|
|
85
|
+
* Build OAuth 2.0 Protected Resource Metadata (RFC9728)
|
|
86
|
+
* Points to THIS server as the authorization server
|
|
87
|
+
*/
|
|
88
|
+
const handleProtectedResourceMetadata = (req: Request): Response => {
|
|
89
|
+
const url = forceHttps(new URL(req.url));
|
|
90
|
+
const resourceUrl = `${url.origin}/mcp`;
|
|
91
|
+
|
|
92
|
+
return Response.json({
|
|
93
|
+
resource: resourceUrl,
|
|
94
|
+
// Point to ourselves - we are the authorization server proxy
|
|
95
|
+
authorization_servers: [url.origin],
|
|
96
|
+
scopes_supported: ["*"],
|
|
97
|
+
bearer_methods_supported: ["header"],
|
|
98
|
+
resource_signing_alg_values_supported: ["RS256", "none"],
|
|
99
|
+
});
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Build OAuth 2.0 Authorization Server Metadata (RFC8414)
|
|
104
|
+
* Exposes our endpoints for authorization, token exchange, and registration
|
|
105
|
+
*/
|
|
106
|
+
const handleAuthorizationServerMetadata = (req: Request): Response => {
|
|
107
|
+
const url = forceHttps(new URL(req.url));
|
|
108
|
+
const baseUrl = url.origin;
|
|
109
|
+
|
|
110
|
+
return Response.json({
|
|
111
|
+
issuer: baseUrl,
|
|
112
|
+
authorization_endpoint: `${baseUrl}/authorize`,
|
|
113
|
+
token_endpoint: `${baseUrl}/token`,
|
|
114
|
+
registration_endpoint: `${baseUrl}/register`,
|
|
115
|
+
scopes_supported: ["*"],
|
|
116
|
+
response_types_supported: ["code"],
|
|
117
|
+
response_modes_supported: ["query"],
|
|
118
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
119
|
+
token_endpoint_auth_methods_supported: ["none", "client_secret_post"],
|
|
120
|
+
code_challenge_methods_supported: ["S256", "plain"],
|
|
121
|
+
});
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Handle authorization request - redirects to external OAuth provider
|
|
126
|
+
* Stateless: encodes all needed info in the state parameter
|
|
127
|
+
*/
|
|
128
|
+
const handleAuthorize = (req: Request): Response => {
|
|
129
|
+
const url = forceHttps(new URL(req.url));
|
|
130
|
+
const redirectUri = url.searchParams.get("redirect_uri");
|
|
131
|
+
const responseType = url.searchParams.get("response_type");
|
|
132
|
+
const clientState = url.searchParams.get("state");
|
|
133
|
+
const codeChallenge = url.searchParams.get("code_challenge");
|
|
134
|
+
const codeChallengeMethod = url.searchParams.get("code_challenge_method");
|
|
135
|
+
|
|
136
|
+
// Validate required params
|
|
137
|
+
if (!redirectUri) {
|
|
138
|
+
return Response.json(
|
|
139
|
+
{
|
|
140
|
+
error: "invalid_request",
|
|
141
|
+
error_description: "redirect_uri required",
|
|
142
|
+
},
|
|
143
|
+
{ status: 400 },
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (responseType !== "code") {
|
|
148
|
+
return Response.json(
|
|
149
|
+
{
|
|
150
|
+
error: "unsupported_response_type",
|
|
151
|
+
error_description: "Only 'code' is supported",
|
|
152
|
+
},
|
|
153
|
+
{ status: 400 },
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Encode pending auth state
|
|
158
|
+
const pendingState: PendingAuthState = {
|
|
159
|
+
redirectUri,
|
|
160
|
+
clientState: clientState ?? undefined,
|
|
161
|
+
codeChallenge: codeChallenge ?? undefined,
|
|
162
|
+
codeChallengeMethod: codeChallengeMethod ?? undefined,
|
|
163
|
+
};
|
|
164
|
+
const encodedState = encodeState(pendingState);
|
|
165
|
+
|
|
166
|
+
// Build callback URL pointing to our internal callback
|
|
167
|
+
const callbackUrl = forceHttps(new URL(`${url.origin}/oauth/callback`));
|
|
168
|
+
callbackUrl.searchParams.set("state", encodedState);
|
|
169
|
+
|
|
170
|
+
// Get the external authorization URL from the config
|
|
171
|
+
const externalAuthUrl = oauth.authorizationUrl(callbackUrl.toString());
|
|
172
|
+
|
|
173
|
+
// Redirect to external OAuth provider
|
|
174
|
+
return Response.redirect(externalAuthUrl, 302);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Handle OAuth callback from external provider
|
|
179
|
+
* Stateless: decodes state to get redirect info, encodes token in code
|
|
180
|
+
*/
|
|
181
|
+
const handleOAuthCallback = async (req: Request): Promise<Response> => {
|
|
182
|
+
const url = forceHttps(new URL(req.url));
|
|
183
|
+
const code = url.searchParams.get("code");
|
|
184
|
+
const encodedState = url.searchParams.get("state");
|
|
185
|
+
const error = url.searchParams.get("error");
|
|
186
|
+
|
|
187
|
+
// Decode state
|
|
188
|
+
const pending = encodedState
|
|
189
|
+
? decodeState<PendingAuthState>(encodedState)
|
|
190
|
+
: null;
|
|
191
|
+
|
|
192
|
+
if (error) {
|
|
193
|
+
const errorDescription =
|
|
194
|
+
url.searchParams.get("error_description") ?? "Authorization failed";
|
|
195
|
+
if (pending?.redirectUri) {
|
|
196
|
+
const redirectUrl = forceHttps(new URL(pending.redirectUri));
|
|
197
|
+
redirectUrl.searchParams.set("error", error);
|
|
198
|
+
redirectUrl.searchParams.set("error_description", errorDescription);
|
|
199
|
+
if (pending.clientState)
|
|
200
|
+
redirectUrl.searchParams.set("state", pending.clientState);
|
|
201
|
+
return Response.redirect(redirectUrl.toString(), 302);
|
|
202
|
+
}
|
|
203
|
+
return Response.json(
|
|
204
|
+
{ error, error_description: errorDescription },
|
|
205
|
+
{ status: 400 },
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!code || !pending) {
|
|
210
|
+
return Response.json(
|
|
211
|
+
{
|
|
212
|
+
error: "invalid_request",
|
|
213
|
+
error_description: "Missing code or state",
|
|
214
|
+
},
|
|
215
|
+
{ status: 400 },
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
// Exchange code with external provider
|
|
221
|
+
const oauthParams: OAuthParams = { code };
|
|
222
|
+
const tokenResponse = await oauth.exchangeCode(oauthParams);
|
|
223
|
+
|
|
224
|
+
// Encode the token in our own code (stateless)
|
|
225
|
+
const codePayload: CodePayload = {
|
|
226
|
+
accessToken: tokenResponse.access_token,
|
|
227
|
+
tokenType: tokenResponse.token_type,
|
|
228
|
+
codeChallenge: pending.codeChallenge,
|
|
229
|
+
codeChallengeMethod: pending.codeChallengeMethod,
|
|
230
|
+
};
|
|
231
|
+
const ourCode = encodeState(codePayload);
|
|
232
|
+
|
|
233
|
+
// Redirect back to client with our code
|
|
234
|
+
const redirectUrl = forceHttps(new URL(pending.redirectUri));
|
|
235
|
+
redirectUrl.searchParams.set("code", ourCode);
|
|
236
|
+
if (pending.clientState) {
|
|
237
|
+
redirectUrl.searchParams.set("state", pending.clientState);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return Response.redirect(redirectUrl.toString(), 302);
|
|
241
|
+
} catch (err) {
|
|
242
|
+
console.error("OAuth callback error:", err);
|
|
243
|
+
|
|
244
|
+
// Redirect back to client with error
|
|
245
|
+
const redirectUrl = forceHttps(new URL(pending.redirectUri));
|
|
246
|
+
redirectUrl.searchParams.set("error", "server_error");
|
|
247
|
+
redirectUrl.searchParams.set(
|
|
248
|
+
"error_description",
|
|
249
|
+
"Failed to exchange authorization code",
|
|
250
|
+
);
|
|
251
|
+
if (pending.clientState)
|
|
252
|
+
redirectUrl.searchParams.set("state", pending.clientState);
|
|
253
|
+
|
|
254
|
+
return Response.redirect(redirectUrl.toString(), 302);
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Handle token exchange - decodes our code to get the actual token
|
|
260
|
+
* Stateless: token is encoded in the code
|
|
261
|
+
*/
|
|
262
|
+
const handleToken = async (req: Request): Promise<Response> => {
|
|
263
|
+
try {
|
|
264
|
+
const contentType = req.headers.get("content-type") ?? "";
|
|
265
|
+
let body: Record<string, string>;
|
|
266
|
+
|
|
267
|
+
if (contentType.includes("application/x-www-form-urlencoded")) {
|
|
268
|
+
const formData = await req.formData();
|
|
269
|
+
body = Object.fromEntries(formData.entries()) as Record<string, string>;
|
|
270
|
+
} else {
|
|
271
|
+
body = await req.json();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const { code, code_verifier, grant_type } = body;
|
|
275
|
+
|
|
276
|
+
if (grant_type !== "authorization_code") {
|
|
277
|
+
return Response.json(
|
|
278
|
+
{
|
|
279
|
+
error: "unsupported_grant_type",
|
|
280
|
+
error_description: "Only authorization_code supported",
|
|
281
|
+
},
|
|
282
|
+
{ status: 400 },
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (!code) {
|
|
287
|
+
return Response.json(
|
|
288
|
+
{ error: "invalid_request", error_description: "code is required" },
|
|
289
|
+
{ status: 400 },
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Decode the code to get the token
|
|
294
|
+
const payload = decodeState<CodePayload>(code);
|
|
295
|
+
if (!payload || !payload.accessToken) {
|
|
296
|
+
return Response.json(
|
|
297
|
+
{
|
|
298
|
+
error: "invalid_grant",
|
|
299
|
+
error_description: "Invalid or expired code",
|
|
300
|
+
},
|
|
301
|
+
{ status: 400 },
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Verify PKCE if code challenge was provided
|
|
306
|
+
if (payload.codeChallenge) {
|
|
307
|
+
if (!code_verifier) {
|
|
308
|
+
return Response.json(
|
|
309
|
+
{
|
|
310
|
+
error: "invalid_grant",
|
|
311
|
+
error_description: "code_verifier required",
|
|
312
|
+
},
|
|
313
|
+
{ status: 400 },
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Verify the code verifier
|
|
318
|
+
let computedChallenge: string;
|
|
319
|
+
if (payload.codeChallengeMethod === "S256") {
|
|
320
|
+
const encoder = new TextEncoder();
|
|
321
|
+
const data = encoder.encode(code_verifier);
|
|
322
|
+
const hash = await crypto.subtle.digest("SHA-256", data);
|
|
323
|
+
computedChallenge = btoa(String.fromCharCode(...new Uint8Array(hash)))
|
|
324
|
+
.replace(/\+/g, "-")
|
|
325
|
+
.replace(/\//g, "_")
|
|
326
|
+
.replace(/=+$/, "");
|
|
327
|
+
} else {
|
|
328
|
+
computedChallenge = code_verifier;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (computedChallenge !== payload.codeChallenge) {
|
|
332
|
+
return Response.json(
|
|
333
|
+
{
|
|
334
|
+
error: "invalid_grant",
|
|
335
|
+
error_description: "Invalid code_verifier",
|
|
336
|
+
},
|
|
337
|
+
{ status: 400 },
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Return the actual token
|
|
343
|
+
return Response.json(
|
|
344
|
+
{
|
|
345
|
+
access_token: payload.accessToken,
|
|
346
|
+
token_type: payload.tokenType,
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
headers: {
|
|
350
|
+
"Cache-Control": "no-store",
|
|
351
|
+
Pragma: "no-cache",
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
);
|
|
355
|
+
} catch (err) {
|
|
356
|
+
console.error("Token exchange error:", err);
|
|
357
|
+
return Response.json(
|
|
358
|
+
{
|
|
359
|
+
error: "server_error",
|
|
360
|
+
error_description: "Failed to process token request",
|
|
361
|
+
},
|
|
362
|
+
{ status: 500 },
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Handle dynamic client registration (RFC7591)
|
|
369
|
+
* Stateless: just generates a client_id and returns it, no storage needed
|
|
370
|
+
*/
|
|
371
|
+
const handleClientRegistration = async (req: Request): Promise<Response> => {
|
|
372
|
+
try {
|
|
373
|
+
const body = (await req.json()) as {
|
|
374
|
+
redirect_uris?: string[];
|
|
375
|
+
client_name?: string;
|
|
376
|
+
grant_types?: string[];
|
|
377
|
+
response_types?: string[];
|
|
378
|
+
token_endpoint_auth_method?: string;
|
|
379
|
+
scope?: string;
|
|
380
|
+
client_uri?: string;
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
// Validate redirect URIs
|
|
384
|
+
if (!body.redirect_uris || body.redirect_uris.length === 0) {
|
|
385
|
+
return Response.json(
|
|
386
|
+
{
|
|
387
|
+
error: "invalid_redirect_uri",
|
|
388
|
+
error_description: "At least one redirect_uri is required",
|
|
389
|
+
},
|
|
390
|
+
{ status: 400 },
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
for (const uri of body.redirect_uris) {
|
|
395
|
+
if (!isValidRedirectUri(uri)) {
|
|
396
|
+
return Response.json(
|
|
397
|
+
{
|
|
398
|
+
error: "invalid_redirect_uri",
|
|
399
|
+
error_description: `Invalid redirect URI: ${uri}`,
|
|
400
|
+
},
|
|
401
|
+
{ status: 400 },
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const clientId = generateRandomToken(32);
|
|
407
|
+
const clientSecret =
|
|
408
|
+
body.token_endpoint_auth_method !== "none"
|
|
409
|
+
? generateRandomToken(32)
|
|
410
|
+
: undefined;
|
|
411
|
+
const now = Math.floor(Date.now() / 1000);
|
|
412
|
+
|
|
413
|
+
const client: OAuthClient = {
|
|
414
|
+
client_id: clientId,
|
|
415
|
+
client_secret: clientSecret,
|
|
416
|
+
client_name: body.client_name,
|
|
417
|
+
redirect_uris: body.redirect_uris,
|
|
418
|
+
grant_types: body.grant_types ?? ["authorization_code"],
|
|
419
|
+
response_types: body.response_types ?? ["code"],
|
|
420
|
+
token_endpoint_auth_method:
|
|
421
|
+
body.token_endpoint_auth_method ?? "client_secret_post",
|
|
422
|
+
scope: body.scope,
|
|
423
|
+
client_id_issued_at: now,
|
|
424
|
+
client_secret_expires_at: 0,
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
// Save client if persistence is provided
|
|
428
|
+
if (oauth.persistence) {
|
|
429
|
+
await oauth.persistence.saveClient(client);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return new Response(JSON.stringify(client), {
|
|
433
|
+
status: 201,
|
|
434
|
+
headers: {
|
|
435
|
+
"Content-Type": "application/json",
|
|
436
|
+
"Cache-Control": "no-store",
|
|
437
|
+
Pragma: "no-cache",
|
|
438
|
+
},
|
|
439
|
+
});
|
|
440
|
+
} catch (err) {
|
|
441
|
+
console.error("Client registration error:", err);
|
|
442
|
+
return Response.json(
|
|
443
|
+
{
|
|
444
|
+
error: "invalid_client_metadata",
|
|
445
|
+
error_description: "Invalid client registration request",
|
|
446
|
+
},
|
|
447
|
+
{ status: 400 },
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Return 401 with WWW-Authenticate header for unauthenticated MCP requests
|
|
454
|
+
* Per MCP spec: MUST include resource_metadata URL
|
|
455
|
+
*/
|
|
456
|
+
const createUnauthorizedResponse = (req: Request): Response => {
|
|
457
|
+
const url = forceHttps(new URL(req.url));
|
|
458
|
+
const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource`;
|
|
459
|
+
const wwwAuthenticateValue = `Bearer resource_metadata="${resourceMetadataUrl}", scope="*"`;
|
|
460
|
+
|
|
461
|
+
return Response.json(
|
|
462
|
+
{
|
|
463
|
+
jsonrpc: "2.0",
|
|
464
|
+
error: {
|
|
465
|
+
code: -32000,
|
|
466
|
+
message: "Unauthorized: Authentication required",
|
|
467
|
+
},
|
|
468
|
+
id: null,
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
status: 401,
|
|
472
|
+
headers: {
|
|
473
|
+
"WWW-Authenticate": wwwAuthenticateValue,
|
|
474
|
+
"Access-Control-Expose-Headers": "WWW-Authenticate",
|
|
475
|
+
},
|
|
476
|
+
},
|
|
477
|
+
);
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Check if request has authentication token
|
|
482
|
+
*/
|
|
483
|
+
const hasAuth = (req: Request) => req.headers.has("Authorization");
|
|
484
|
+
|
|
485
|
+
return {
|
|
486
|
+
handleProtectedResourceMetadata,
|
|
487
|
+
handleAuthorizationServerMetadata,
|
|
488
|
+
handleAuthorize,
|
|
489
|
+
handleOAuthCallback,
|
|
490
|
+
handleToken,
|
|
491
|
+
handleClientRegistration,
|
|
492
|
+
createUnauthorizedResponse,
|
|
493
|
+
hasAuth,
|
|
494
|
+
};
|
|
495
|
+
}
|