strapi-mcp-server 0.1.1

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.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +415 -0
  3. package/admin/src/components/PageHeader.tsx +33 -0
  4. package/admin/src/components/Sidebar.tsx +138 -0
  5. package/admin/src/index.tsx +54 -0
  6. package/admin/src/lib/api.ts +27 -0
  7. package/admin/src/lib/applyQuery.ts +152 -0
  8. package/admin/src/pages/App.tsx +126 -0
  9. package/admin/src/pages/AuditLog.tsx +386 -0
  10. package/admin/src/pages/Clients.tsx +465 -0
  11. package/admin/src/pages/EditClient.tsx +248 -0
  12. package/admin/src/pages/HomePage.tsx +378 -0
  13. package/admin/src/pages/NewClient.tsx +244 -0
  14. package/admin/src/pages/Settings.tsx +514 -0
  15. package/admin/src/pages/SsoBridge.tsx +96 -0
  16. package/admin/src/pages/Tools.tsx +68 -0
  17. package/admin/src/pluginId.ts +1 -0
  18. package/admin/src/translations/en.json +8 -0
  19. package/package.json +105 -0
  20. package/server/src/bootstrap.ts +118 -0
  21. package/server/src/config/index.ts +290 -0
  22. package/server/src/content-types/audit-log/index.ts +3 -0
  23. package/server/src/content-types/audit-log/schema.json +32 -0
  24. package/server/src/content-types/index.ts +19 -0
  25. package/server/src/content-types/oauth-auth-code/index.ts +3 -0
  26. package/server/src/content-types/oauth-auth-code/schema.json +31 -0
  27. package/server/src/content-types/oauth-client/index.ts +3 -0
  28. package/server/src/content-types/oauth-client/schema.json +33 -0
  29. package/server/src/content-types/oauth-consent/index.ts +3 -0
  30. package/server/src/content-types/oauth-consent/schema.json +21 -0
  31. package/server/src/content-types/oauth-refresh-token/index.ts +3 -0
  32. package/server/src/content-types/oauth-refresh-token/schema.json +25 -0
  33. package/server/src/content-types/oauth-revocation/index.ts +3 -0
  34. package/server/src/content-types/oauth-revocation/schema.json +18 -0
  35. package/server/src/content-types/oauth-signing-key/index.ts +3 -0
  36. package/server/src/content-types/oauth-signing-key/schema.json +21 -0
  37. package/server/src/controllers/admin/audit.ts +30 -0
  38. package/server/src/controllers/admin/clients.ts +148 -0
  39. package/server/src/controllers/admin/dashboard.ts +28 -0
  40. package/server/src/controllers/admin/index.ts +15 -0
  41. package/server/src/controllers/admin/settings.ts +38 -0
  42. package/server/src/controllers/admin/tools.ts +23 -0
  43. package/server/src/controllers/index.ts +13 -0
  44. package/server/src/controllers/mcp.ts +168 -0
  45. package/server/src/controllers/oauth/authorize.ts +418 -0
  46. package/server/src/controllers/oauth/index.ts +15 -0
  47. package/server/src/controllers/oauth/introspect.ts +45 -0
  48. package/server/src/controllers/oauth/metadata.ts +86 -0
  49. package/server/src/controllers/oauth/mode-guard.ts +22 -0
  50. package/server/src/controllers/oauth/register.ts +109 -0
  51. package/server/src/controllers/oauth/token.ts +206 -0
  52. package/server/src/controllers/proxy.ts +81 -0
  53. package/server/src/destroy.ts +28 -0
  54. package/server/src/index.ts +23 -0
  55. package/server/src/policies/authenticate.ts +81 -0
  56. package/server/src/policies/index.ts +13 -0
  57. package/server/src/policies/origin.ts +50 -0
  58. package/server/src/policies/rateLimit.ts +27 -0
  59. package/server/src/policies/scope.ts +32 -0
  60. package/server/src/register.ts +48 -0
  61. package/server/src/routes/admin.ts +85 -0
  62. package/server/src/routes/index.ts +13 -0
  63. package/server/src/routes/mcp.ts +31 -0
  64. package/server/src/routes/oauth.ts +81 -0
  65. package/server/src/routes/proxy.ts +29 -0
  66. package/server/src/services/audit.ts +158 -0
  67. package/server/src/services/heartbeat.ts +76 -0
  68. package/server/src/services/index.ts +37 -0
  69. package/server/src/services/instance-id.ts +30 -0
  70. package/server/src/services/mcp-server.ts +100 -0
  71. package/server/src/services/oauth/audience.ts +26 -0
  72. package/server/src/services/oauth/auth-codes.ts +78 -0
  73. package/server/src/services/oauth/clients.ts +386 -0
  74. package/server/src/services/oauth/consent.ts +38 -0
  75. package/server/src/services/oauth/errors.ts +32 -0
  76. package/server/src/services/oauth/pkce.ts +34 -0
  77. package/server/src/services/oauth/scopes.ts +42 -0
  78. package/server/src/services/oauth/signing-keys.ts +166 -0
  79. package/server/src/services/oauth/tokens.ts +324 -0
  80. package/server/src/services/permissions.ts +87 -0
  81. package/server/src/services/proxy-client.ts +167 -0
  82. package/server/src/services/rate-limiter.ts +180 -0
  83. package/server/src/services/redis.ts +139 -0
  84. package/server/src/services/session-directory.ts +121 -0
  85. package/server/src/services/session-store.ts +216 -0
  86. package/server/src/services/sso-cookie.ts +146 -0
  87. package/server/src/services/tools/content.ts +284 -0
  88. package/server/src/services/tools/index.ts +23 -0
  89. package/server/src/services/tools/media.ts +170 -0
