strapi-oauth-mcp-manager 0.1.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/README.md +143 -0
- package/dist/_chunks/App-CjW3NftW.mjs +23 -0
- package/dist/_chunks/App-DsMhfKkM.js +23 -0
- package/dist/_chunks/en-B4KWt_jN.js +4 -0
- package/dist/_chunks/en-Byx4XI2L.mjs +4 -0
- package/dist/_chunks/index-B2ShbPnj.js +65 -0
- package/dist/_chunks/index-DzBaU9Fw.mjs +66 -0
- package/dist/admin/index.js +3 -0
- package/dist/admin/index.mjs +4 -0
- package/dist/admin/src/components/Initializer.d.ts +5 -0
- package/dist/admin/src/components/PluginIcon.d.ts +2 -0
- package/dist/admin/src/index.d.ts +10 -0
- package/dist/admin/src/pages/App.d.ts +2 -0
- package/dist/admin/src/pages/HomePage.d.ts +2 -0
- package/dist/admin/src/pluginId.d.ts +1 -0
- package/dist/admin/src/utils/getTranslation.d.ts +2 -0
- package/dist/server/index.js +800 -0
- package/dist/server/index.mjs +801 -0
- package/dist/server/src/bootstrap.d.ts +5 -0
- package/dist/server/src/config/index.d.ts +5 -0
- package/dist/server/src/content-types/index.d.ts +208 -0
- package/dist/server/src/controllers/index.d.ts +11 -0
- package/dist/server/src/controllers/oauth.d.ts +31 -0
- package/dist/server/src/destroy.d.ts +5 -0
- package/dist/server/src/index.d.ts +278 -0
- package/dist/server/src/middlewares/index.d.ts +6 -0
- package/dist/server/src/middlewares/mcp-oauth.d.ts +19 -0
- package/dist/server/src/pluginId.d.ts +1 -0
- package/dist/server/src/policies/index.d.ts +2 -0
- package/dist/server/src/register.d.ts +5 -0
- package/dist/server/src/routes/admin/index.d.ts +2 -0
- package/dist/server/src/routes/content-api/index.d.ts +10 -0
- package/dist/server/src/routes/index.d.ts +19 -0
- package/dist/server/src/services/endpoint.d.ts +37 -0
- package/dist/server/src/services/index.d.ts +22 -0
- package/dist/server/src/services/oauth.d.ts +32 -0
- package/package.json +88 -0
|
@@ -0,0 +1,801 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
const PLUGIN_ID = "strapi-oauth-mcp-manager";
|
|
3
|
+
const PLUGIN_UID$3 = `plugin::${PLUGIN_ID}`;
|
|
4
|
+
function extractBearerToken(authHeader) {
|
|
5
|
+
if (!authHeader?.startsWith("Bearer ")) {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
return authHeader.slice(7);
|
|
9
|
+
}
|
|
10
|
+
function getBaseUrl$1(ctx, strapi) {
|
|
11
|
+
const forwardedProto = ctx.request.headers["x-forwarded-proto"] || ctx.protocol;
|
|
12
|
+
const forwardedHost = ctx.request.headers["x-forwarded-host"] || ctx.request.headers["host"];
|
|
13
|
+
const serverUrl = strapi.config.get("server.url") || "http://localhost:1337";
|
|
14
|
+
return forwardedHost ? `${forwardedProto}://${forwardedHost}` : serverUrl;
|
|
15
|
+
}
|
|
16
|
+
function buildWwwAuthenticateHeader(ctx, strapi) {
|
|
17
|
+
const baseUrl = getBaseUrl$1(ctx, strapi);
|
|
18
|
+
const resourceMetadataUrl = `${baseUrl}/api/${PLUGIN_ID}/.well-known/oauth-protected-resource`;
|
|
19
|
+
return `Bearer resource_metadata="${resourceMetadataUrl}"`;
|
|
20
|
+
}
|
|
21
|
+
async function validateOAuthToken(token, strapi) {
|
|
22
|
+
try {
|
|
23
|
+
const tokenRecord = await strapi.documents(`${PLUGIN_UID$3}.mcp-oauth-token`).findFirst({
|
|
24
|
+
filters: { accessToken: token, revoked: false }
|
|
25
|
+
});
|
|
26
|
+
if (!tokenRecord) {
|
|
27
|
+
return { valid: false };
|
|
28
|
+
}
|
|
29
|
+
if (new Date(tokenRecord.expiresAt) < /* @__PURE__ */ new Date()) {
|
|
30
|
+
return { valid: false, error: "Token expired" };
|
|
31
|
+
}
|
|
32
|
+
const client = await strapi.documents(`${PLUGIN_UID$3}.mcp-oauth-client`).findFirst({
|
|
33
|
+
filters: { clientId: tokenRecord.clientId }
|
|
34
|
+
});
|
|
35
|
+
if (!client) {
|
|
36
|
+
return { valid: false, error: "Client not found" };
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
valid: true,
|
|
40
|
+
strapiApiToken: client.strapiApiToken
|
|
41
|
+
};
|
|
42
|
+
} catch (error) {
|
|
43
|
+
strapi.log.error(`[${PLUGIN_ID}] Error validating OAuth token`, { error });
|
|
44
|
+
return { valid: false, error: "Token validation failed" };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const mcpOauthMiddleware = (config2, { strapi }) => {
|
|
48
|
+
let endpointCache = [];
|
|
49
|
+
let lastCacheUpdate = 0;
|
|
50
|
+
const CACHE_TTL = 6e4;
|
|
51
|
+
async function refreshEndpointCache() {
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
if (now - lastCacheUpdate > CACHE_TTL) {
|
|
54
|
+
try {
|
|
55
|
+
const endpoints = await strapi.documents(`${PLUGIN_UID$3}.mcp-endpoint`).findMany({
|
|
56
|
+
filters: { active: true }
|
|
57
|
+
});
|
|
58
|
+
endpointCache = endpoints.map((e) => e.path);
|
|
59
|
+
lastCacheUpdate = now;
|
|
60
|
+
} catch (error) {
|
|
61
|
+
strapi.log.error(`[${PLUGIN_ID}] Error refreshing endpoint cache`, { error });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function isProtectedPath(path) {
|
|
66
|
+
return endpointCache.some((endpoint) => path.includes(endpoint));
|
|
67
|
+
}
|
|
68
|
+
return async (ctx, next) => {
|
|
69
|
+
await refreshEndpointCache();
|
|
70
|
+
if (!isProtectedPath(ctx.path)) {
|
|
71
|
+
return next();
|
|
72
|
+
}
|
|
73
|
+
const authHeader = ctx.request.headers.authorization;
|
|
74
|
+
const token = extractBearerToken(authHeader);
|
|
75
|
+
if (!token) {
|
|
76
|
+
ctx.status = 401;
|
|
77
|
+
ctx.set("WWW-Authenticate", buildWwwAuthenticateHeader(ctx, strapi));
|
|
78
|
+
ctx.body = {
|
|
79
|
+
error: "Unauthorized",
|
|
80
|
+
message: "No authorization token provided"
|
|
81
|
+
};
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const oauthResult = await validateOAuthToken(token, strapi);
|
|
85
|
+
if (oauthResult.valid && oauthResult.strapiApiToken) {
|
|
86
|
+
ctx.state.strapiToken = oauthResult.strapiApiToken;
|
|
87
|
+
ctx.state.authMethod = "oauth";
|
|
88
|
+
return next();
|
|
89
|
+
}
|
|
90
|
+
ctx.state.strapiToken = token;
|
|
91
|
+
ctx.state.authMethod = "api-token";
|
|
92
|
+
return next();
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
const bootstrap = async ({ strapi }) => {
|
|
96
|
+
const middleware = mcpOauthMiddleware({}, { strapi });
|
|
97
|
+
strapi.server.use(middleware);
|
|
98
|
+
strapi.log.info(`[${PLUGIN_ID}] OAuth middleware registered`);
|
|
99
|
+
strapi.log.info(`[${PLUGIN_ID}] OAuth endpoints available at: /api/${PLUGIN_ID}/oauth/*`);
|
|
100
|
+
strapi.log.info(`[${PLUGIN_ID}] Discovery: /api/${PLUGIN_ID}/.well-known/oauth-authorization-server`);
|
|
101
|
+
};
|
|
102
|
+
const destroy = ({ strapi }) => {
|
|
103
|
+
};
|
|
104
|
+
const register = ({ strapi }) => {
|
|
105
|
+
};
|
|
106
|
+
const config = {
|
|
107
|
+
default: {},
|
|
108
|
+
validator() {
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
const kind$3 = "collectionType";
|
|
112
|
+
const collectionName$3 = "mcp_oauth_clients";
|
|
113
|
+
const info$3 = {
|
|
114
|
+
singularName: "mcp-oauth-client",
|
|
115
|
+
pluralName: "mcp-oauth-clients",
|
|
116
|
+
displayName: "MCP OAuth Client",
|
|
117
|
+
description: "OAuth 2.0 clients for MCP authentication"
|
|
118
|
+
};
|
|
119
|
+
const options$3 = {
|
|
120
|
+
draftAndPublish: false
|
|
121
|
+
};
|
|
122
|
+
const pluginOptions$3 = {
|
|
123
|
+
"content-manager": {
|
|
124
|
+
visible: true
|
|
125
|
+
},
|
|
126
|
+
"content-type-builder": {
|
|
127
|
+
visible: true
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
const attributes$3 = {
|
|
131
|
+
name: {
|
|
132
|
+
type: "string",
|
|
133
|
+
required: true
|
|
134
|
+
},
|
|
135
|
+
clientId: {
|
|
136
|
+
type: "string",
|
|
137
|
+
required: true,
|
|
138
|
+
unique: true
|
|
139
|
+
},
|
|
140
|
+
clientSecret: {
|
|
141
|
+
type: "string",
|
|
142
|
+
required: true,
|
|
143
|
+
"private": true
|
|
144
|
+
},
|
|
145
|
+
redirectUris: {
|
|
146
|
+
type: "json",
|
|
147
|
+
required: true
|
|
148
|
+
},
|
|
149
|
+
strapiApiToken: {
|
|
150
|
+
type: "string",
|
|
151
|
+
required: true,
|
|
152
|
+
"private": true
|
|
153
|
+
},
|
|
154
|
+
description: {
|
|
155
|
+
type: "text"
|
|
156
|
+
},
|
|
157
|
+
active: {
|
|
158
|
+
type: "boolean",
|
|
159
|
+
"default": true
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
const mcpOauthClient = {
|
|
163
|
+
kind: kind$3,
|
|
164
|
+
collectionName: collectionName$3,
|
|
165
|
+
info: info$3,
|
|
166
|
+
options: options$3,
|
|
167
|
+
pluginOptions: pluginOptions$3,
|
|
168
|
+
attributes: attributes$3
|
|
169
|
+
};
|
|
170
|
+
const kind$2 = "collectionType";
|
|
171
|
+
const collectionName$2 = "mcp_oauth_codes";
|
|
172
|
+
const info$2 = {
|
|
173
|
+
singularName: "mcp-oauth-code",
|
|
174
|
+
pluralName: "mcp-oauth-codes",
|
|
175
|
+
displayName: "MCP OAuth Code",
|
|
176
|
+
description: "OAuth 2.0 authorization codes"
|
|
177
|
+
};
|
|
178
|
+
const options$2 = {
|
|
179
|
+
draftAndPublish: false
|
|
180
|
+
};
|
|
181
|
+
const pluginOptions$2 = {
|
|
182
|
+
"content-manager": {
|
|
183
|
+
visible: false
|
|
184
|
+
},
|
|
185
|
+
"content-type-builder": {
|
|
186
|
+
visible: false
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
const attributes$2 = {
|
|
190
|
+
code: {
|
|
191
|
+
type: "string",
|
|
192
|
+
required: true,
|
|
193
|
+
unique: true
|
|
194
|
+
},
|
|
195
|
+
clientId: {
|
|
196
|
+
type: "string",
|
|
197
|
+
required: true
|
|
198
|
+
},
|
|
199
|
+
redirectUri: {
|
|
200
|
+
type: "string",
|
|
201
|
+
required: true
|
|
202
|
+
},
|
|
203
|
+
codeChallenge: {
|
|
204
|
+
type: "string"
|
|
205
|
+
},
|
|
206
|
+
codeChallengeMethod: {
|
|
207
|
+
type: "string"
|
|
208
|
+
},
|
|
209
|
+
expiresAt: {
|
|
210
|
+
type: "datetime",
|
|
211
|
+
required: true
|
|
212
|
+
},
|
|
213
|
+
used: {
|
|
214
|
+
type: "boolean",
|
|
215
|
+
"default": false
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
const mcpOauthCode = {
|
|
219
|
+
kind: kind$2,
|
|
220
|
+
collectionName: collectionName$2,
|
|
221
|
+
info: info$2,
|
|
222
|
+
options: options$2,
|
|
223
|
+
pluginOptions: pluginOptions$2,
|
|
224
|
+
attributes: attributes$2
|
|
225
|
+
};
|
|
226
|
+
const kind$1 = "collectionType";
|
|
227
|
+
const collectionName$1 = "mcp_oauth_tokens";
|
|
228
|
+
const info$1 = {
|
|
229
|
+
singularName: "mcp-oauth-token",
|
|
230
|
+
pluralName: "mcp-oauth-tokens",
|
|
231
|
+
displayName: "MCP OAuth Token",
|
|
232
|
+
description: "OAuth 2.0 access and refresh tokens"
|
|
233
|
+
};
|
|
234
|
+
const options$1 = {
|
|
235
|
+
draftAndPublish: false
|
|
236
|
+
};
|
|
237
|
+
const pluginOptions$1 = {
|
|
238
|
+
"content-manager": {
|
|
239
|
+
visible: false
|
|
240
|
+
},
|
|
241
|
+
"content-type-builder": {
|
|
242
|
+
visible: false
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
const attributes$1 = {
|
|
246
|
+
accessToken: {
|
|
247
|
+
type: "string",
|
|
248
|
+
required: true,
|
|
249
|
+
unique: true,
|
|
250
|
+
"private": true
|
|
251
|
+
},
|
|
252
|
+
refreshToken: {
|
|
253
|
+
type: "string",
|
|
254
|
+
required: true,
|
|
255
|
+
unique: true,
|
|
256
|
+
"private": true
|
|
257
|
+
},
|
|
258
|
+
clientId: {
|
|
259
|
+
type: "string",
|
|
260
|
+
required: true
|
|
261
|
+
},
|
|
262
|
+
expiresAt: {
|
|
263
|
+
type: "datetime",
|
|
264
|
+
required: true
|
|
265
|
+
},
|
|
266
|
+
refreshExpiresAt: {
|
|
267
|
+
type: "datetime",
|
|
268
|
+
required: true
|
|
269
|
+
},
|
|
270
|
+
revoked: {
|
|
271
|
+
type: "boolean",
|
|
272
|
+
"default": false
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
const mcpOauthToken = {
|
|
276
|
+
kind: kind$1,
|
|
277
|
+
collectionName: collectionName$1,
|
|
278
|
+
info: info$1,
|
|
279
|
+
options: options$1,
|
|
280
|
+
pluginOptions: pluginOptions$1,
|
|
281
|
+
attributes: attributes$1
|
|
282
|
+
};
|
|
283
|
+
const kind = "collectionType";
|
|
284
|
+
const collectionName = "mcp_endpoints";
|
|
285
|
+
const info = {
|
|
286
|
+
singularName: "mcp-endpoint",
|
|
287
|
+
pluralName: "mcp-endpoints",
|
|
288
|
+
displayName: "MCP Endpoint",
|
|
289
|
+
description: "Registered MCP endpoints protected by OAuth"
|
|
290
|
+
};
|
|
291
|
+
const options = {
|
|
292
|
+
draftAndPublish: false
|
|
293
|
+
};
|
|
294
|
+
const pluginOptions = {
|
|
295
|
+
"content-manager": {
|
|
296
|
+
visible: true
|
|
297
|
+
},
|
|
298
|
+
"content-type-builder": {
|
|
299
|
+
visible: true
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
const attributes = {
|
|
303
|
+
name: {
|
|
304
|
+
type: "string",
|
|
305
|
+
required: true
|
|
306
|
+
},
|
|
307
|
+
pluginId: {
|
|
308
|
+
type: "string",
|
|
309
|
+
required: true
|
|
310
|
+
},
|
|
311
|
+
path: {
|
|
312
|
+
type: "string",
|
|
313
|
+
required: true,
|
|
314
|
+
unique: true
|
|
315
|
+
},
|
|
316
|
+
description: {
|
|
317
|
+
type: "text"
|
|
318
|
+
},
|
|
319
|
+
active: {
|
|
320
|
+
type: "boolean",
|
|
321
|
+
"default": true
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
const mcpEndpoint = {
|
|
325
|
+
kind,
|
|
326
|
+
collectionName,
|
|
327
|
+
info,
|
|
328
|
+
options,
|
|
329
|
+
pluginOptions,
|
|
330
|
+
attributes
|
|
331
|
+
};
|
|
332
|
+
const contentTypes = {
|
|
333
|
+
"mcp-oauth-client": { schema: mcpOauthClient },
|
|
334
|
+
"mcp-oauth-code": { schema: mcpOauthCode },
|
|
335
|
+
"mcp-oauth-token": { schema: mcpOauthToken },
|
|
336
|
+
"mcp-endpoint": { schema: mcpEndpoint }
|
|
337
|
+
};
|
|
338
|
+
const PLUGIN_UID$2 = `plugin::${PLUGIN_ID}`;
|
|
339
|
+
function getBaseUrl(ctx, strapi) {
|
|
340
|
+
const forwardedProto = ctx.request.headers["x-forwarded-proto"] || ctx.protocol;
|
|
341
|
+
const forwardedHost = ctx.request.headers["x-forwarded-host"] || ctx.request.headers["host"];
|
|
342
|
+
const serverUrl = strapi.config.get("server.url") || "http://localhost:1337";
|
|
343
|
+
return forwardedHost ? `${forwardedProto}://${forwardedHost}` : serverUrl;
|
|
344
|
+
}
|
|
345
|
+
function matchRedirectUri(redirectUri, allowedPatterns) {
|
|
346
|
+
return allowedPatterns.some((pattern) => {
|
|
347
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
348
|
+
const regexPattern = escaped.replace(/\*/g, "[^/]*");
|
|
349
|
+
const regex = new RegExp("^" + regexPattern + "$");
|
|
350
|
+
return regex.test(redirectUri);
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
const oauthController = ({ strapi }) => ({
|
|
354
|
+
/**
|
|
355
|
+
* OAuth 2.0 Authorization Server Metadata (RFC 8414)
|
|
356
|
+
* GET /.well-known/oauth-authorization-server
|
|
357
|
+
*/
|
|
358
|
+
async discovery(ctx) {
|
|
359
|
+
const baseUrl = getBaseUrl(ctx, strapi);
|
|
360
|
+
const pluginPath = `/api/${PLUGIN_ID}`;
|
|
361
|
+
const issuer = `${baseUrl}${pluginPath}`;
|
|
362
|
+
ctx.body = {
|
|
363
|
+
issuer,
|
|
364
|
+
authorization_endpoint: `${baseUrl}${pluginPath}/oauth/authorize`,
|
|
365
|
+
token_endpoint: `${baseUrl}${pluginPath}/oauth/token`,
|
|
366
|
+
response_types_supported: ["code"],
|
|
367
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
368
|
+
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
|
|
369
|
+
code_challenge_methods_supported: ["S256"]
|
|
370
|
+
};
|
|
371
|
+
},
|
|
372
|
+
/**
|
|
373
|
+
* OAuth 2.0 Protected Resource Metadata (RFC 9728)
|
|
374
|
+
* GET /.well-known/oauth-protected-resource
|
|
375
|
+
*/
|
|
376
|
+
async protectedResource(ctx) {
|
|
377
|
+
const baseUrl = getBaseUrl(ctx, strapi);
|
|
378
|
+
const pluginPath = `/api/${PLUGIN_ID}`;
|
|
379
|
+
const authServer = `${baseUrl}${pluginPath}`;
|
|
380
|
+
const endpoints = await strapi.documents(`${PLUGIN_UID$2}.mcp-endpoint`).findMany({
|
|
381
|
+
filters: { active: true }
|
|
382
|
+
});
|
|
383
|
+
const resources = endpoints.map((e) => `${baseUrl}${e.path}`);
|
|
384
|
+
ctx.body = {
|
|
385
|
+
resource: resources.length === 1 ? resources[0] : resources,
|
|
386
|
+
authorization_servers: [authServer],
|
|
387
|
+
bearer_methods_supported: ["header"]
|
|
388
|
+
};
|
|
389
|
+
},
|
|
390
|
+
/**
|
|
391
|
+
* OAuth 2.0 Authorization Endpoint
|
|
392
|
+
* GET /oauth/authorize
|
|
393
|
+
*/
|
|
394
|
+
async authorize(ctx) {
|
|
395
|
+
const { client_id, redirect_uri, response_type, state, code_challenge, code_challenge_method } = ctx.query;
|
|
396
|
+
if (!client_id) {
|
|
397
|
+
ctx.status = 400;
|
|
398
|
+
ctx.body = { error: "invalid_request", error_description: "client_id is required" };
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (!redirect_uri) {
|
|
402
|
+
ctx.status = 400;
|
|
403
|
+
ctx.body = { error: "invalid_request", error_description: "redirect_uri is required" };
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (response_type !== "code") {
|
|
407
|
+
ctx.status = 400;
|
|
408
|
+
ctx.body = { error: "unsupported_response_type", error_description: "Only code response type is supported" };
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const client = await strapi.documents(`${PLUGIN_UID$2}.mcp-oauth-client`).findFirst({
|
|
412
|
+
filters: { clientId: client_id, active: true }
|
|
413
|
+
});
|
|
414
|
+
if (!client) {
|
|
415
|
+
ctx.status = 400;
|
|
416
|
+
ctx.body = { error: "invalid_client", error_description: "Unknown client_id" };
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
const allowedRedirects = client.redirectUris;
|
|
420
|
+
if (!matchRedirectUri(redirect_uri, allowedRedirects)) {
|
|
421
|
+
strapi.log.warn(`[${PLUGIN_ID}] Invalid redirect_uri: ${redirect_uri}`);
|
|
422
|
+
strapi.log.warn(`[${PLUGIN_ID}] Allowed patterns: ${allowedRedirects.join(", ")}`);
|
|
423
|
+
ctx.status = 400;
|
|
424
|
+
ctx.body = { error: "invalid_request", error_description: "Invalid redirect_uri" };
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
const code = randomBytes(32).toString("hex");
|
|
428
|
+
const expiresAt = new Date(Date.now() + 10 * 60 * 1e3);
|
|
429
|
+
await strapi.documents(`${PLUGIN_UID$2}.mcp-oauth-code`).create({
|
|
430
|
+
data: {
|
|
431
|
+
code,
|
|
432
|
+
clientId: client_id,
|
|
433
|
+
redirectUri: redirect_uri,
|
|
434
|
+
codeChallenge: code_challenge || null,
|
|
435
|
+
codeChallengeMethod: code_challenge_method || null,
|
|
436
|
+
expiresAt: expiresAt.toISOString(),
|
|
437
|
+
used: false
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
const redirectUrl = new URL(redirect_uri);
|
|
441
|
+
redirectUrl.searchParams.set("code", code);
|
|
442
|
+
if (state) {
|
|
443
|
+
redirectUrl.searchParams.set("state", state);
|
|
444
|
+
}
|
|
445
|
+
ctx.redirect(redirectUrl.toString());
|
|
446
|
+
},
|
|
447
|
+
/**
|
|
448
|
+
* OAuth 2.0 Token Endpoint
|
|
449
|
+
* POST /oauth/token
|
|
450
|
+
*/
|
|
451
|
+
async token(ctx) {
|
|
452
|
+
const { grant_type, code, redirect_uri, client_id, client_secret, refresh_token } = ctx.request.body;
|
|
453
|
+
let authClientId = client_id;
|
|
454
|
+
let authClientSecret = client_secret;
|
|
455
|
+
const authHeader = ctx.request.headers.authorization;
|
|
456
|
+
if (authHeader && authHeader.startsWith("Basic ")) {
|
|
457
|
+
const credentials = Buffer.from(authHeader.slice(6), "base64").toString();
|
|
458
|
+
const [id, secret] = credentials.split(":");
|
|
459
|
+
authClientId = authClientId || id;
|
|
460
|
+
authClientSecret = authClientSecret || secret;
|
|
461
|
+
}
|
|
462
|
+
if (!authClientId) {
|
|
463
|
+
ctx.status = 401;
|
|
464
|
+
ctx.body = { error: "invalid_client", error_description: "Client authentication required" };
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
const client = await strapi.documents(`${PLUGIN_UID$2}.mcp-oauth-client`).findFirst({
|
|
468
|
+
filters: { clientId: authClientId, active: true }
|
|
469
|
+
});
|
|
470
|
+
if (!client || authClientSecret && client.clientSecret !== authClientSecret) {
|
|
471
|
+
ctx.status = 401;
|
|
472
|
+
ctx.body = { error: "invalid_client", error_description: "Invalid client credentials" };
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
if (grant_type === "authorization_code") {
|
|
476
|
+
await handleAuthorizationCodeGrant(ctx, strapi, client, code, redirect_uri);
|
|
477
|
+
} else if (grant_type === "refresh_token") {
|
|
478
|
+
await handleRefreshTokenGrant(ctx, strapi, client, refresh_token);
|
|
479
|
+
} else {
|
|
480
|
+
ctx.status = 400;
|
|
481
|
+
ctx.body = { error: "unsupported_grant_type", error_description: "Unsupported grant type" };
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
async function handleAuthorizationCodeGrant(ctx, strapi, client, code, redirect_uri) {
|
|
486
|
+
if (!code) {
|
|
487
|
+
ctx.status = 400;
|
|
488
|
+
ctx.body = { error: "invalid_request", error_description: "code is required" };
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
const authCode = await strapi.documents(`${PLUGIN_UID$2}.mcp-oauth-code`).findFirst({
|
|
492
|
+
filters: { code, clientId: client.clientId, used: false }
|
|
493
|
+
});
|
|
494
|
+
if (!authCode) {
|
|
495
|
+
ctx.status = 400;
|
|
496
|
+
ctx.body = { error: "invalid_grant", error_description: "Invalid authorization code" };
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
if (new Date(authCode.expiresAt) < /* @__PURE__ */ new Date()) {
|
|
500
|
+
ctx.status = 400;
|
|
501
|
+
ctx.body = { error: "invalid_grant", error_description: "Authorization code expired" };
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (authCode.redirectUri !== redirect_uri) {
|
|
505
|
+
ctx.status = 400;
|
|
506
|
+
ctx.body = { error: "invalid_grant", error_description: "redirect_uri mismatch" };
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
await strapi.documents(`${PLUGIN_UID$2}.mcp-oauth-code`).update({
|
|
510
|
+
documentId: authCode.documentId,
|
|
511
|
+
data: { used: true }
|
|
512
|
+
});
|
|
513
|
+
const accessToken = randomBytes(32).toString("hex");
|
|
514
|
+
const refreshToken = randomBytes(32).toString("hex");
|
|
515
|
+
const expiresIn = 3600;
|
|
516
|
+
const refreshExpiresIn = 30 * 24 * 3600;
|
|
517
|
+
await strapi.documents(`${PLUGIN_UID$2}.mcp-oauth-token`).create({
|
|
518
|
+
data: {
|
|
519
|
+
accessToken,
|
|
520
|
+
refreshToken,
|
|
521
|
+
clientId: client.clientId,
|
|
522
|
+
expiresAt: new Date(Date.now() + expiresIn * 1e3).toISOString(),
|
|
523
|
+
refreshExpiresAt: new Date(Date.now() + refreshExpiresIn * 1e3).toISOString(),
|
|
524
|
+
revoked: false
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
ctx.body = {
|
|
528
|
+
access_token: accessToken,
|
|
529
|
+
token_type: "Bearer",
|
|
530
|
+
expires_in: expiresIn,
|
|
531
|
+
refresh_token: refreshToken
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
async function handleRefreshTokenGrant(ctx, strapi, client, refreshToken) {
|
|
535
|
+
if (!refreshToken) {
|
|
536
|
+
ctx.status = 400;
|
|
537
|
+
ctx.body = { error: "invalid_request", error_description: "refresh_token is required" };
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
const token = await strapi.documents(`${PLUGIN_UID$2}.mcp-oauth-token`).findFirst({
|
|
541
|
+
filters: { refreshToken, clientId: client.clientId, revoked: false }
|
|
542
|
+
});
|
|
543
|
+
if (!token) {
|
|
544
|
+
ctx.status = 400;
|
|
545
|
+
ctx.body = { error: "invalid_grant", error_description: "Invalid refresh token" };
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (new Date(token.refreshExpiresAt) < /* @__PURE__ */ new Date()) {
|
|
549
|
+
ctx.status = 400;
|
|
550
|
+
ctx.body = { error: "invalid_grant", error_description: "Refresh token expired" };
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
await strapi.documents(`${PLUGIN_UID$2}.mcp-oauth-token`).update({
|
|
554
|
+
documentId: token.documentId,
|
|
555
|
+
data: { revoked: true }
|
|
556
|
+
});
|
|
557
|
+
const newAccessToken = randomBytes(32).toString("hex");
|
|
558
|
+
const newRefreshToken = randomBytes(32).toString("hex");
|
|
559
|
+
const expiresIn = 3600;
|
|
560
|
+
const refreshExpiresIn = 30 * 24 * 3600;
|
|
561
|
+
await strapi.documents(`${PLUGIN_UID$2}.mcp-oauth-token`).create({
|
|
562
|
+
data: {
|
|
563
|
+
accessToken: newAccessToken,
|
|
564
|
+
refreshToken: newRefreshToken,
|
|
565
|
+
clientId: client.clientId,
|
|
566
|
+
expiresAt: new Date(Date.now() + expiresIn * 1e3).toISOString(),
|
|
567
|
+
refreshExpiresAt: new Date(Date.now() + refreshExpiresIn * 1e3).toISOString(),
|
|
568
|
+
revoked: false
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
ctx.body = {
|
|
572
|
+
access_token: newAccessToken,
|
|
573
|
+
token_type: "Bearer",
|
|
574
|
+
expires_in: expiresIn,
|
|
575
|
+
refresh_token: newRefreshToken
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
const controllers = {
|
|
579
|
+
oauth: oauthController
|
|
580
|
+
};
|
|
581
|
+
const middlewares = {
|
|
582
|
+
"mcp-oauth": mcpOauthMiddleware
|
|
583
|
+
};
|
|
584
|
+
const policies = {};
|
|
585
|
+
const contentApi = [
|
|
586
|
+
// OAuth 2.0 Authorization Server Metadata (RFC 8414)
|
|
587
|
+
{
|
|
588
|
+
method: "GET",
|
|
589
|
+
path: "/.well-known/oauth-authorization-server",
|
|
590
|
+
handler: "oauth.discovery",
|
|
591
|
+
config: {
|
|
592
|
+
auth: false,
|
|
593
|
+
policies: []
|
|
594
|
+
}
|
|
595
|
+
},
|
|
596
|
+
// OAuth 2.0 Protected Resource Metadata (RFC 9728)
|
|
597
|
+
{
|
|
598
|
+
method: "GET",
|
|
599
|
+
path: "/.well-known/oauth-protected-resource",
|
|
600
|
+
handler: "oauth.protectedResource",
|
|
601
|
+
config: {
|
|
602
|
+
auth: false,
|
|
603
|
+
policies: []
|
|
604
|
+
}
|
|
605
|
+
},
|
|
606
|
+
// OAuth 2.0 Authorization Endpoint
|
|
607
|
+
{
|
|
608
|
+
method: "GET",
|
|
609
|
+
path: "/oauth/authorize",
|
|
610
|
+
handler: "oauth.authorize",
|
|
611
|
+
config: {
|
|
612
|
+
auth: false,
|
|
613
|
+
policies: []
|
|
614
|
+
}
|
|
615
|
+
},
|
|
616
|
+
// OAuth 2.0 Token Endpoint
|
|
617
|
+
{
|
|
618
|
+
method: "POST",
|
|
619
|
+
path: "/oauth/token",
|
|
620
|
+
handler: "oauth.token",
|
|
621
|
+
config: {
|
|
622
|
+
auth: false,
|
|
623
|
+
policies: []
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
];
|
|
627
|
+
const admin = [];
|
|
628
|
+
const routes = {
|
|
629
|
+
"content-api": {
|
|
630
|
+
type: "content-api",
|
|
631
|
+
routes: [...contentApi]
|
|
632
|
+
},
|
|
633
|
+
admin: {
|
|
634
|
+
type: "admin",
|
|
635
|
+
routes: [...admin]
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
const PLUGIN_UID$1 = `plugin::${PLUGIN_ID}`;
|
|
639
|
+
const oauthService = ({ strapi }) => ({
|
|
640
|
+
/**
|
|
641
|
+
* Validate an OAuth access token
|
|
642
|
+
*/
|
|
643
|
+
async validateToken(accessToken) {
|
|
644
|
+
try {
|
|
645
|
+
const tokenRecord = await strapi.documents(`${PLUGIN_UID$1}.mcp-oauth-token`).findFirst({
|
|
646
|
+
filters: { accessToken, revoked: false }
|
|
647
|
+
});
|
|
648
|
+
if (!tokenRecord) {
|
|
649
|
+
return { valid: false };
|
|
650
|
+
}
|
|
651
|
+
if (new Date(tokenRecord.expiresAt) < /* @__PURE__ */ new Date()) {
|
|
652
|
+
return { valid: false, error: "Token expired" };
|
|
653
|
+
}
|
|
654
|
+
const client = await strapi.documents(`${PLUGIN_UID$1}.mcp-oauth-client`).findFirst({
|
|
655
|
+
filters: { clientId: tokenRecord.clientId }
|
|
656
|
+
});
|
|
657
|
+
if (!client) {
|
|
658
|
+
return { valid: false, error: "Client not found" };
|
|
659
|
+
}
|
|
660
|
+
return {
|
|
661
|
+
valid: true,
|
|
662
|
+
strapiApiToken: client.strapiApiToken,
|
|
663
|
+
clientId: client.clientId
|
|
664
|
+
};
|
|
665
|
+
} catch (error) {
|
|
666
|
+
strapi.log.error(`[${PLUGIN_ID}] Error validating token`, { error });
|
|
667
|
+
return { valid: false, error: "Token validation failed" };
|
|
668
|
+
}
|
|
669
|
+
},
|
|
670
|
+
/**
|
|
671
|
+
* Revoke all tokens for a client
|
|
672
|
+
*/
|
|
673
|
+
async revokeClientTokens(clientId) {
|
|
674
|
+
const tokens = await strapi.documents(`${PLUGIN_UID$1}.mcp-oauth-token`).findMany({
|
|
675
|
+
filters: { clientId, revoked: false }
|
|
676
|
+
});
|
|
677
|
+
for (const token of tokens) {
|
|
678
|
+
await strapi.documents(`${PLUGIN_UID$1}.mcp-oauth-token`).update({
|
|
679
|
+
documentId: token.documentId,
|
|
680
|
+
data: { revoked: true }
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
return tokens.length;
|
|
684
|
+
},
|
|
685
|
+
/**
|
|
686
|
+
* Clean up expired tokens and codes
|
|
687
|
+
*/
|
|
688
|
+
async cleanupExpired() {
|
|
689
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
690
|
+
const expiredCodes = await strapi.documents(`${PLUGIN_UID$1}.mcp-oauth-code`).findMany({
|
|
691
|
+
filters: { expiresAt: { $lt: now } }
|
|
692
|
+
});
|
|
693
|
+
for (const code of expiredCodes) {
|
|
694
|
+
await strapi.documents(`${PLUGIN_UID$1}.mcp-oauth-code`).delete({
|
|
695
|
+
documentId: code.documentId
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
const expiredTokens = await strapi.documents(`${PLUGIN_UID$1}.mcp-oauth-token`).findMany({
|
|
699
|
+
filters: { refreshExpiresAt: { $lt: now } }
|
|
700
|
+
});
|
|
701
|
+
for (const token of expiredTokens) {
|
|
702
|
+
await strapi.documents(`${PLUGIN_UID$1}.mcp-oauth-token`).delete({
|
|
703
|
+
documentId: token.documentId
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
return {
|
|
707
|
+
tokens: expiredTokens.length,
|
|
708
|
+
codes: expiredCodes.length
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
const PLUGIN_UID = `plugin::${PLUGIN_ID}`;
|
|
713
|
+
const endpointService = ({ strapi }) => ({
|
|
714
|
+
/**
|
|
715
|
+
* Register an MCP endpoint for OAuth protection
|
|
716
|
+
*/
|
|
717
|
+
async register(endpoint) {
|
|
718
|
+
const existing = await strapi.documents(`${PLUGIN_UID}.mcp-endpoint`).findFirst({
|
|
719
|
+
filters: { path: endpoint.path }
|
|
720
|
+
});
|
|
721
|
+
if (existing) {
|
|
722
|
+
return strapi.documents(`${PLUGIN_UID}.mcp-endpoint`).update({
|
|
723
|
+
documentId: existing.documentId,
|
|
724
|
+
data: {
|
|
725
|
+
name: endpoint.name,
|
|
726
|
+
pluginId: endpoint.pluginId,
|
|
727
|
+
description: endpoint.description,
|
|
728
|
+
active: true
|
|
729
|
+
}
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
return strapi.documents(`${PLUGIN_UID}.mcp-endpoint`).create({
|
|
733
|
+
data: {
|
|
734
|
+
name: endpoint.name,
|
|
735
|
+
pluginId: endpoint.pluginId,
|
|
736
|
+
path: endpoint.path,
|
|
737
|
+
description: endpoint.description,
|
|
738
|
+
active: true
|
|
739
|
+
}
|
|
740
|
+
});
|
|
741
|
+
},
|
|
742
|
+
/**
|
|
743
|
+
* Unregister an MCP endpoint
|
|
744
|
+
*/
|
|
745
|
+
async unregister(path) {
|
|
746
|
+
const endpoint = await strapi.documents(`${PLUGIN_UID}.mcp-endpoint`).findFirst({
|
|
747
|
+
filters: { path }
|
|
748
|
+
});
|
|
749
|
+
if (!endpoint) {
|
|
750
|
+
return false;
|
|
751
|
+
}
|
|
752
|
+
await strapi.documents(`${PLUGIN_UID}.mcp-endpoint`).update({
|
|
753
|
+
documentId: endpoint.documentId,
|
|
754
|
+
data: { active: false }
|
|
755
|
+
});
|
|
756
|
+
return true;
|
|
757
|
+
},
|
|
758
|
+
/**
|
|
759
|
+
* Get all registered endpoints
|
|
760
|
+
*/
|
|
761
|
+
async getAll(activeOnly = true) {
|
|
762
|
+
const filters = activeOnly ? { active: true } : {};
|
|
763
|
+
return strapi.documents(`${PLUGIN_UID}.mcp-endpoint`).findMany({ filters });
|
|
764
|
+
},
|
|
765
|
+
/**
|
|
766
|
+
* Get endpoints for a specific plugin
|
|
767
|
+
*/
|
|
768
|
+
async getByPlugin(pluginId, activeOnly = true) {
|
|
769
|
+
const filters = { pluginId };
|
|
770
|
+
if (activeOnly) {
|
|
771
|
+
filters.active = true;
|
|
772
|
+
}
|
|
773
|
+
return strapi.documents(`${PLUGIN_UID}.mcp-endpoint`).findMany({ filters });
|
|
774
|
+
},
|
|
775
|
+
/**
|
|
776
|
+
* Check if a path is a protected endpoint
|
|
777
|
+
*/
|
|
778
|
+
async isProtected(path) {
|
|
779
|
+
const endpoints = await this.getAll(true);
|
|
780
|
+
return endpoints.some((e) => path.includes(e.path));
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
const services = {
|
|
784
|
+
oauth: oauthService,
|
|
785
|
+
endpoint: endpointService
|
|
786
|
+
};
|
|
787
|
+
const index = {
|
|
788
|
+
register,
|
|
789
|
+
bootstrap,
|
|
790
|
+
destroy,
|
|
791
|
+
config,
|
|
792
|
+
controllers,
|
|
793
|
+
routes,
|
|
794
|
+
services,
|
|
795
|
+
contentTypes,
|
|
796
|
+
policies,
|
|
797
|
+
middlewares
|
|
798
|
+
};
|
|
799
|
+
export {
|
|
800
|
+
index as default
|
|
801
|
+
};
|