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.
- package/LICENSE +21 -0
- package/README.md +415 -0
- package/admin/src/components/PageHeader.tsx +33 -0
- package/admin/src/components/Sidebar.tsx +138 -0
- package/admin/src/index.tsx +54 -0
- package/admin/src/lib/api.ts +27 -0
- package/admin/src/lib/applyQuery.ts +152 -0
- package/admin/src/pages/App.tsx +126 -0
- package/admin/src/pages/AuditLog.tsx +386 -0
- package/admin/src/pages/Clients.tsx +465 -0
- package/admin/src/pages/EditClient.tsx +248 -0
- package/admin/src/pages/HomePage.tsx +378 -0
- package/admin/src/pages/NewClient.tsx +244 -0
- package/admin/src/pages/Settings.tsx +514 -0
- package/admin/src/pages/SsoBridge.tsx +96 -0
- package/admin/src/pages/Tools.tsx +68 -0
- package/admin/src/pluginId.ts +1 -0
- package/admin/src/translations/en.json +8 -0
- package/package.json +105 -0
- package/server/src/bootstrap.ts +118 -0
- package/server/src/config/index.ts +290 -0
- package/server/src/content-types/audit-log/index.ts +3 -0
- package/server/src/content-types/audit-log/schema.json +32 -0
- package/server/src/content-types/index.ts +19 -0
- package/server/src/content-types/oauth-auth-code/index.ts +3 -0
- package/server/src/content-types/oauth-auth-code/schema.json +31 -0
- package/server/src/content-types/oauth-client/index.ts +3 -0
- package/server/src/content-types/oauth-client/schema.json +33 -0
- package/server/src/content-types/oauth-consent/index.ts +3 -0
- package/server/src/content-types/oauth-consent/schema.json +21 -0
- package/server/src/content-types/oauth-refresh-token/index.ts +3 -0
- package/server/src/content-types/oauth-refresh-token/schema.json +25 -0
- package/server/src/content-types/oauth-revocation/index.ts +3 -0
- package/server/src/content-types/oauth-revocation/schema.json +18 -0
- package/server/src/content-types/oauth-signing-key/index.ts +3 -0
- package/server/src/content-types/oauth-signing-key/schema.json +21 -0
- package/server/src/controllers/admin/audit.ts +30 -0
- package/server/src/controllers/admin/clients.ts +148 -0
- package/server/src/controllers/admin/dashboard.ts +28 -0
- package/server/src/controllers/admin/index.ts +15 -0
- package/server/src/controllers/admin/settings.ts +38 -0
- package/server/src/controllers/admin/tools.ts +23 -0
- package/server/src/controllers/index.ts +13 -0
- package/server/src/controllers/mcp.ts +168 -0
- package/server/src/controllers/oauth/authorize.ts +418 -0
- package/server/src/controllers/oauth/index.ts +15 -0
- package/server/src/controllers/oauth/introspect.ts +45 -0
- package/server/src/controllers/oauth/metadata.ts +86 -0
- package/server/src/controllers/oauth/mode-guard.ts +22 -0
- package/server/src/controllers/oauth/register.ts +109 -0
- package/server/src/controllers/oauth/token.ts +206 -0
- package/server/src/controllers/proxy.ts +81 -0
- package/server/src/destroy.ts +28 -0
- package/server/src/index.ts +23 -0
- package/server/src/policies/authenticate.ts +81 -0
- package/server/src/policies/index.ts +13 -0
- package/server/src/policies/origin.ts +50 -0
- package/server/src/policies/rateLimit.ts +27 -0
- package/server/src/policies/scope.ts +32 -0
- package/server/src/register.ts +48 -0
- package/server/src/routes/admin.ts +85 -0
- package/server/src/routes/index.ts +13 -0
- package/server/src/routes/mcp.ts +31 -0
- package/server/src/routes/oauth.ts +81 -0
- package/server/src/routes/proxy.ts +29 -0
- package/server/src/services/audit.ts +158 -0
- package/server/src/services/heartbeat.ts +76 -0
- package/server/src/services/index.ts +37 -0
- package/server/src/services/instance-id.ts +30 -0
- package/server/src/services/mcp-server.ts +100 -0
- package/server/src/services/oauth/audience.ts +26 -0
- package/server/src/services/oauth/auth-codes.ts +78 -0
- package/server/src/services/oauth/clients.ts +386 -0
- package/server/src/services/oauth/consent.ts +38 -0
- package/server/src/services/oauth/errors.ts +32 -0
- package/server/src/services/oauth/pkce.ts +34 -0
- package/server/src/services/oauth/scopes.ts +42 -0
- package/server/src/services/oauth/signing-keys.ts +166 -0
- package/server/src/services/oauth/tokens.ts +324 -0
- package/server/src/services/permissions.ts +87 -0
- package/server/src/services/proxy-client.ts +167 -0
- package/server/src/services/rate-limiter.ts +180 -0
- package/server/src/services/redis.ts +139 -0
- package/server/src/services/session-directory.ts +121 -0
- package/server/src/services/session-store.ts +216 -0
- package/server/src/services/sso-cookie.ts +146 -0
- package/server/src/services/tools/content.ts +284 -0
- package/server/src/services/tools/index.ts +23 -0
- 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
|
+
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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
|
+
}
|