@@ -0,0 +1,418 @@
1
+ 'use strict';
2
+
3
+ import type { Core } from '@strapi/strapi';
4
+ import type { Context } from 'koa';
5
+ import { randomBytes } from 'crypto';
6
+ import { parseScope, scopeString, isSubsetOf } from '../../services/oauth/scopes';
7
+ import { canonicalResourceUrl } from '../../services/oauth/audience';
8
+ import { ensureEmbeddedMode } from './mode-guard';
9
+
10
+ interface AuthorizeQuery {
11
+ response_type?: string;
12
+ client_id?: string;
13
+ redirect_uri?: string;
14
+ scope?: string;
15
+ state?: string;
16
+ code_challenge?: string;
17
+ code_challenge_method?: string;
18
+ resource?: string;
19
+ }
20
+
21
+ function htmlEscape(s: string): string {
22
+ return s.replace(/[&<>"']/g, (c) =>
23
+ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] as string
24
+ );
25
+ }
26
+
27
+ function renderError(ctx: Context, status: number, code: string, description: string): void {
28
+ ctx.status = status;
29
+ ctx.set('Referrer-Policy', 'no-referrer');
30
+ ctx.type = 'text/html';
31
+ ctx.body = `<!doctype html><html><head><title>OAuth error</title></head><body>
32
+ <h1>${htmlEscape(code)}</h1>
33
+ <p>${htmlEscape(description)}</p>
34
+ </body></html>`;
35
+ }
36
+
37
+ export default ({ strapi }: { strapi: Core.Strapi }) => ({
38
+ /**
39
+ * GET /oauth/authorize — PKCE S256-only, strict redirect_uri match. Renders
40
+ * a consent screen if the admin has an active SSO cookie; otherwise bounces
41
+ * through the admin login → SsoBridge → back to this endpoint.
42
+ *
43
+ * All validation happens *before* any redirect (open-redirect mitigation).
44
+ */
45
+ async start(ctx: Context): Promise<void> {
46
+ if (!ensureEmbeddedMode(ctx, strapi)) return;
47
+ const q = ctx.query as AuthorizeQuery;
48
+ if (q.response_type !== 'code') {
49
+ return renderError(ctx, 400, 'unsupported_response_type', 'only response_type=code is supported');
50
+ }
51
+ if (!q.client_id) return renderError(ctx, 400, 'invalid_request', 'client_id required');
52
+ if (!q.redirect_uri) return renderError(ctx, 400, 'invalid_request', 'redirect_uri required');
53
+ if (q.code_challenge_method !== 'S256') {
54
+ return renderError(ctx, 400, 'invalid_request', 'code_challenge_method must be S256');
55
+ }
56
+ if (!q.code_challenge || q.code_challenge.length < 43 || q.code_challenge.length > 128) {
57
+ return renderError(ctx, 400, 'invalid_request', 'invalid code_challenge');
58
+ }
59
+ if (!q.state) return renderError(ctx, 400, 'invalid_request', 'state required');
60
+ if (q.resource !== canonicalResourceUrl(strapi)) {
61
+ return renderError(ctx, 400, 'invalid_target', 'resource indicator mismatch');
62
+ }
63
+
64
+ const clientsSvc = strapi.plugin('mcp-server').service('clients');
65
+ const client = await clientsSvc.findActive(q.client_id);
66
+ if (!client) return renderError(ctx, 400, 'invalid_request', 'unknown client');
67
+ if (!clientsSvc.isAllowedRedirectUri(client, q.redirect_uri)) {
68
+ return renderError(ctx, 400, 'invalid_request', 'redirect_uri not allowed for this client');
69
+ }
70
+
71
+ const requestedScopes = parseScope(q.scope);
72
+ if (requestedScopes.length === 0) {
73
+ return renderError(ctx, 400, 'invalid_scope', 'no valid scopes requested');
74
+ }
75
+ if (!isSubsetOf(requestedScopes, client.scopes)) {
76
+ return renderError(ctx, 400, 'invalid_scope', 'requested scopes exceed client grant');
77
+ }
78
+
79
+ // Resource-owner authentication.
80
+ const ssoSvc = strapi.plugin('mcp-server').service('sso-cookie');
81
+ const cookieVal = ctx.cookies.get(ssoSvc.cookieName());
82
+ const adminId = await ssoSvc.verify(cookieVal);
83
+ if (!adminId) {
84
+ const resume = ctx.originalUrl;
85
+ // Set a signed resume cookie as the source of truth. Strapi's AuthPage
86
+ // double-decodes its redirectTo param, which mangles nested OAuth query
87
+ // strings — so we cannot trust the `next=` URL param to survive the
88
+ // login round-trip. The cookie is read back at /oauth/sso-handoff and
89
+ // cleared on success. The URL param remains as best-effort fallback.
90
+ const { value, maxAgeSec } = await ssoSvc.issueResume(resume);
91
+ ctx.cookies.set(ssoSvc.resumeCookieName(), value, {
92
+ httpOnly: true,
93
+ sameSite: 'lax',
94
+ secure: ctx.protocol === 'https',
95
+ maxAge: maxAgeSec * 1000,
96
+ signed: false,
97
+ });
98
+ // Send the user straight to the SsoBridge route. The admin SPA's
99
+ // PrivateRoute will either:
100
+ // - render SsoBridge immediately if the admin is already logged in, or
101
+ // - redirect to /admin/auth/login?redirectTo=... and come back here
102
+ // after login.
103
+ // We DO NOT redirect to /admin/auth/login ourselves — Strapi's AuthPage
104
+ // hard-redirects already-logged-in users to "/" and ignores redirectTo.
105
+ const bridgePath = `/admin/plugins/mcp-server/sso-bridge?next=${encodeURIComponent(resume)}`;
106
+ ctx.redirect(bridgePath);
107
+ return;
108
+ }
109
+
110
+ // Optional pre-existing consent (default config disables remember).
111
+ const consentSvc = strapi.plugin('mcp-server').service('consent');
112
+ if (await consentSvc.hasActiveConsent(client.clientId, adminId, requestedScopes)) {
113
+ const code = await issueAuthCode({
114
+ strapi,
115
+ clientId: client.clientId,
116
+ adminUserId: adminId,
117
+ scope: scopeString(requestedScopes),
118
+ redirectUri: q.redirect_uri,
119
+ codeChallenge: q.code_challenge,
120
+ resource: q.resource,
121
+ });
122
+ const target = new URL(q.redirect_uri);
123
+ target.searchParams.set('code', code);
124
+ target.searchParams.set('state', q.state);
125
+ ctx.redirect(target.toString());
126
+ return;
127
+ }
128
+
129
+ // Render consent screen with CSRF token bound to SSO cookie.
130
+ const csrf = randomBytes(24).toString('base64url');
131
+ const csrfCookieName = 'mcp_consent_csrf';
132
+ ctx.cookies.set(csrfCookieName, csrf, {
133
+ httpOnly: true,
134
+ sameSite: 'lax',
135
+ secure: ctx.protocol === 'https',
136
+ maxAge: 10 * 60 * 1000,
137
+ signed: false,
138
+ });
139
+ // Use `same-origin` (not `no-referrer`): keeps the Origin header on the
140
+ // form POST to /oauth/consent (same-origin) so our origin policy can verify
141
+ // it, but still strips Referer/Origin on the cross-origin redirect to the
142
+ // client's redirect_uri — so the OAuth `code` does not leak via Referer.
143
+ ctx.set('Referrer-Policy', 'same-origin');
144
+ ctx.set('Cache-Control', 'no-store');
145
+ // Override Strapi's default CSP (form-action 'self') for this response so
146
+ // the browser will follow the cross-origin 302 to the client's redirect_uri
147
+ // after consent. The redirect_uri has already been validated against the
148
+ // client's strict exact-match allowlist, so widening form-action to the
149
+ // specific origin we'll redirect to is safe.
150
+ try {
151
+ const redirectOrigin = new URL(q.redirect_uri).origin;
152
+ ctx.set(
153
+ 'Content-Security-Policy',
154
+ `default-src 'none'; style-src 'unsafe-inline'; form-action 'self' ${redirectOrigin}; base-uri 'none'; frame-ancestors 'none'`
155
+ );
156
+ } catch {
157
+ /* unreachable — redirect_uri was validated above */
158
+ }
159
+ ctx.type = 'text/html';
160
+ const SCOPE_LABELS = (strapi
161
+ .plugin('mcp-server')
162
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
163
+ .service('clients') as any).SCOPE_LABELS;
164
+ void SCOPE_LABELS;
165
+ const labels: Record<string, string> = {
166
+ 'strapi:content:read': 'Read content (list types, schemas, entries)',
167
+ 'strapi:content:write': 'Create and update content entries (draft only)',
168
+ 'strapi:media:read': 'List media files',
169
+ 'strapi:media:write': 'Upload media files',
170
+ };
171
+ ctx.body = renderConsent({
172
+ clientName: client.clientName,
173
+ scopes: requestedScopes.map((s) => labels[s] ?? s),
174
+ resource: canonicalResourceUrl(strapi),
175
+ csrf,
176
+ hidden: {
177
+ client_id: client.clientId,
178
+ redirect_uri: q.redirect_uri,
179
+ scope: scopeString(requestedScopes),
180
+ state: q.state,
181
+ code_challenge: q.code_challenge,
182
+ code_challenge_method: 'S256',
183
+ resource: q.resource,
184
+ },
185
+ });
186
+ },
187
+
188
+ /**
189
+ * POST /oauth/consent — admin approves. Validates CSRF and SSO cookie again,
190
+ * mints an auth code, redirects to the registered redirect_uri.
191
+ */
192
+ async consent(ctx: Context): Promise<void> {
193
+ if (!ensureEmbeddedMode(ctx, strapi)) return;
194
+ ctx.set('Cache-Control', 'no-store');
195
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
196
+ const body = ((ctx.request as any).body ?? {}) as Record<string, string>;
197
+ const csrfCookieName = 'mcp_consent_csrf';
198
+ const cookieCsrf = ctx.cookies.get(csrfCookieName);
199
+ if (!cookieCsrf || cookieCsrf !== body.csrf) {
200
+ return renderError(ctx, 403, 'invalid_request', 'CSRF mismatch');
201
+ }
202
+ ctx.cookies.set(csrfCookieName, '', { maxAge: 0 });
203
+
204
+ const ssoSvc = strapi.plugin('mcp-server').service('sso-cookie');
205
+ const adminId = await ssoSvc.verify(ctx.cookies.get(ssoSvc.cookieName()));
206
+ if (!adminId) return renderError(ctx, 401, 'invalid_request', 'SSO expired');
207
+
208
+ if (body.decision !== 'approve') {
209
+ const target = new URL(body.redirect_uri);
210
+ target.searchParams.set('error', 'access_denied');
211
+ if (body.state) target.searchParams.set('state', body.state);
212
+ ctx.redirect(target.toString());
213
+ return;
214
+ }
215
+
216
+ // Re-validate inputs server-side — the form may have been tampered with.
217
+ const clientsSvc = strapi.plugin('mcp-server').service('clients');
218
+ const client = await clientsSvc.findActive(body.client_id);
219
+ if (!client) return renderError(ctx, 400, 'invalid_request', 'unknown client');
220
+ if (!clientsSvc.isAllowedRedirectUri(client, body.redirect_uri)) {
221
+ return renderError(ctx, 400, 'invalid_request', 'redirect_uri not allowed');
222
+ }
223
+ if (body.code_challenge_method !== 'S256' || !body.code_challenge) {
224
+ return renderError(ctx, 400, 'invalid_request', 'bad challenge');
225
+ }
226
+ if (body.resource !== canonicalResourceUrl(strapi)) {
227
+ return renderError(ctx, 400, 'invalid_target', 'resource mismatch');
228
+ }
229
+ const scopes = parseScope(body.scope);
230
+ if (scopes.length === 0 || !isSubsetOf(scopes, client.scopes)) {
231
+ return renderError(ctx, 400, 'invalid_scope', 'scope mismatch');
232
+ }
233
+
234
+ const code = await issueAuthCode({
235
+ strapi,
236
+ clientId: client.clientId,
237
+ adminUserId: adminId,
238
+ scope: scopeString(scopes),
239
+ redirectUri: body.redirect_uri,
240
+ codeChallenge: body.code_challenge,
241
+ resource: body.resource,
242
+ });
243
+
244
+ await strapi.plugin('mcp-server').service('consent').record(client.clientId, adminId, scopes);
245
+
246
+ // Claim DCR-registered clients (no owner) for the first admin to approve
247
+ // consent — the Clients UI surfaces this as "created/connected by".
248
+ if (!client.ownerAdminId) {
249
+ await clientsSvc.setOwner(client.clientId, adminId);
250
+ }
251
+
252
+ // Sweep sibling DCR orphans (same name + redirect URIs, no owner, no
253
+ // consent/code/token records). MCP libraries commonly hit /oauth/register
254
+ // multiple times during connect; only the one that reached consent should
255
+ // remain in the Clients table.
256
+ await clientsSvc.purgeOrphansLike({
257
+ clientId: client.clientId,
258
+ clientName: client.clientName,
259
+ redirectUris: client.redirectUris,
260
+ });
261
+
262
+ // Audit the consent grant attributed to the approving admin — this is the
263
+ // entry that ties a human identity to the client, since DCR itself is
264
+ // unauthenticated and audits as #anonymous.
265
+ strapi.plugin('mcp-server').service('audit').record({
266
+ ts: new Date(),
267
+ principalType: 'admin',
268
+ principalId: adminId,
269
+ clientId: client.clientId,
270
+ tool: 'oauth.consent.grant',
271
+ params: { scopes, redirectUri: body.redirect_uri },
272
+ resultStatus: 'ok',
273
+ ip: ctx.ip ?? ctx.request.ip,
274
+ userAgent: ctx.request.header['user-agent'] as string | undefined,
275
+ });
276
+
277
+ const target = new URL(body.redirect_uri);
278
+ target.searchParams.set('code', code);
279
+ if (body.state) target.searchParams.set('state', body.state);
280
+ ctx.redirect(target.toString());
281
+ },
282
+
283
+ /**
284
+ * POST /oauth/sso-handoff — called by the admin-UI SPA bridge after a
285
+ * successful admin login. Body: { adminToken }. Verifies it against
286
+ * strapi.sessionManager('admin').validateAccessToken (Strapi v5's
287
+ * session-aware verifier — `decodeJwtToken` no longer exists), sets the
288
+ * mcp_admin_sso cookie, returns { next }.
289
+ */
290
+ async ssoHandoff(ctx: Context): Promise<void> {
291
+ if (!ensureEmbeddedMode(ctx, strapi)) return;
292
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
293
+ const body = ((ctx.request as any).body ?? {}) as { adminToken?: string; next?: string };
294
+ if (!body.adminToken) {
295
+ ctx.status = 400;
296
+ ctx.body = { error: 'missing_admin_token' };
297
+ return;
298
+ }
299
+ let decoded: {
300
+ isValid: boolean;
301
+ payload: { userId?: string | number; sessionId?: string } | null;
302
+ };
303
+ try {
304
+ decoded = strapi
305
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
306
+ .sessionManager('admin' as any)
307
+ .validateAccessToken(body.adminToken);
308
+ } catch {
309
+ ctx.status = 401;
310
+ ctx.body = { error: 'invalid_admin_token' };
311
+ return;
312
+ }
313
+ if (!decoded?.isValid || !decoded.payload?.userId || !decoded.payload?.sessionId) {
314
+ ctx.status = 401;
315
+ ctx.body = { error: 'invalid_admin_token' };
316
+ return;
317
+ }
318
+ const principal = await strapi
319
+ .plugin('mcp-server')
320
+ .service('permissions')
321
+ .loadPrincipal(decoded.payload.userId);
322
+ if (!principal) {
323
+ ctx.status = 401;
324
+ ctx.body = { error: 'principal_unavailable' };
325
+ return;
326
+ }
327
+ const ssoSvc = strapi.plugin('mcp-server').service('sso-cookie');
328
+ const { value, maxAgeSec } = await ssoSvc.issue(
329
+ String(decoded.payload.userId),
330
+ decoded.payload.sessionId
331
+ );
332
+ ctx.cookies.set(ssoSvc.cookieName(), value, {
333
+ httpOnly: true,
334
+ sameSite: 'lax',
335
+ secure: ctx.protocol === 'https',
336
+ maxAge: maxAgeSec * 1000,
337
+ signed: false,
338
+ });
339
+ // Prefer the signed resume cookie set at /oauth/authorize redirect time —
340
+ // it carries the canonical OAuth URL untouched by Strapi's login flow
341
+ // (which double-decodes its redirectTo param and mangles nested query
342
+ // strings). Fall back to body.next, then the plugin home.
343
+ const resumeFromCookie = await ssoSvc.verifyResume(
344
+ ctx.cookies.get(ssoSvc.resumeCookieName())
345
+ );
346
+ if (resumeFromCookie) {
347
+ ctx.cookies.set(ssoSvc.resumeCookieName(), '', { maxAge: 0, signed: false });
348
+ }
349
+ ctx.body = {
350
+ ok: true,
351
+ next: resumeFromCookie ?? body.next ?? '/admin/plugins/mcp-server',
352
+ };
353
+ },
354
+ });
355
+
356
+ async function issueAuthCode(input: {
357
+ strapi: Core.Strapi;
358
+ clientId: string;
359
+ adminUserId: string;
360
+ scope: string;
361
+ redirectUri: string;
362
+ codeChallenge: string;
363
+ resource: string;
364
+ }): Promise<string> {
365
+ return input.strapi.plugin('mcp-server').service('auth-codes').issue({
366
+ clientId: input.clientId,
367
+ adminUserId: input.adminUserId,
368
+ scope: input.scope,
369
+ redirectUri: input.redirectUri,
370
+ codeChallenge: input.codeChallenge,
371
+ resource: input.resource,
372
+ });
373
+ }
374
+
375
+ function renderConsent(opts: {
376
+ clientName: string;
377
+ scopes: string[];
378
+ resource: string;
379
+ csrf: string;
380
+ hidden: Record<string, string>;
381
+ }): string {
382
+ const hiddenInputs = Object.entries(opts.hidden)
383
+ .map(
384
+ ([k, v]) =>
385
+ `<input type="hidden" name="${htmlEscape(k)}" value="${htmlEscape(v)}" />`
386
+ )
387
+ .join('\n');
388
+ const scopes = opts.scopes.map((s) => `<li>${htmlEscape(s)}</li>`).join('');
389
+ return `<!doctype html>
390
+ <html>
391
+ <head>
392
+ <meta charset="utf-8" />
393
+ <title>Authorize MCP client</title>
394
+ <meta name="referrer" content="same-origin" />
395
+ <style>
396
+ body { font-family: -apple-system, system-ui, sans-serif; max-width: 540px; margin: 60px auto; color: #1f1f1f; }
397
+ h1 { font-size: 20px; }
398
+ .client { font-weight: 600; }
399
+ ul { background: #f6f6f7; padding: 16px 16px 16px 32px; border-radius: 6px; }
400
+ button { padding: 10px 18px; font-size: 14px; border-radius: 4px; cursor: pointer; }
401
+ .approve { background: #4945ff; color: #fff; border: 0; margin-right: 8px; }
402
+ .deny { background: #fff; border: 1px solid #d0d0d0; color: #1f1f1f; }
403
+ .resource { color: #666; font-size: 13px; }
404
+ </style>
405
+ </head>
406
+ <body>
407
+ <h1>Authorize <span class="client">${htmlEscape(opts.clientName)}</span></h1>
408
+ <p>This MCP client is requesting the following permissions on <code class="resource">${htmlEscape(opts.resource)}</code>:</p>
409
+ <ul>${scopes}</ul>
410
+ <form method="POST" action="/oauth/consent">
411
+ ${hiddenInputs}
412
+ <input type="hidden" name="csrf" value="${htmlEscape(opts.csrf)}" />
413
+ <button class="approve" type="submit" name="decision" value="approve">Approve</button>
414
+ <button class="deny" type="submit" name="decision" value="deny">Deny</button>
415
+ </form>
416
+ </body>
417
+ </html>`;
418
+ }
@@ -0,0 +1,15 @@
1
+ 'use strict';
2
+
3
+ import metadata from './metadata';
4
+ import authorize from './authorize';
5
+ import token from './token';
6
+ import introspect from './introspect';
7
+ import dcrRegister from './register';
8
+
9
+ export default {
10
+ metadata,
11
+ authorize,
12
+ token,
13
+ introspect,
14
+ 'dcr-register': dcrRegister,
15
+ };
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+
3
+ import type { Core } from '@strapi/strapi';
4
+ import type { Context } from 'koa';
5
+ import { getConfig } from '../../config';
6
+ import { ensureEmbeddedMode } from './mode-guard';
7
+
8
+ export default ({ strapi }: { strapi: Core.Strapi }) => ({
9
+ /**
10
+ * RFC 7662 introspection — internal use only, IP-allowlisted.
11
+ * Without this guard, introspection is a token-validity oracle attackers can
12
+ * leverage; default config restricts to loopback.
13
+ */
14
+ async introspect(ctx: Context): Promise<void> {
15
+ if (!ensureEmbeddedMode(ctx, strapi)) return;
16
+ const cfg = getConfig(strapi);
17
+ const ip = ctx.ip ?? ctx.request.ip ?? '';
18
+ if (!cfg.oauth.introspection.allowedIps.includes(ip)) {
19
+ ctx.status = 403;
20
+ ctx.body = { active: false };
21
+ return;
22
+ }
23
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
24
+ const body = ((ctx.request as any).body ?? {}) as { token?: string };
25
+ if (!body.token) {
26
+ ctx.body = { active: false };
27
+ return;
28
+ }
29
+ try {
30
+ const claims = await strapi
31
+ .plugin('mcp-server')
32
+ .service('tokens')
33
+ .verifyAccessToken(body.token);
34
+ ctx.body = {
35
+ active: true,
36
+ sub: claims.sub,
37
+ scope: claims.scope.join(' '),
38
+ client_id: claims.clientId,
39
+ exp: claims.exp,
40
+ };
41
+ } catch {
42
+ ctx.body = { active: false };
43
+ }
44
+ },
45
+ });
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+
3
+ import type { Core } from '@strapi/strapi';
4
+ import type { Context } from 'koa';
5
+ import { authorizationServerUrl, canonicalResourceUrl } from '../../services/oauth/audience';
6
+ import { ALL_SCOPES } from '../../services/oauth/scopes';
7
+ import { getConfig } from '../../config';
8
+
9
+ export default ({ strapi }: { strapi: Core.Strapi }) => ({
10
+ /**
11
+ * RFC 9728 — points the client at the AS that issued tokens for this RS.
12
+ * In external mode the AS is the operator-configured external issuer; in
13
+ * embedded mode it's this server's root URL.
14
+ */
15
+ protectedResource(ctx: Context): void {
16
+ const cfg = getConfig(strapi);
17
+ const resource = canonicalResourceUrl(strapi);
18
+ const externalMode = cfg.oauth.mode === 'external' && !!cfg.oauth.external;
19
+ const asUrl = externalMode
20
+ ? cfg.oauth.external!.issuer
21
+ : authorizationServerUrl(strapi);
22
+
23
+ const body: Record<string, unknown> = {
24
+ resource,
25
+ authorization_servers: [asUrl],
26
+ bearer_methods_supported: ['header'],
27
+ resource_documentation: `${asUrl}/.well-known/oauth-authorization-server`,
28
+ };
29
+
30
+ // Only advertise `strapi:*` scopes when this server is the AS (embedded
31
+ // mode) or when the operator has opted into IdP-side scope enforcement.
32
+ // Otherwise clients shouldn't request scopes the external IdP doesn't
33
+ // know about — they'd fail with `invalid_scope`.
34
+ const advertiseScopes = !externalMode || cfg.oauth.external?.enforceScopes === true;
35
+ if (advertiseScopes) {
36
+ body.scopes_supported = ALL_SCOPES;
37
+ }
38
+
39
+ ctx.body = body;
40
+ },
41
+
42
+ /**
43
+ * RFC 8414 — Authorization Server metadata. Only valid in embedded mode;
44
+ * in external mode the external AS publishes its own metadata at its own
45
+ * URL, so we 404 to avoid lying.
46
+ */
47
+ authorizationServer(ctx: Context): void {
48
+ const cfg = getConfig(strapi);
49
+ if (cfg.oauth.mode === 'external') {
50
+ ctx.status = 404;
51
+ ctx.body = { error: 'not_found', error_description: 'AS metadata served by external issuer' };
52
+ return;
53
+ }
54
+ const asUrl = authorizationServerUrl(strapi);
55
+ const body: Record<string, unknown> = {
56
+ issuer: asUrl,
57
+ authorization_endpoint: `${asUrl}/oauth/authorize`,
58
+ token_endpoint: `${asUrl}/oauth/token`,
59
+ revocation_endpoint: `${asUrl}/oauth/revoke`,
60
+ introspection_endpoint: `${asUrl}/oauth/introspect`,
61
+ jwks_uri: `${asUrl}/oauth/jwks`,
62
+ response_types_supported: ['code'],
63
+ grant_types_supported: ['authorization_code', 'refresh_token'],
64
+ code_challenge_methods_supported: ['S256'],
65
+ token_endpoint_auth_methods_supported: ['none', 'client_secret_basic', 'client_secret_post'],
66
+ scopes_supported: ALL_SCOPES,
67
+ response_modes_supported: ['query'],
68
+ };
69
+ if (cfg.oauth.dcr.enabled) {
70
+ body.registration_endpoint = `${asUrl}/oauth/register`;
71
+ }
72
+ ctx.body = body;
73
+ },
74
+
75
+ async jwks(ctx: Context): Promise<void> {
76
+ const cfg = getConfig(strapi);
77
+ if (cfg.oauth.mode === 'external') {
78
+ ctx.status = 404;
79
+ ctx.body = { error: 'not_found', error_description: 'JWKS served by external issuer' };
80
+ return;
81
+ }
82
+ const sk = strapi.plugin('mcp-server').service('signing-keys');
83
+ ctx.body = await sk.publicJwks();
84
+ ctx.set('Cache-Control', 'public, max-age=300');
85
+ },
86
+ });
@@ -0,0 +1,22 @@
1
+ 'use strict';
2
+
3
+ import type { Core } from '@strapi/strapi';
4
+ import type { Context } from 'koa';
5
+ import { getConfig } from '../../config';
6
+
7
+ /**
8
+ * Short-circuit OAuth endpoints that only make sense in embedded AS mode.
9
+ * Returns true when the caller should proceed; returns false (and sets a 404
10
+ * response on ctx) when the plugin is configured to delegate to an external
11
+ * AS — in that case the external issuer owns these endpoints, not us.
12
+ */
13
+ export function ensureEmbeddedMode(ctx: Context, strapi: Core.Strapi): boolean {
14
+ const cfg = getConfig(strapi);
15
+ if (cfg.oauth.mode !== 'external') return true;
16
+ ctx.status = 404;
17
+ ctx.body = {
18
+ error: 'not_found',
19
+ error_description: 'OAuth AS endpoints are disabled; this plugin is configured to delegate to an external authorization server.',
20
+ };
21
+ return false;
22
+ }